Skip to main content

listing-service

Work on the WebScheduler Directory CodeIgniter 4 app (app/) — public listing/search/detail pages, signup+verify, the paid Verified Business badge (application, PayFast checkout, admin review queue, team members and extra branches), owner self-service manage flow, admin backend. Routes, models, services, settings precedence, local env, and end-to-end verify steps.

Source facts

Repository
niloc95/marketing
Last source activity
October 3, 2026 at 14:22
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
listing-service
description
Work on the WebScheduler Directory CodeIgniter 4 app (app/) — public listing/search/detail pages, signup+verify, the paid Verified Business badge (application, PayFast checkout, admin review queue, team members and extra branches), owner self-service manage flow, admin backend. Routes, models, services, settings precedence, local env, and end-to-end verify steps.
# Directory listing service (`app/`) Public directory SaaS: browse/search South African service businesses, public profile ("show") pages, "list your business" signup gated by email verification, owner self-service editing via a passwordless magic link, and an admin backend. Repo root `README.md` has the setup runbook — read it for env vars and Mailpit; this skill is the condensed working reference for making and verifying code changes. **Deploying is a separate skill: `deploy-production`.** Use it for releases, migrations against production, and diagnosing a deploy that went green without changing anything. `DEPLOY.md` is the first-time server setup. ## Routes (`app/Config/Routes.php`) | Route | Controller/action | |---|---| | `GET /` | `Directory::home` — featured listings, categories | | `GET /directory` | `Directory::index` — browse/search (`q`, `category`, `province`, `city`) | | `GET /directory/categories` | `Directory::categories` — all categories hub | | `GET /directory/suggest` | `Directory::suggest` — search typeahead JSON (see below) | | `GET /directory/at/{venue}` | `Directory::venue` — every business in one complex/mall/building | | `GET /directory/{category}/{province}` | `Directory::place` — landing page | | `GET /directory/{slug}` | `Directory::segment` → category landing page **or** falls through to `show($slug)`, the listing detail page — category wins so a listing can never claim a category's slug | | `GET /directory/verify/{token}` | `Directory::verify` — publishes a pending listing | | `GET/POST /add-listing` | `Listing::create` / `Listing::store` — public signup | | `GET /add-listing/verified` | `Listing::createVerified` — the **same** form and POST target in **verified-only** mode, where every link we control points. Not a second signup path; see "Signup form" below | | `GET /verified` | `Contact::verified` — public explainer for what the badge claims. Every badge on a profile links here | | `GET /compare` | `Contact::compare` — WebScheduler Local vs Google Business Profile, LinkedIn and SA directories. Table from `ComparisonService`; see "Comparison page" below | | `POST /manage/verification`, `GET /manage/verification/checkout`, `POST .../cancel`, `GET .../done` | `Manage::*` — apply, pay, cancel, PayFast return | | `POST /payfast/notify` | `PayFastNotify::index` — PayFast ITN | | `GET /admin/verifications`, `POST /admin/verifications/{id}/{approve,reject,activate,revoke}` | `Admin::*` — the review queue | | `GET/POST /manage` | `Manage::index` / `Manage::request` — owner requests an edit link by email | | `GET /manage/{token}` | `Manage::redeem` — single-use, trades the token for a session | | `GET/POST /manage/edit` | `Manage::edit` / `Manage::update` — the owner's edit form | | `GET/POST /contact` | `Contact::index` / `Contact::submit` — emails `directory.adminEmail`, stores nothing | | `GET/POST /recommend`, `GET/POST /recommend/stop/{token}` | `Referral::*` — "Recommend a business"; see that section. Admin queue at `/admin/referrals` | | `GET /faq` | `Contact::faq` — copy lives in the view as one `$groups` array that renders the page **and** builds the `FAQPage` JSON-LD, so the two cannot drift. Edit answers there, not in two places. | | `GET/POST /admin`, `/admin/login` | `Admin::*` — admin backend | **404s go through `Errors::notFound`**, the `Config\Routing::$override404`. That covers unknown routes and any `PageNotFoundException` a controller throws. It renders the branded `errors/not_found` view with a real 404 status, `noindex`, the security headers, and a fixed canonical, so the requested URL is never reflected. In feature tests a 404 is therefore a **response**: use `->assertStatus(404)`, not `expectException`. `errors/html/error_404.php` is only the fallback if that page itself fails. Production also has `zend.exception_ignore_args = On`, which is why `Config\Exceptions` switches trace masking off there (see its constructor). **Route-ordering gotcha** (called out in comments in `Routes.php`): literal segments (`manage/edit`, `directory/categories`, `directory/verify/...`) must be declared *before* the `{segment}`/`{token}` catch-alls, or the catch-all swallows them first. ## Data model `DirectoryListingModel` — table `xs_directory_listings`, soft-deletes, FULLTEXT search over `display_name`/`description`/`credentials`. - `OWNER_EDITABLE` allowlist (what `/manage/edit` may write): business fields only — `type, display_name, contact_person, title, position, category_id, credentials, description, phone, phone_alt, whatsapp, website, social_facebook, social_instagram, social_linkedin, social_tiktok, address_line, address_line_2, suburb, city, province, region, postal_code, logo_path, trading_hours, by_appointment, accepts_card_payments, offers_delivery, offers_online_booking, booking_url, customer_location, show_address, service_areas`. Deliberately **excludes** `email`, `slug`, `status`, `is_featured`, `is_verified`, `country` (it decides whether a listing is free) and `venue_id` — a crafted owner POST can't publish/feature a listing, hijack the address, dodge the International Listing paywall, or break the SEO'd URL. Read the constant rather than trusting this copy of it, since it is a security boundary and this doc has drifted from it before. - **Links and the WhatsApp number are checked on every write path *and* at render.** `website`/`booking_url` go through `normaliseUrl()` + `safe_external_url()`. The four `social_*` fields go through `social_profile_url()` (in `directory_ui_helper.php`), which also requires the host to match the network (so the Instagram icon cannot link anywhere else) and turns an `@handle` into a link for Instagram/TikTok. `whatsapp` is stored as international digits via `whatsapp_digits()` (leading `0` → `27`), and `whatsapp_chat_url()` builds the profile's "Chat on WhatsApp" wa.me button. Signup, owner edit and `DirectoryAdminService::upsert()` all use these helpers. A new link field needs the same treatment on all three. - **`is_verified` and `verified_until` are unrelated.** `is_verified` (bool) means the signup email was confirmed — `verify()` sets it alongside `status = published`. `verified_until` (date) is the paid Verified Business badge. Neither implies the other. - Other tables: `directory_categories` (seeded taxonomy, 182 categories / 16 groups, ordered by `directory_category_groups`), `directory_practice_locations` (extra locations per listing), `directory_tags` + `directory_listing_tags` (filterable areas of focus), `directory_listing_team` (team members), `directory_listing_services` + `directory_listing_attributes` ("Services & prices" and "Features & amenities" — free for every listing, written only through `ServiceMenuService`; feature keys and labels live in `Config\ListingAttributes`, keyed by category `group_name`, and saves drop keys the chosen category's group doesn't offer. Both sections carry a `*_present` marker input: absent = leave alone, present-but-empty = clear), `directory_settings` (admin-editable badge price/enabled), and the verification set — `directory_verifications`, `_verification_documents`, `_verification_events`, `_verification_submissions`, `_verification_itn_rejections`. ## Signup quality rules (since 30 Sep 2026) Added after a spam profile ("Polokwane ➸ [+②⑦⑦…] A TRADITIONAL HEALER…") went live. Enforced in `DirectoryListingMutationService::validate()` on **signup and owner edit**; admin intake (`DirectoryAdminService::upsert()`) is exempt. - **Name / description** rules live in `App\Libraries\ListingText`. Spam shapes (phone numbers, emoji, ②-style digits, `[]`, >3 commas, URLs) are **rejected**; ALL-CAPS is **fixed quietly** (`tidyName()`, `tidyDescriptionHtml()` via `RichText::mapText()`). Description is compulsory, 50+ plain chars. A stored value that is unchanged is never re-judged (the `$stored` argument to `validate()`), so legacy rows can still save. - **Website**: `normaliseUrl()` upgrades `http://` → `https://`, refuses IPs/localhost/ userinfo/no-TLD, and a new or changed link must resolve in DNS through `Services::domainChecker()` (a lookup, never a request to the site). Under `ENVIRONMENT === 'testing'` the checker says yes to everything; inject a fake to test a no. - **Title / Position** are dropdowns from `DirectoryListingModel::TITLES` / `POSITIONS`; a legacy free-text title stays valid while unchanged. - **Compulsory sections**: complete hours *or* `by_appointment`, one named service, and a picture. The picture flag is `$post['_has_photo']`, always written by the controller (`HandlesListingUploads::hasPhoto()`) and only enforced when that key is present, so direct service calls in tests skip it. Tests build valid input with `Tests\Support\ValidListingInput::withRequiredSections()`. - **Form flow**: filling `[data-standout-trigger]` (contact person) opens every `details.disclosure` in `[data-standout]` except `[data-standout-skip]` (branches). - `php spark directory:audit-text [--all]` reports stored listings that break the rules (report only). ## Services split - **`DirectoryService`** — read-side: `browse()`, `getProfile($slug)` (assembles the detail-page data: listing + category + locations + tags), `relatedListings()`, `categories()`/`categoriesGrouped()`, `provinces()`, sitemap URL helpers. - **`DirectoryListingMutationService`** — write-side: `submitPublic()` (dedupes by email — a second signup with an existing email gets a manage link, not a second row, with an identical on-screen message either way), `verify()` (publishes), `requestManageLink()` / `redeemManageToken()` (single-use, 1h TTL), `updateOwn()` (applies only `OWNER_EDITABLE`). - **Description cap** is `RichText::MAX_PLAIN_LENGTH` (1000 plain-text chars; the JS counter reads it from `data-rich-text-max`). `RichText::exceedsCap()` exempts an *unchanged* description, so profiles saved under the old 5000 cap can still save other edits. - **`App\Libraries\Mailer`** — the **one** outbound-mail path. `send($to, $subject, $body, $replyTo = '')` returns whether it went out and records the result in `MailHealth`. It logs the recipient *domain* only, never the address or body. Signup/edit callers ignore the return value (a broken mailer must not block them, so `writable/logs/` and `/health` are the only signal); `Contact::submit()` uses it to tell the visitor the truth. Do not add a second send path — the error handling here exists because an earlier version checked for an exception that CI4's `Email::send()` never throws, so outbound mail could stop entirely and leave no trace. ## Mobile / service-area businesses (`customer_location`, `show_address`, `service_areas`) "Address required" and "address publicly visible" are separate. The address stays compulsory and stored for everyone. Only what the public sees changes. - **Rules live in `App\Libraries\ServiceArea`.** Signup, owner edit and admin intake all call it. `columns()` writes nothing unless `service_area_present` is posted, so a partial POST cannot un-hide an address. It forces `show_address = 1` unless `customer_location = 'travel'`, because "visit" and "both" mean customers come to the door. `problem()` refuses links, phone numbers and symbols, and caps areas at 15 × 80 chars. Areas are stored one per line. - **The render-side rule is `listing_public_view()`** (`directory_ui_helper.php`). For a hidden address it blanks street, line 2, suburb, postal code, coordinates, precision, distance and venue. City and province stay. `show.php`, `_card.php` and both schema builders run rows through it. `_contact_panel.php` and `_map_panel.php` also check `listing_address_public()` themselves, so a caller that forgets still leaks nothing. A new public surface must do the same. - **Location queries skip hidden listings** (`DirectoryService::HIDDEN_ADDRESS_SQL`): the map JSON always, and `browse()` whenever a near-me point, map bounds or venue is set. A radius asked often enough gives the address away. Search by `service_areas` works, and a suburb match is ignored for hidden listings. - Branches (`directory_practice_locations`) are unaffected; a branch is somewhere customers go. - The profile shows a "Mobile service — we travel to you" contact row and a `_service_area_panel.php` ("We travel to customers in …"). JSON-LD `areaServed` lists the areas as `Place` nodes. - Tests: `tests/database/ServiceAreaBusinessTest.php`. ## Venues — one complex, mall or building `directory_venues` + `xs_directory_listings.venue_id`, added for the Oriental Plaza import (262 shops sharing `address_line_2` and one coordinate). - **Not a tag, deliberately.** `syncListingTags()` replaces a listing's whole tag set on every save and the owner form posts tags as free text, so a venue kept there would vanish the first time an owner edited their profile. The migration docblock says so. - **Admin-only.** `venue_id` is in `$allowedFields` but **not** `OWNER_EDITABLE`; `DirectoryAdminService::upsert()` writes it in the same gated block as `status` and `is_featured`. A venue is a claim about a shared building — an owner who could set it could move their shop onto any complex's page. - **`ON DELETE SET NULL`.** Deleting a venue ungroups its businesses and deletes none; `deleteVenue()` reports how many were detached. Unlike `deleteCategory()` it does not refuse while in use. - The page is `/directory/at/{slug}` — `at` is a reserved slug, and the prefix keeps venue slugs out of the category/listing namespace `Directory::segment()` resolves. Same `landingMinListings` indexability rule as the other landing tiers, a fourth sitemap emitter, and `?category=`/`?q=` narrow **within** the venue (both make it noindex). - `browse()`'s venue filter is a plain `where()` after `applySearch()` — an `orWhere()` there escapes the OR group and lists the whole country. There is a test for exactly that. - **The map shows the venue's own single pin**, because every shop in a building geocodes to the same point (`ListingGeocoder` leaves `address_line_2` out of the lookup). The `/directory` results map still stacks them — not fixed yet. - Bulk assign with `php spark directory:venue-assign --venue <slug> --address2 "…" [--dry-run] [--set-venue-point]`. `--set-venue-point` copies the shops' shared coordinate onto the venue and refuses when they disagree. `--limit 20`, never `--limit=20` (the CLI quirk `GeocodeListings` documents). ## Geocoding provider: Mapbox for lookup, Nominatim as fallback
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub