- 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