# Marketplace architecture and delivery plan

This document plans the extension of the ABACO INFRA site into a multi-sector buy/sell/rent
marketplace with **real estate as the primary vertical**. It is written against the repository as it
actually stands, not against a greenfield assumption.

Read alongside `docs/SOW.md` (the client-approved scope of the corporate site) and `docs/DESIGN.md`
(the design system). Where this document and the SOW disagree, the SOW governs the *corporate*
modules and this document governs the *marketplace* modules.

---

## 1. Audit of the existing application

### What exists today

| Area | State |
| --- | --- |
| Framework | Laravel 12, PHP 8.2+, Blade, Tailwind v4 (CSS-first, no JS config), Alpine, GSAP |
| Auth | **Admin-only.** One `web` guard, login at `/admin/login`, `guest`/`auth`/`active` middleware. No public registration, no email verification, no password reset. |
| Roles | `spatie/laravel-permission`. Three roles: Super Admin, Content Editor, Media Manager. Permissions generated from `config/modules.php`. |
| Users | `users` + `phone`, `avatar`, `designation`, `is_active`, `last_login_at`. Soft deletes. One factory. |
| Admin CRUD | `AdminCrudController` — list/search/filter/create/edit/soft-delete/publish/reorder, authorised in `callAction()`. 36 admin controllers subclass it. |
| Content | Sectors, projects, team, media, downloads, gallery, careers, partners, achievements, FAQs, quick links, newsletter, contact, pages + block builder, offices, homepage pieces. |
| Property | `properties` (+ `property_images`, `property_floor_plans`, `amenities` pivot, `property_enquiries`). **Admin-published**, not user-generated. Real-estate columns are hard-coded on the table. |
| Services | `FileUploadService`, `PropertySearch`, `SiteSearch`, `SettingsService`, `HomepageSections`, `ContactChannels` |
| Concerns | `HasSlug`, `HasSeo`, `HasContentScopes`, `ProtectsPublicForms` |
| Tests | 124 passing across 15 feature classes + 1 unit class |
| Infrastructure | `QUEUE_CONNECTION=database`, `CACHE_STORE=database`, `SESSION_DRIVER=database`, `MAIL_MAILER=log` in `.env.example`. `local` and `public` disks configured; **no private disk**. |

### What the marketplace specification needs that does not exist

- Public accounts of any kind: registration, email verification, password reset, buyer/seller roles.
- A generic `Sector → Category → Attribute → Listing` engine. Today's listing model is a single
  table of real-estate columns.
- Listing ownership by a seller, and a listing lifecycle state machine.
- Locations as first-class records (province/district/municipality/ward), currently free-text.
- Favorites, saved searches, leads, messaging, appointments, offers, reviews, reports.
- Seller/agent/business profiles and a verification workflow with **private** document storage.
- Payments, subscriptions, invoices; audit logs; analytics.
- `app/Policies`, `app/Enums`, `app/Events`, `app/Listeners`, `app/Jobs`, `app/Notifications` — none
  of these directories exist yet.
- Factories for anything other than `User`.

### Two decisions this plan takes

**1. The marketplace gets its own namespace.** `App\Models\Sector` already means "engineering
service area" (Transportation, Hydropower…) and is wired into projects, team, media, careers,
partners and the navigation. The marketplace's own top-level taxonomy (Real Estate, Vehicles,
Electronics…) is therefore `App\Models\Marketplace\Sector` on table `marketplace_sectors`, with
every other marketplace table prefixed `marketplace_` where a name would otherwise collide. The two
taxonomies stay independent; neither is forced to mean the other.

**2. `Property` becomes the real-estate profile of a `Listing`, not a parallel system.** The generic
engine is built first (phases 3–5). In phase 6 the existing `properties` rows are migrated into
`listings` + `listing_property_details`, the public routes keep working, and `PropertyModuleTest` is
rewritten against the new names in the same commit. The corporate site keeps its admin-published
listings by making the admin a seller — one code path, not two.

---

## 2. Target architecture

```
User ─┬─ SellerProfile ─── Business
      └─ BuyerProfile

Marketplace\Sector ─── Category (nested, self-referencing)
                            │
                            ├─ CategoryAttribute ─── Attribute ─── AttributeOption
                            │
                            └─ Listing ─┬─ ListingAttributeValue
                                        ├─ ListingMedia
                                        ├─ Location
                                        ├─ Favorite / SavedSearch
                                        ├─ Lead ─── Appointment
                                        ├─ Offer (append-only history)
                                        ├─ Conversation ─── Message
                                        ├─ Review / Report
                                        └─ ListingPromotion ─── Subscription/Payment
```

Rules that hold across the whole engine:

- **Nothing real-estate-specific in the core.** `listings` carries only what every sector needs:
  owner, category, title, slug, price, currency, listing intent (sale/rent/lease/exchange), status,
  location, timestamps, counters. Everything else is an attribute value or an extension table.
- **An extension table is justified only by query pressure.** Real estate gets
  `listing_property_details` because bedrooms/bathrooms/area are filtered on every search and cannot
  afford an EAV join. Sectors without that pressure use attribute values alone.
- **Business logic lives in services**, not controllers: `ListingService`, `ListingStateMachine`,
  `SearchService`, `AttributeService`, `LeadService`, `OfferService`, `VerificationService`,
  `MediaService`, `PromotionService`.
- **Authorisation is policy-based.** Every marketplace model gets a policy; the admin panel keeps
  its permission-based gating.
- **Events decouple side effects.** `ListingPublished`, `LeadCreated`, `OfferMade`,
  `AppointmentRequested`, `VerificationSubmitted` fan out to queued listeners for notifications,
  counters and alert matching.

---

## 3. Delivery phases

Each phase ends with: migrations run, seeders run, `php artisan test`, `pint`, a route check and a
responsive pass. A phase is not done until it meets the checklist in §5.

| # | Phase | Delivers |
| --- | --- | --- |
| 1 | Audit & architecture | **Done.** This document. |
| 2 | Auth, users, roles | **Done.** Public registration, login, email verification, password reset, `account_type`, Buyer/Seller roles, buyer & seller profiles, `AccountService`, account dashboard shell, seller onboarding. 40 tests. |
| 3 | Taxonomy & attributes | **Done.** `marketplace_sectors`, `marketplace_categories` (nested), `marketplace_attributes`, `marketplace_attribute_options`, `marketplace_category_attribute`; `AttributeType` and `ListingIntent` enums; `AttributeService` with inheritance, generated validation and filter definitions; admin CRUD for all three screens; real-estate taxonomy seeded. 42 tests. |
| 4 | Listing engine | **Done.** `listings`, `listing_attribute_values`; `ListingStatus` enum with its transition map; `ListingStateMachine`; `ListingService`; `ListingPolicy`; seller create/edit/submit flow with a category-driven form; admin moderation queue; minimal public listing page. 53 tests. |
| 5 | Media & locations | **Done.** `listing_media`, `locations`, `listings.location_id`; `MediaType` enum; `MediaService` with responsive derivatives, cover handling and ordering; a `private` disk with a policy-checked download route; seller media screen; public gallery, floor plans and video. 31 tests. |
| 6 | Real estate module | **Done.** `listing_property_details` + `PropertyProjector`, amenities reattached to listings, `marketplace:migrate-properties`, property index and detail rebuilt on the engine, SEO URLs with canonical + 301s, enquiries repointed. Legacy property CRUD retired. 25 tests rewritten. |
| 7 | Search | **Done.** `SearchCriteria` + `SearchService` (sector-agnostic, dynamic attributes, radius, bounds, sorting); `MapProvider` contract with OSM and Google implementations; grid/list/map/split views; clustered Leaflet map with "search this area" and "near me"; JSON marker endpoint. 24 tests. |
| 8 | Favourites & saved searches | **Done.** `favourites`, `saved_searches` with a criteria fingerprint, `listing_price_changes`; `FavouriteService`, `SavedSearchService`, `MatchSavedSearches` job, digest schedule, `ListingPublished`/`ListingPriceChanged` events, two queued notifications; hearts on cards and detail, save-search band, two dashboard screens. 38 tests. |
| 9 | Profiles & verification | **Done.** `verification_requests`, `verification_documents`; `VerificationType` enum; `VerificationService`; seller profile editor, public seller directory and profile pages, verification submission, admin queue with policy-checked document downloads off the private disk. 21 tests. |
| 10 | Leads & messaging | **Done.** `conversations`, `messages`, `message_attachments`, `leads`; `LeadStatus`/`LeadSource` enums; `MessagingService` and `LeadService`; seller lead pipeline, buyer/seller inbox, two queued notifications, `marketplace:migrate-enquiries`. Legacy property-enquiry inbox retired. 21 tests. |
| 11 | Appointments | **Done.** `appointments` with a derived `ends_at`; `AppointmentStatus`/`AppointmentType` enums; `AppointmentService` with overlap detection at request time; request/confirm/decline/reschedule/cancel/complete, queued notifications, buyer and seller screens. 20 tests. |
| 12 | Offers | **Done.** `negotiations`, `offers`; `NegotiationStatus`/`OfferStatus` enums; append-only `OfferService` where every round points at the one it answers; accept/reject/counter/withdraw/expire, lead status kept in step, queued notification, negotiation screens. 22 tests. |
| 13 | Reviews & reports | **Done.** `reviews`, `reports`; `ReviewStatus`/`ReportStatus`/`ReportReason` enums; `ReviewService` (dealing required, held until read, rating rebuilt not adjusted) and `ReportService` (duplicate folding, triaged queue, decision separate from action); public report dialog open to signed-out visitors, seller replies, reviews on the public profile, two admin queues. 40 tests. |
| 14 | Admin moderation | **Done.** `audit_logs` (append-only) and `account_suspensions`; `AuditLogger`; `SuspensionService`; moderation dashboard with queue ages, filterable audit log, marketplace accounts screen with suspend/reinstate and per-account history; `AccountSuspended` notification. Listing, verification, review and report decisions all write to the trail. 22 tests. |
| 15 | Monetization | **Done.** `plans`, `subscriptions`, `payments`, `invoices`, `refunds`, `listing_promotions`; `PaymentStatus`/`SubscriptionStatus`/`PromotionType` enums; `PaymentGateway` contract with `BankTransferGateway` and `OfflineGateway` behind `PaymentGatewayManager`; `PaymentService`, `InvoiceService`, `SubscriptionService`, `PromotionService`; `PaymentSettled` event; seller billing screens, promote screen, admin plans CRUD and payment ledger with reconciliation and refunds; renewal and sweep commands. 47 tests. |
| 16 | SEO & analytics | **Done.** Sectioned XML sitemap with an index, generated `robots.txt` gated on `app.indexable`, `Listing::publicUrl()` as the single canonical builder, `RealEstateAgent` structured data on seller profiles; `listing_daily_stats` with `ListingAnalytics`, seller and per-listing analytics screens. 32 tests. |
| 17 | Additional sectors | **Done.** `AdditionalSectorsSeeder` adds eight verticals with ~120 categories and 30 shared attributes — no migration; `ListingIntent::Service` added; generic `MarketplaceController` (`/marketplace`, `/marketplace/{sector}`, `/marketplace/{sector}/{listing}`) over the same search engine; `Listing::publicUrl()` picks the URL shape from the vertical. 22 tests. |
| 18 | Performance | **Done.** `QueryBudgetTest` guards eleven hot paths against N+1 growth; indexes added for `scopeLive`, the default sort, the lead pipeline and appointment overlap detection; the slot picker cut from eighteen queries a day to one; marketplace sector counts cached. 11 tests. |
| 19 | Security review | **Done.** `SecurityAuditTest` — 27 attacks attempted against IDOR, mass assignment, role boundaries, private storage, enumeration and injection. Four real holes found and closed: the admin panel open to any authenticated buyer, stored XSS through JSON-LD, unescaped LIKE wildcards, and a 301 that confirmed drafts exist. 27 tests. |
| 20 | QA & production readiness | **Done.** `ProductionReadinessTest` guards the mistakes that only appear on a live server; `docs/DEPLOYMENT.md` written; `.env.example` extended from 58 keys to 83 so every marketplace and payment setting is documented. 13 tests. |

---

## 4. Conventions

- Marketplace models live in `app/Models/Marketplace/`; tables that would collide with a corporate
  table are prefixed `marketplace_`.
- Enums live in `app/Enums/` and are backed by strings, so a database value stays readable.
- Every user-facing string goes through `__()`; the site must stay translatable to Nepali.
- Money is stored in **minor units** (integer), as the existing property module already does.
- Uploads always go through the media service — never straight to disk. Verification documents and
  private attachments use the `private` disk and are streamed through a policy-checked controller.
- Public forms keep `ProtectsPublicForms` (honeypot, time-on-form, optional reCAPTCHA) plus
  route-level rate limiting.
- Seeders: structural data is production-safe; demo content refuses to run when
  `APP_ENV=production`.

---

## 5. Definition of done

A feature ships only when all of these hold:

1. Migration exists, with foreign keys, indexes and the right nullability.
2. Model, relationships and casts exist; factory exists.
3. Validation lives in a Form Request.
4. Authorisation lives in a Policy (or an admin permission) and is enforced.
5. Controller is thin; complex work sits in a service.
6. Routes exist and are named.
7. Blade UI exists using shared components — no duplicated markup.
8. UI is responsive across `xs`→`4xl` and keyboard accessible.
9. Empty, error and loading states exist.
10. Tests cover the happy path, the authorisation boundary and the validation failure.
11. Queries are eager-loaded; no N+1 (lazy loading throws outside production).
12. Security considered: mass assignment, uploads, rate limits, private storage.
13. Existing functionality has not regressed — the full suite passes.


---

## 6. Phase log

### Phase 2 — authentication, users and roles

Delivered:

- `users` gains `account_type`, `last_seen_at`, `registration_ip`, `suspended_at`, `suspension_reason`.
  Every pre-existing account was migrated to `account_type = staff`, so the panel is unaffected.
- `seller_profiles` and `buyer_profiles`. Every account holds a buyer profile (it is where saved
  searches and alert preferences will live); a seller profile is what makes an account able to list.
- `App\Enums\AccountType`, `SellerType`, `VerificationStatus`.
- `App\Services\AccountService` — registration and seller onboarding in one transaction, idempotent.
- Public auth at `/register`, `/login`, `/forgot-password`, `/reset-password`, `/email/verify`, with
  Form Requests, rate limits and a layout of its own. The panel keeps `/admin/login`; guests are
  redirected to whichever of the two belongs to the area they asked for.
- `/account` dashboard, profile and password screens, and `/account/become-a-seller`.
- `EnsureUserIsActive` now covers suspension as well as deactivation, on both areas.
- Buyer and Seller roles carry **no** panel permissions. `RolePermissionSeeder` was guarding this
  incorrectly: an empty module list produced an unconstrained query, which would have granted such a
  role every permission in the system. Fixed.
- `UserSeeder` credentials moved from `env()` to `config/accounts.php`. Reading `env()` inside a
  seeder returns null once `config:cache` has run, so a locked-down deployment would silently have
  seeded the documented fallback password.

Not yet built, and deliberately so: policies beyond the ownership checks above (they arrive with the
records they protect), seller public profiles (phase 9), and the dashboard panels for favourites,
messages, offers and appointments (phases 8–12) — the overview names them but does not fake them.

### Phase 3 — taxonomy and dynamic attributes

Delivered:

- Five tables, all prefixed `marketplace_`. Categories nest through `parent_id` with a derived
  `depth`: "category" and "subcategory" in the specification are one record at two levels, and
  modelling them as separate tables would have duplicated every binding, relation and slug rule.
- `AttributeType` decides three things together — the form control, the storage column on
  `listing_attribute_values`, and the validation. Numbers land in a numeric column so a range filter
  is an index scan rather than a string comparison.
- `ListingIntent` (sale / rent / lease / exchange). A category declares which it permits; a category
  that declares none permits all rather than none.
- `App\Services\Marketplace\AttributeService` resolves the attributes that apply to a category,
  **including those inherited from its ancestors**, with a nearer binding overriding a farther one —
  so "Bedrooms" is declared once on Residential and House, Flat and Studio all get it, and any one of
  them can re-bind it to make it required there. It also generates the validation rules, normalises
  submitted values, and describes each filter for the search UI. Resolution is cached under a version
  stamp rather than cache tags, because the database cache driver does not support tags.
- Admin CRUD for sectors, categories (with the attribute-binding matrix) and attributes (with their
  options). Two rules the screens enforce: exactly one sector is primary, and an attribute's `code`
  is fixed after creation — renaming it would orphan every stored value and saved search.
- The real-estate taxonomy from specification §6 is seeded structurally (production-safe): four
  groups, twenty-two categories, seventeen attributes with their option lists, bound at the top level
  and inherited downward.

Two things worth noting for later phases:

- The category relation is `attributeDefinitions()`, not `attributes()`. Eloquent already owns that
  word for a model's raw column values.
- MySQL caps identifiers at 64 characters, and `marketplace_attribute_options_marketplace_attribute_id_value_unique`
  is 67. The marketplace foreign keys are therefore named plainly (`sector_id`, `category_id`,
  `attribute_id`) — the table prefix already says which taxonomy they belong to.

### Phase 4 — the listing engine

Delivered:

- `listings` carries only what every vertical needs — owner, category, price, place, lifecycle. What
  makes a row a flat rather than a van is the set of `listing_attribute_values` hanging off it.
  Five typed value columns rather than one text column: a range filter has to compare numbers against
  an index, and casting text per row would turn every search into a full scan.
- `ListingStatus` holds the transition map. Draft cannot jump to Published; Archived is terminal; a
  Sold listing can be re-listed when a deal falls through.
- `ListingStateMachine` is the only place a status changes, and it answers two separate questions on
  every move: is this transition legal, and may *this actor* make it. A seller submits, pauses and
  closes; only a moderator approves or rejects. Approval and publication are deliberately separate,
  so a seller keeps control of when their listing actually appears.
- State side effects live with the state: publishing stamps `published_at` and an expiry (and
  re-publishing after a pause keeps the original date, so pausing cannot game "newest first");
  resubmitting clears the previous rejection; closing records when.
- `ListingService` writes the row and its attribute answers together, discards answers for
  attributes the category never asked for, and drops stale ones when a listing moves category.
- `ListingRequest` generates half its own rules: `attributes.*` comes from whatever the category
  binds, so a flat is validated on bedrooms and a van on mileage from the same request class.
- Seller screens at `/account/listings`, with a form that asks for the category first and then
  renders the fields that category needs. Admin moderation queue at `/admin/listings`, which opens on
  what actually needs a decision and requires a reason with every rejection.
- An unverified seller is capped (`config/marketplace.php`) so a throwaway registration cannot flood
  the listings; verified sellers have no cap.

Notes for later phases:

- `config/modules.php` rows may now declare their own `abilities`. `listings` declares `moderate`,
  because reviewing a submission is not the same right as editing a record, and adding `moderate` to
  the shared ability list would have created a meaningless permission on all forty modules.
- The base `Controller` now uses `AuthorizesRequests`. The admin panel is unaffected — it still gates
  on module permissions inside `AdminCrudController::callAction()`.
- `/listings/{slug}` is a placeholder page. Phase 6 rebuilds the property presentation on it and
  phase 7 gives it the SEO-friendly URL from specification §23.

### Phase 5 — media and locations

Delivered:

- `listing_media` holds photographs, floor plans, videos and documents in one table, discriminated by
  `MediaType`. The type decides three things together: which disk the file goes to, whether it is
  publicly addressable at all, and how it is rendered.
- `MediaService` is built **on** `FileUploadService` rather than beside it, so the security work
  already done there — extension *and* sniffed-MIME allow-lists, regenerated filenames, image
  re-encoding — applies to marketplace uploads too. What it adds is what a marketplace needs and a
  CMS did not: responsive derivatives at 400/800/1600 (never upscaling), stored dimensions so a grid
  can reserve the box before the file arrives, exactly one cover per listing, ordering, and per-type
  limits.
- **Documents go to a `private` disk with no `url` configured.** There is nothing to link to and
  nothing to guess. `ListingMedium::url()` returns null for anything private, so a template that
  renders media generically gets nothing back rather than a working link to someone's ownership
  papers; `publicMedia()` excludes them at the relation, not at the template. The single route to a
  document authorises against the listing first.
- Removing the cover promotes the next photograph rather than leaving the listing pointing at a file
  that no longer exists; removing the last one clears it.
- `locations` is a self-referencing province → district → municipality → ward tree with a
  denormalised `full_name`. `LocationSeeder` covers all seven provinces and all seventy-seven
  districts and is production-safe. Municipalities and wards are deliberately **not** seeded: there
  are thousands, they change with local reorganisation, and a stale list is worse than none.
- A listing resolves what the seller typed to a known place, so "Lalitpur" is one thing a buyer can
  filter on rather than four spellings. An unmatched place leaves the foreign key null and the plain
  columns intact — minting a location record from a typo would be worse than not matching.

One correction made along the way: `MediaService` used `abort()` to enforce its upload limits, and
`HttpException` extends `RuntimeException`, so the controller's own `catch (RuntimeException)`
swallowed it and turned a hard 422 into a redirect. The service now throws a plain domain exception
and the controller decides how to present it.

### Phase 6 — the real-estate module

Delivered:

- `listing_property_details` is the one deliberate denormalisation in this codebase. "Three bedrooms,
  over 1,200 sq ft, under two crore, in Lalitpur" is a single indexed query here; asked of
  `listing_attribute_values` it would be four self-joins. Attribute values stay the source of truth
  and `PropertyProjector` rebuilds the projection on every save, so it can always be thrown away.
  No other sector gets a table like this until its search proves it needs one.
- Areas are normalised to square feet on the way in. Land is quoted in aana, ropani, dhur and bigha
  depending on where you are, and a range filter across four units means nothing.
- Amenities are reused from the corporate module rather than re-invented — same vocabulary, already
  maintained in the panel — through a new `amenity_listing` pivot.
- `marketplace:migrate-properties` moves the legacy rows into the engine: reference and slug carried
  over so links keep resolving, media and floor plans copied, amenities synced, enquiries **repointed**
  rather than duplicated, and legacy status mapped onto the lifecycle so what was visible stays
  visible. It is re-runnable and has a `--dry-run`. `DatabaseSeeder` runs the same command, so the
  demo database and a live deployment reach the listing engine by identical routes.
- The corporate site's own listings are given a **house seller profile** on a staff account. That is
  what makes "one code path, not two" true: staff author listings through the same flow sellers use,
  and the panel's property CRUD has been retired along with the table behind it. The panel keeps
  amenities, the enquiry inbox and the moderation queue.
- Public URLs now read `/properties/apartment/lalitpur/riverside-apartment-top-floor`, with a
  `<link rel="canonical">` and Schema.org `RealEstateListing`. The bare `/properties/{slug}` route is
  kept and 301s; a stale category or district segment also redirects rather than 404ing, so a shared
  link survives a listing being recategorised.
- Enquiries and viewing requests keep working exactly as before — same table, same inbox, same
  notification — pointed at a listing. Phase 10 replaces them with leads and messaging.

Two things worth recording:

- `ListingPolicy` gained `isPubliclyReadable`. A **sold** listing keeps its page: the link has been
  shared, indexed and bookmarked, and answering it with "sold" is more useful than a 404. A paused or
  expired listing is different — the seller took it down, so it goes down.
- Blade treats the leading `@` of `"@context"` and `"@type"` as directive syntax inside an
  `@json(...)` argument and mangles the array. The structured data is built in `@php` and encoded
  explicitly.

### Phase 7 — search, filtering and the map

Delivered:

- `SearchCriteria` owns the query-string contract as a value object, and round-trips exactly. That
  matters beyond tidiness: phase 8 stores this array against a saved search and re-runs it later, so
  it has to reproduce the same result set from nothing but the stored keys.
- `SearchService` knows nothing about bedrooms. A filter is either a column every listing has — price,
  place, intent, category — or an attribute the category declares. Two paths, chosen per attribute:
  real estate's hot fields go through the indexed `listing_property_details` projection, everything
  else joins `listing_attribute_values` against the typed column that attribute's type writes to. The
  vehicles test proves it: a fuel filter and a mileage range work with nothing named in the engine.
- Keyword search covers title, reference, place, summary, category name and any attribute marked
  searchable — deliberately not a title `LIKE`, and the seam a dedicated search engine slots into.
- Radius search prefilters with a bounding box, which is what an index can use, then trims the corners
  of that box to a true circle with a haversine distance. The `asin` form is used rather than the
  commoner `acos` one because its square-root argument is naturally within range and needs no
  `LEAST`/`GREATEST` clamp — which SQLite does not have.
- `MapProvider` is a contract with OpenStreetMap and Google implementations. OSM is the default
  because it needs no key and no billing account, and a marketplace that cannot draw a map until
  someone sets up payment ships broken. A Google key that is missing is a misconfiguration, not an
  outage: the binding falls back rather than rendering watermarked tiles.
- Four views — grid, list, map, split — as a query-string key, so a shared link opens on the one the
  sender was looking at. The map clusters, carries the page's filters into its marker request, and
  offers "search this area" only after the map has actually been moved. Leaflet is bundled, not
  CDN-loaded, and lazily: 53 kB gzip that only pages with a map ever fetch.
- The marker endpoint is the one place the marketplace answers with JSON. A map library needs
  coordinates, not markup, and it is capped — a pan over a busy city must not become a query that
  ships every listing in it.

Two problems found and fixed along the way, both invisible until something ran:

- **The radius filter silently matched everything under SQLite.** Laravel binds floats as
  `PDO::PARAM_STR`, and SQLite compares a raw expression against text *as text*, where every string
  sorts above every number — so `14.03 <= '10'` was true. A column comparison is rescued by the
  column's own affinity; a bare expression has none. The operand is now forced numeric with `+ 0`,
  which changes nothing on MySQL.
- SQLite ships without maths functions unless compiled with `SQLITE_ENABLE_MATH_FUNCTIONS`, which the
  bundled build is not. Rather than weaken the SQL to suit the test driver,
  `MarketplaceServiceProvider` registers the handful the search needs when a SQLite connection opens.

### Phase 8 — favourites and saved searches

Delivered:

- `favourites` with a maintained `favourite_count`, recounted rather than incremented so it self-heals.
  The heart is a real form that posts and redirects with JavaScript off, upgraded by Alpine to a
  fetch — it is the first control a visitor touches before they have decided to trust the site.
- A saved search stores **the question, not the answer**: the `SearchCriteria` payload, re-run on
  demand. `last_checked_at` is the high-water mark that keeps a digest honest — a run only considers
  listings published since it, and a search that matched nothing still moves its mark or the next run
  re-examines the same window forever.
- Saving the same search twice is one saved search. Matched on a **fingerprint** — a hash over a
  recursively key-sorted payload — because comparing the JSON column does not work: the same search
  serialises differently depending on the order its keys were built in, so the match silently failed
  and buyers collected duplicates.
- `ListingPublished` fans out to instant alerts; daily and weekly cohorts are swept by the scheduler,
  because a daily digest sent the moment something matches is not a daily digest. A listing withdrawn
  between the event and the job alerts nobody.
- Price movement is recorded **on the model**, so it is caught however the price changed — seller
  form, moderator or console command — and only for a live listing, since a draft being edited towards
  its asking price is not a reduction. Watchers are notified; the listing page shows "Reduced from".
- `marketplace:expire-listings` makes the seller dashboard honest: `scopeLive()` already hides an
  expired listing from search, but showing "Published" while nobody can find it is worse than showing
  "Expired" with a renew button.

### Phase 9 — profiles and verification

Delivered:

- A seller profile is the marketplace's answer to "who is this", so it is a page, not a settings
  screen: a public directory at `/sellers`, a profile at `/sellers/{slug}` carrying the listings,
  the verification badge and now the reviews.
- Verification is a **request with documents**, not a flag an admin ticks. `VerificationService`
  owns the transitions; `VerificationType` says which documents each kind of seller must supply, so
  a company is asked for registration papers and an individual is not.
- **Documents never touch the public disk.** They go to the `private` disk introduced in phase 5 and
  are served only through a route that runs the policy first. A verification document is the most
  sensitive thing this application stores; a signed URL that leaks is a citizenship certificate on
  the open web.
- The badge is derived from `verification_status`, never set by hand, so nothing can display
  "Verified" without a decision behind it.

### Phase 10 — leads and messaging

Delivered:

- One `leads` table behind **every** contact surface — enquiry form, phone reveal, offer,
  appointment — because a seller who has to check four inboxes checks none of them.
- `conversations`/`messages` for the thread itself, with attachments through `FileUploadService`,
  read state per participant and a block that stops the thread rather than hiding it.
- The legacy `property_enquiries` inbox was **migrated and retired** rather than left running.
  `marketplace:migrate-enquiries` moves the rows across; two inboxes that disagree are worse than
  one that is incomplete.
- Notifications are queued. A seller learns about a lead within seconds, but a slow mail server
  never holds up the buyer's form submission.

### Phase 11 — appointments

Delivered:

- Viewings, meetings, calls and virtual tours in one table, separated by `AppointmentType`; the
  full request → confirm → complete path with reschedule and cancel from either side.
- **The conflict is raised when the appointment is requested**, not when it is confirmed. A buyer
  who picks an occupied slot is told immediately, while they still have the calendar in front of
  them; discovering it a day later when the seller confirms is a worse experience for both.
- `ends_at` is a stored, derived column maintained in `Appointment::booted()`. Overlap detection
  needs both ends of the interval in SQL, and `DATE_ADD(..., INTERVAL ... MINUTE)` is MySQL-only —
  the test driver would never have run it.

### Phase 12 — offers

Delivered:

- `negotiations` (the conversation about price) and `offers` (the rounds inside it). The history is
  **append-only**: no round is ever edited, every one records what it answered, and the current
  position is the latest row rather than a mutable field. Money is disputed later; a negotiation
  that can be rewritten is not evidence of anything.
- Accept, reject, counter, withdraw and expire, each moving the parent lead's status in step, so
  the pipeline reflects reality without a seller maintaining it by hand.
- Only the party whose turn it is may act, enforced in the service — the screen hides the wrong
  buttons, but the screen is not the authority.

### Phase 13 — reviews and reports

Two mechanisms carry this phase, and both are about what a marketplace's reputation signal is worth.

**A review requires a dealing.** `ReviewService` will not accept one unless the author sent the
subject a lead within the last 180 days. A ratings system open to strangers is a ratings system that
gets bought, and no amount of moderation afterwards repairs a score that anybody could write into.

**No review is visible before somebody has read it.** Reviews are held `Pending` and published by
hand, because the harm a fabricated review does happens on first read — taking it down an hour later
does not undo it. Editing a published review sends it straight back to the queue: the text that was
checked is not the text that would now be shown.

The seller's rating is **rebuilt from the published reviews**, never adjusted by a delta, so hiding
one or publishing a backlog cannot leave an average quietly wrong. It is written by query rather
than by `save()` — the instance handed to `recalculate()` may be a stale copy whose attributes
already hold the new numbers, and a save with nothing dirty writes nothing at all. That was a real
bug: an edited review kept counting at its old score.

Reporting is **confidential and open to visitors who are not signed in**. Somebody who spots a scam
should be able to say so without first making an account, and losing that report to a login wall
helps nobody except whoever posted the listing. The form is honeypot-protected and rate-limited.
Nothing written back to the person reported ever quotes the reporter — a suspension carries the
moderator's wording, not the complaint's, because the complaint would identify who filed it. The
success message is deliberately vague for the same reason: telling a reporter that a listing came
down would let the form be used to find out.

In the queue, **the decision is separate from the action**. Upholding a report does not imply
removal; a moderator picks the response explicitly, and a report can be correct without anything
needing to be taken down. Reports about the same subject are triaged with scam, fraud and illegal
first, duplicates from one person are folded into one, and resolving one closes its siblings.

### Phase 14 — admin moderation

Phases 4, 9, 13 each left a queue behind; this phase is what sits above them.

**The audit log.** A moderation panel without one is a panel where nobody can answer "who took this
down, and why" a month later — which is precisely when the question gets asked. `audit_logs` is
append-only (`created_at`, no `updated_at`, nothing ever updated), and `AuditLogger` is deliberately
tiny and deliberately quiet: it swallows its own failures, because recording an action must never
fail the action it records. A moderator who takes a scam listing down and gets an exception from the
log writer is worse off than one whose log has a gap.

The actor's **name is copied in, not joined for**. The account may be gone by the time somebody reads
the entry, and "deleted user did X" answers nothing.

Only moderator transitions are logged, not every status change. A seller pausing their own listing is
not an act of moderation, and burying the queue's decisions under routine seller activity would make
the log unreadable — which is the same as not having one.

**Suspensions** go through `SuspensionService`, so the flag the login check reads, the history behind
it, the listings that come down with it and the notice to the account are one transaction rather than
four things somebody has to remember. Three rules are worth stating:

- **What the account is told is a different field from what the team recorded.** The public reason
  goes out by email; the internal note stays internal. Merging them would let a suspension notice
  quote a report, which identifies whoever filed it — the one promise reporting makes.
- **Reinstating does not republish the listings.** They were taken down by a decision; putting them
  back is a decision too, and it is the seller's to make.
- **Staff accounts are not suspended from here.** Locking out an administrator is an account
  decision, not a moderation one, and the queue is worked by people who should not be able to lock
  each other out by accident.

The dashboard reports **queue age, not just queue depth**. A count alone says nothing: three items
that have sat for a week is a worse state than thirty filed this morning, and only the age shows it.

One incidental fix: the suite exceeded PHP's default 128 MB in a single process once image-processing
tests accumulated, so `phpunit.xml` now pins `memory_limit` to 512 MB rather than leaving the run
dependent on whatever the developer's `php.ini` happens to say.

### Phase 15 — monetization

**Nothing is granted until the money has arrived.** A subscription is activated by a settled payment,
never by pressing a button; a promotion runs only while a *paid* row says it does. `subscribe()` and
`purchase()` open a payment and grant nothing — only `activate()`, reached from `PaymentSettled`,
turns anything on. Any other arrangement lets a seller take a plan by starting a payment and closing
the page.

**The gateway boundary is the point of the phase.** `App\Contracts\PaymentGateway` says what the
marketplace needs from a provider and nothing more; `GatewayResult` gives every driver one shape to
answer in, so a provider that settles in-process, one that redirects and one reconciled by hand all
come out of the same code path. Nothing outside `app/Payments` names a gateway, checks for one, or
branches on one — a marketplace whose listing rules know about a particular processor cannot change
processor without a rewrite.

Two drivers ship, and both are real:

- **`BankTransferGateway`** — the payer is shown account details and a reference, and the payment
  sits `Pending` until somebody on the finance side marks it received against the statement. Nothing
  is invented and nothing is pretended.
- **`OfflineGateway`** — money that changed hands off the site, so the books are not half in the
  database and half in somebody's notebook. It settles on the spot, which is exactly why
  `availableToSellers()` excludes it and both checkout controllers validate the posted `gateway`
  against that list. Without that constraint a seller could post `gateway=offline` and mark their own
  subscription paid — a real hole, caught by a test rather than by review.

Adding an online processor is one class beside those two and one row in `config/payments.php`.

Other decisions worth recording:

- **Settlement is idempotent**, because it is reached from three directions — the payer's browser
  returning, a server-to-server callback, and a human reconciling a transfer — and two of those
  routinely arrive together. Settling twice must not issue two invoices or extend a period twice.
- **A free plan still gets a payment row**, settled at zero, so the ledger has no gaps and nothing
  downstream has to special-case the free tier.
- **Cancelling is not expiring.** A cancelled subscription runs to the end of what was paid for; it
  is `renews = false`, not a switch-off. Collapsing the two either cuts somebody off early or keeps
  billing them.
- **A renewal extends nothing until it is paid.** The period moves in `activate()`. Until then the
  subscription is `PastDue` rather than dead, because a bank transfer takes a day and locking a
  paying seller out mid-transfer is worse than carrying them through the grace period.
- **Promotions are time-bounded rows; `listings.is_featured` is a cache of them**, rebuilt by
  `syncListing()` and swept hourly. A boolean somebody forgot to clear is a listing featured for
  ever. Buying the same placement twice extends it rather than stacking it — stacking would be money
  for nothing.
- **Invoices copy their billing details rather than joining for them**, and the number series is
  contiguous per year, taken under a lock. An invoice is a record of what was true on the day; a
  seller renaming their business must not silently rewrite last year's paperwork.
- **A payment's status is derived from its refunds**, not adjusted by them, so a corrected refund
  cannot leave the payment claiming something the refunds do not support.
- **The plan cap and the verification floor are both consulted and the lower wins.** Paying does not
  buy a way past being unverified, and a small plan is not widened by being verified.

### Phase 16 — SEO and analytics

**The sitemap is generated and sectioned.** A marketplace's URLs change every day, so a static file
somebody regenerates by hand is wrong within a week; and a marketplace's listings outgrow the
50,000-URL limit long before its content pages do, so one enormous file would make a crawler
re-fetch everything to learn that one listing changed. There is an index and four sections — pages,
listings, sellers, content — each cached for an hour, because a crawler asks far more often than the
answer changes.

Every section goes through the same published scopes the public pages use. **A sitemap that
advertises a draft is worse than no sitemap**: it hands a crawler a page that 404s the moment
moderation rejects it. Paid placement deliberately does not raise a listing's `priority` — freshness
does. Promotion is a product, not a signal to a search engine.

`robots.txt` is served rather than shipped, so the sitemap address is right on any domain and a
staging copy is closed to crawlers by `APP_INDEXABLE=false` rather than by an edit somebody forgets
to revert. Under any non-production environment it disallows everything by default.

**One canonical URL builder.** The property URL was being assembled in six places — the controller,
the search service, a notification, two partials and two account views — and a URL built six ways
drifts into six URLs for one listing, which is precisely the duplicate-content problem canonical
links exist to prevent. `Listing::publicUrl()` is now the only one, and everything else delegates.

**Analytics are counted per day, not per event.** `listing_daily_stats` holds one row per listing per
day, incremented atomically by an upsert-then-increment so two requests landing together cannot both
read the same number. There is no event log: a row per page view would be the largest table in the
application within a month, and nothing either screen asks needs one.

The trade that buys is that **uniqueness is decided as a view is recorded** — a 24-hour cache key per
viewer per listing — and cannot be recomputed afterwards. It is approximate on purpose: somebody
browsing on a phone and a laptop counts twice, and the screen says so rather than implying a
precision that would require tracking people across devices. A seller refreshing their own listing is
not counted at all.

The seller screen leads with the **enquiry rate**, not with views. Views alone flatter a listing
nobody contacts; the ratio between the two is the number that tells a seller their price is wrong.
Unfavouriting does not decrement the counter — the day a listing was saved it was saved, and a daily
counter that runs backwards is not a history. The per-listing chart is drawn in CSS, with the same
figures repeated in a table underneath: a page of listings should not download a plotting engine to
show one series of bars.

### Phase 17 — the other verticals

The claim this phase had to make good on: **adding a vertical is configuration, not code.**

`AdditionalSectorsSeeder` adds vehicles, electronics, furniture, machinery, agriculture, fashion,
services and businesses — eight sectors, around a hundred and twenty categories and thirty
attributes — and **writes no schema at all**. A vehicle listing is the same `Listing` as a house with
different attribute values bound to a different category. Had this needed a migration, the attribute
engine built in phase 3 would have failed at the one thing it exists to do.

Attributes are reused rather than duplicated. "Condition" is the same question for a flat and for a
phone, so it is one row; two rows meaning the same thing is how a filter panel ends up asking it
twice. They are bound to top-level categories and inherited downward, so "Make" is declared once on
Cars rather than six times beneath it. Nothing is seeded as required — a vertical launching with a
mandatory field nobody has data for is a vertical nobody can list in.

One enum case was added: **`ListingIntent::Service`**. A plumber listed "to lease" is nonsense a buyer
has to read past. It is an enum case rather than a column because the intent is already a string — a
new vertical still costs no migration.

`MarketplaceController` is the proof: it is `PropertyController` with the sector read from the URL
instead of hard-coded, and it needed no new table, service or query. Real estate keeps its own
controller and its own `/properties/...` URLs because it is the vertical the site leads with and
earns the bespoke treatment — not because it works differently underneath. `Listing::publicUrl()`
picks the shape from the listing's own sector, so no caller has to know.

Three real bugs surfaced while wiring it up, each caught by a test rather than by review:

- **The filter panel had never worked with JavaScript enabled.** `fragmentFilter` reads
  `$refs.results`, but on the property page the results container sits outside the form that carries
  the `x-data` — so every filter change threw a `TypeError` instead of swapping the results in.
  Shipped in phase 7 and unnoticed since. The component now falls back to a document lookup and does
  nothing at all when there is no container, leaving the plain form submit to carry the filter.
- **The sitemap advertised every listing under `/properties`**, because it eager-loaded
  `category:id,slug` without `sector_id`, leaving `publicUrl()` unable to tell which vertical it was
  in.
- **Card grids would have lazy-loaded the sector per row.** `SearchService` now selects
  `category.sector` explicitly, and loads `attributeValues` only for non-property sectors — real
  estate has an indexed projection and does not need them, so the extra load is paid only where it
  earns its keep.

### Phase 18 — performance

Measured first. With warm caches the hot pages run 10–20 queries each — the property index 10, a
property page 19, the home page 20, a seller profile 8 — and none of them grew with the number of
rows on the page. There was no N+1 to find in the public site, which is what phases 6 and 7 were
careful about at the time.

The durable output is **`QueryBudgetTest`**: eleven guards that assert a page's query count does not
grow with its results. Each measures the *second* call, not the first — the layout composer, the
attribute vocabulary and the settings all cache on first use, so a cold request runs about twice the
queries of every one after it, and comparing a cold page against a warm one reports an improvement
where there was none. Getting that wrong was the first thing this test did.

The ceilings on absolute counts are deliberately loose. A budget tight enough to break on any
refactor gets raised without being read, which is worse than not having one.

**One real N+1, in the slot picker.** `availableSlots()` asked the database whether the seller was
free once per slot: eighteen queries to draw a working day, a hundred and twenty-six for a week — for
a set of rows that fits in memory several times over. It now reads the day's blocking appointments
once and decides the overlap in PHP, applying the same edge rule the SQL scope does.

**Indexes**, chosen from the queries that actually run rather than from columns that look important —
an index nothing uses still costs a write on every insert, so each names the query it exists for:

- `listings (status, expires_at)` — `scopeLive()` sits on the front of every public query, and the
  existing `(status, published_at)` index did not cover the expiry test.
- `listings (status, is_featured, published_at)` — the default sort, featured then newest.
- `listings (is_featured, featured_until)` — the promotion sweep and the featured filter.
- `leads (seller_id, status, created_at)` — the pipeline, sorted.
- `appointments (seller_id, status, scheduled_at, ends_at)` — overlap detection, which a seller
  waits on.

Two were considered and rejected: `favourites (user_id, listing_id)` is already covered by the unique
constraint on the same pair, and `messages (conversation_id, created_at)` already exists.

**Caching** was already doing most of the work — the layout composer, the attribute vocabulary, the
sitemap. Added: the marketplace sector counts, which scan every live listing in every vertical for a
figure nobody reads to the unit.

The asset bundles were checked rather than assumed: `app.js` 118 kB and `app.css` 154 kB uncompressed,
with select2 (157 kB), Leaflet (182 kB) and the Trix admin bundle (192 kB) all split out and loaded
only where they are used. A visitor browsing listings downloads none of the three.

### Phase 19 — security review

Written as **attacks rather than as assertions about intent**. Every test in `SecurityAuditTest` tries
to do something it should not be able to do — read another account's records, set a column the form
does not offer, reach a private document, act while suspended, escalate a role, enumerate what should
not be enumerable. A passing test means the attempt was refused, not that the code looked careful.

Twenty-seven attempts. Nineteen were already refused. **Four landed**, and all four were real:

**1. The admin panel was open to any authenticated account.** `routes/admin.php` sat behind `auth`
and `active` only. That was correct when it was written — before phase 2 every authenticated user
*was* staff — and quietly became a data leak the moment public registration shipped: any buyer could
open `/admin` and read contact messages, job applications and content counts. The dashboard has no
permission gate of its own, so nothing else stopped them. Closed with an `EnsureUserIsStaff`
middleware on the whole group; per-module permissions still do their own work inside each controller.
This is the most serious thing found in the whole build.

**2. Stored cross-site scripting through the structured data.** Every JSON-LD block encoded with
`JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` and nothing else. A listing titled
`</script><script>…` closes the block and runs whatever follows — and on a marketplace, anybody at
all can set a title. Blade's escaping cannot help, because the block is deliberately raw. All eight
blocks now go through one `ld_json()` helper that adds `JSON_HEX_TAG` and its companions.

**3. LIKE wildcards from visitors went straight into the query.** A search for `%` matched every row.
Not a breach, but it defeats the search and lets a scraper page the entire catalogue with one
character. Eleven call sites now go through `like_term()`, which escapes the backslash first so that
escaping the other two is not undone by it.

**4. A 301 confirmed that a draft listing exists.** The legacy property URL redirected to the
canonical one *before* checking visibility, so a slug-guesser got a redirect for a real draft and a
404 for a made-up one — which answers exactly the question they were asking. Visibility is now
checked first.

What was already right and now has a test holding it: ownership on every account resource, the
private disk with no public URL, mass assignment on `status`, `is_featured`, `user_id` and
`verification_status`, the role boundaries between Content Editor, Media Manager and Super Admin,
marketplace roles carrying no panel permissions at all, honeypot and rate limiting on the public
forms, a withdrawn listing 404ing rather than explaining itself, and a password reset that answers
identically whether or not the account exists.

### Phase 20 — QA and production readiness

**`ProductionReadinessTest`** guards the class of mistake that is invisible in development and
expensive on a live server:

- The demo seeders write nothing when the environment says production — asserted by counting rows,
  not by trusting a warning nobody reads. (Laravel's own confirmation prompt turns out to be a second
  guard in front of that one.)
- The structural seeders are safe to re-run: a deployment that reseeds does not double the taxonomy,
  the locations or the permissions.
- **Nothing outside `config/` calls `env()`.** It returns null once `config:cache` has run, so such a
  call works in development and silently returns nothing in production — the exact bug that once had
  `UserSeeder` seeding the documented fallback password instead of the configured one.
- Every module in `config/modules.php` points at a route that exists, belongs to a declared group,
  and is named only by role presets that can grant it.
- Every homepage section has a view; every configured payment gateway resolves and agrees about its
  own name.
- The private disk has no public URL and no remote driver.
- Every scheduled command is registered and runs.
- No route name is registered twice, and the CMS catch-all does not swallow a reserved prefix.

**`docs/DEPLOYMENT.md`** covers first deployment, the seeding order and why it is that order, the
queue worker, the cron entry with a table of what breaks without each job, the update procedure, what
to check after going live, and what to back up — including the point that `storage/app/private` holds
citizenship certificates and land deeds, and that a backup of it needs the same care as the original.

**`.env.example`** went from 58 documented keys to 83. Every marketplace and payment setting is now
there with its reasoning beside it, including `APP_INDEXABLE` — the one switch that keeps a staging
copy out of a search index without an edit somebody has to remember to revert.

---

## Where this leaves the project

All twenty phases are delivered. The suite is **675 tests**, all passing (1,878 assertions).

The architecture holds the line it was drawn on: `Marketplace → Sector → Category → Subcategory →
Attributes → Listing`, with real estate as a *profile* of the listing engine rather than as the
engine itself. Phase 17 is the proof — eight further verticals, roughly a hundred and twenty
categories, and not one migration.

What a future contributor should read first: this document for the reasoning, `docs/DEPLOYMENT.md` to
put it live, `config/modules.php` to add an admin screen, and `MarketplaceTaxonomySeeder` to see how a
vertical is described. What they should not do is add a column to `listings` for a field that belongs
to one vertical; that is what the attribute engine is for, and the moment it is bypassed the next
vertical costs a migration again.

### Post-delivery — two production 500s the green suite had not caught

Reported after phase 20 signed off: the home page returned
`LazyLoadingViolationException — Attempted to lazy load [sector] on model [Category]`. Both bugs
below were live on the server while all 657 tests passed, and the reason they escaped is more useful
than the fixes.

**1. `Listing::publicUrl()` walked `category.sector`.** Phase 17 gave the URL builder the job of
deciding which vertical a listing was in, and it did that by walking a relation. That made every URL
depend on what the calling page had remembered to eager-load, and the same line then behaved three
different ways:

- page eager-loads nothing → lazy-loading violation, 500;
- page eager-loads `category:id,name,slug` → **`sector_id` was not selected, so the `belongsTo`
  returns null without ever running a query** — no violation, no error, and a marketplace listing
  quietly given a `/properties/...` URL;
- page eager-loads `category.sector` → correct.

The silent middle case is why the home page test passed: its own eager load hid the fault. `TaxonomyMap`
now resolves both the sector and the category slug from the listing's own foreign keys through a
per-request cached lookup, so **URL building touches no relations at all**. The guard is a test that
loads a listing with `select('id','slug','district','sector_id','category_id')` and asserts both that
the URL is right and that nothing got loaded building it — an invariant, not a page render.

**2. The sitemap's `pages` section queried a column that does not exist.** `Page` is gated on
`is_active` through `HasContentScopes`; the sitemap asked for `is_published`. On MySQL that is a fatal
error and `/sitemap.xml` returned 500. On SQLite it is not an error at all: **SQLite treats a
double-quoted unknown identifier as a string literal**, so the clause became `'is_published' = 1`,
matched nothing, and returned an empty section. Every status-code assertion in `SeoAnalyticsTest`
passed against a sitemap that had quietly lost its pages.

The lesson is the one worth carrying: **assert the rows, not the status code.** A page returning 200
with a section silently emptied is indistinguishable from a working page unless something checks what
is actually in it. `SeoAnalyticsTest` now asserts a real page, listing and seller URL appear in their
sections.

Verification was done against MySQL rather than the test suite, because the test suite is exactly
what had missed both: a sweep of 44 real URLs — home, every listing view, every sector, sample listing
detail pages, seller profiles, CMS pages, all five sitemap sections and robots.txt — all 200.

Also fixed while sweeping: the amenity admin screen counted the retired `properties` table and showed
every amenity as unused, and `AdditionalSectorsSeeder` and both taxonomy admin screens now clear
`TaxonomyMap` so a renamed sector or category changes the URLs it produces.
