diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md
index b3ceedf..e8b6d64 100644
--- a/docs/TECHNICAL.md
+++ b/docs/TECHNICAL.md
@@ -53,7 +53,8 @@ client SDK writes (content management) or through Admin-SDK-backed API routes
`terms`, `trade`, `sitemap`) are async server components; the data-driven
ones await Firestore reads during rendering.
- **Client components** are used where interactivity or browser state is
- required: `admin-dashboard.tsx`, `admin-access.tsx`, `trade-inquiry-form.tsx`,
+ required: `admin-dashboard.tsx`, `admin-access.tsx`, `admin-trade-*.tsx`,
+ `trade-inquiry-form.tsx`,
`beer-carousel.tsx`, `beers-filter-grid.tsx`, the analytics trackers,
`mobile-menu.tsx`, `site-header-default.tsx`, and the home-page intro/CTA
components.
@@ -120,10 +121,10 @@ client SDK writes (content management) or through Admin-SDK-backed API routes
| --- | --- |
| `app/` | App Router routes. Root `layout.tsx` (header/footer shell, SEO defaults, favicon metadata pointing at `public/`, analytics wiring), `globals.css` (Tailwind v4 theme tokens), `robots.ts`, `sitemap.ts`, `page.tsx` (home). |
| `app/(pages)/` | Route group for all content pages — `about` (MDX), `admin`, `beers` (+`[slug]`), `contact`, `privacy`, `terms`, `trade` (+ `login`/`order`/`orders` "coming soon" placeholders), `where-to-buy` — sharing a `SiteHeaderDefault` layout. Pages are `.tsx`; `about` is authored as `page.mdx` — see §4. |
-| `app/api/` | Server API routes: `admin/bootstrap`, `admin/invitations/accept`, `admin/invitations/[id]/resend`, `admin/me`, `admin/rebuild`, `admin/users` (GET list + POST create-invitation), `admin/users/[uid]` (PATCH/DELETE), and `trade-inquiry`. All are Admin-SDK-protected except `trade-inquiry`. |
-| `components/` | App components: header/footer, home sections, cards, carousel/filter grid, analytics trackers, `admin-dashboard.tsx` (auth + data orchestration), `admin-workspace.tsx` (props-driven authenticated view shared with `/admin-fixture`), `admin-access.tsx`, `admin-fixture.tsx` (test-only data), `trade-inquiry-form.tsx`, `mdx-layout.tsx`. |
+| `app/api/` | Server API routes: `admin/bootstrap`, `admin/invitations/accept`, `admin/invitations/[id]/resend`, `admin/me`, `admin/rebuild`, `admin/users` (GET list + POST create-invitation), `admin/users/[uid]` (PATCH/DELETE), `admin/trade-leads` (GET list + POST create), `admin/trade-leads/[id]` (GET + PATCH), `admin/trade-leads/[id]/activities` (POST note), and `trade-inquiry`. All are Admin-SDK-protected except `trade-inquiry`. |
+| `components/` | App components: header/footer, home sections, cards, carousel/filter grid, analytics trackers, `admin-dashboard.tsx` (auth + data orchestration), `admin-workspace.tsx` (props-driven authenticated view shared with `/admin-fixture`), `admin-access.tsx`, `admin-fixture.tsx` (test-only data), `admin-trade-page.tsx`/`admin-trade-workspace.tsx`/`admin-trade-summary.tsx`/`admin-trade-fixture.tsx` (lead pipeline + test fixture), `trade-inquiry-form.tsx`, `mdx-layout.tsx`. |
| `components/ui/` | shadcn/ui primitives (Radix-based) configured by `components.json`. |
-| `lib/` | Shared logic. Client-safe: `firebase.ts`, `beers.ts`, `venues.ts`, `analytics.ts`, `types.ts`, `utils.ts`, `email.ts`, `trade-leads-common.ts`, admin `*-common`/`admin-format.ts` helpers. Server-only (`import "server-only"`): `firebase-admin.ts`, `admin-auth.ts`, `admin-users.ts`, `admin-invitations.ts`, `admin-invitation-email.ts`, `admin-invitation-resend-core.ts`, `admin-audit.ts`, `trade-leads.ts`. Policy/serialization helpers shared by both: `admin-policy.ts`, `admin-serializers.ts`, `admin-invitation-policy.ts`, `admin-invitation-resend-policy.ts`, `admin-types.ts`. |
+| `lib/` | Shared logic. Client-safe: `firebase.ts`, `beers.ts`, `venues.ts`, `analytics.ts`, `types.ts`, `utils.ts`, `email.ts`, `trade-leads-common.ts`, admin `*-common`/`admin-format.ts` helpers, `trade-leads-admin-common.ts`. Server-only (`import "server-only"`): `firebase-admin.ts`, `admin-auth.ts`, `admin-users.ts`, `admin-invitations.ts`, `admin-invitation-email.ts`, `admin-invitation-resend-core.ts`, `admin-audit.ts`, `trade-leads.ts`, `trade-leads-admin.ts`. Policy/serialization helpers shared by both: `admin-policy.ts`, `admin-serializers.ts`, `admin-invitation-policy.ts`, `admin-invitation-resend-policy.ts`, `admin-types.ts`. |
| `tests/` | Node `node:test` unit tests (`tsx` loader) for admin/auth/invitation/audit helpers and for the *contents* of `firestore.rules` and `storage.rules`. |
| `rules-tests/` | Emulator-backed security-rules tests (`@firebase/rules-unit-testing` against the Firestore/Storage emulators). Run via `npm run test:rules`, which wraps `firebase emulators:exec`; each file uses its own `demo-*` project so parallel `node:test` files stay isolated. |
| `scripts/` | Local/manual tooling: Playwright diagnostics (`*-check.mjs`, `hero-video-network.mjs`), `check-md-links.mjs`, `check-react-versions.mjs`, `optimize-assets.mjs`, `bootstrap-superadmin.ts`, `prune-trade-leads.ts`, `seed-beers.ts`, `seed-venues.ts`. `check-md-links.mjs` and `check-react-versions.mjs` run in CI; the Playwright diagnostics and data scripts do not (CI browser coverage lives in `smoke-tests/`). |
@@ -221,19 +222,32 @@ in code are listed.
— the system of record (owner decision, #57); the Resend email is only a
notification.
- **Fields:** `businessName`, `contactName`, `email`, `phoneOrWhatsapp`,
- `venueType`, `message`, `status` (`"new"` on create), `source`
- (`"trade_form"`), `createdAt`/`updatedAt` (server timestamps).
+ `venueType`, `message`, `status` (`new | contacted | follow_up | customer |
+ closed`; `"new"` on create), `source` (`"trade_form"` for the public form;
+ manual leads use `whatsapp | phone | in_person | referral | event | email |
+ other`), pipeline fields `assignedToUid`/`assignedToName`, `nextFollowUpAt`,
+ `closedAt`, `outcome`, and `createdAt`/`updatedAt`/`lastActivityAt`
+ timestamps. History lives in a `tradeLeads/{id}/activities` subcollection —
+ append-only entries (`type`, `seq`, `authorUid`/`authorName`, `body`,
+ `details`, `createdAt`); a reserved `communication` type with a channel-
+ agnostic payload scaffolds future outbound messaging.
- **Writes:** server-only — `persistTradeLead()` in `lib/trade-leads.ts` via
- the Admin SDK from `POST /api/trade-inquiry`.
-- **Visibility:** **no client access at all** — `read, write: if false`.
- Leads are PII; operators view them via the Firebase console (an admin UI
- would be a separate feature).
+ the Admin SDK from `POST /api/trade-inquiry` (writes a `lead_created`
+ activity), plus the `/api/admin/trade-leads*` routes (`lib/
+ trade-leads-admin.ts`) for the pipeline's list/detail/patch/create/note
+ operations.
+- **Visibility:** **no client access at all** — `read, write: if false` on
+ both `tradeLeads/{leadId}` and the nested `activities/{activityId}` match.
+ Leads are PII; admins work them in the `/admin/trade` pipeline, which reads
+ through authenticated API routes only.
- **Retention (#59):** up to 24 months after the last meaningful activity —
- anchored on `updatedAt` (currently always equal to `createdAt` since
- nothing modifies leads) — unless a legitimate business/legal/accounting/
- dispute/security reason requires longer; earlier deletion when no longer
- needed. Pruning is manual via `npm run prune:trade-leads` (dry-run by
- default; `--delete` executes). No automated deletion exists.
+ anchored on `updatedAt`, which advances on notes, status, owner, and
+ follow-up mutations but **not** on reads — unless a legitimate business/
+ legal/accounting/dispute/security reason requires longer; earlier deletion
+ when no longer needed. Pruning is manual via `npm run prune:trade-leads`
+ (dry-run by default; `--delete` executes and removes each lead's
+ `activities` subcollection — Firestore deletes do not cascade). No
+ automated deletion exists.
### `adminUsers`
@@ -316,8 +330,9 @@ API routes (with rollback on partial failure — see §7/§8).
`isPublic == true` and admin-writable; `meta` is admin-only; `adminUsers`,
`adminInvitations`, and `adminAuditLogs` are superadmin-only in rules
(audit logs additionally immutable — `update, delete: if false`); in practice
- only Admin SDK server code touches admin collections; `tradeLeads` denies
- all client access — leads are written server-side only. A catch-all rule
+ only Admin SDK server code touches admin collections; `tradeLeads` and its
+ `activities` subcollection deny all client access — leads are written
+ server-side only. A catch-all rule
denies everything else.
- **Storage:** `storage.rules` allows public reads and active-admin writes
(`hasActiveAdmin`); images are stored under the `NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET`
@@ -534,7 +549,9 @@ notification.** (Owner decision, #57.)
across serverless instances.
3. **Persistence:** `persistTradeLead()` writes the inquiry to `tradeLeads`
via the Admin SDK (`buildTradeLeadRecord()` in
- `lib/trade-leads-common.ts` shapes the document). Orchestration lives in
+ `lib/trade-leads-common.ts` shapes the document), along with a
+ `lead_created` entry in the lead's `activities` subcollection.
+ Orchestration lives in
`processTradeInquiry()` — injectable and unit-tested. Only validated form
fields plus `status`, `source`, and server timestamps are stored — never
IPs, headers, honeypot values, or analytics identifiers.
@@ -558,6 +575,28 @@ notification.** (Owner decision, #57.)
the configured inbox. Spam controls are the honeypot plus the per-instance
IP rate limit — no captcha. The route is unauthenticated by design.
+### Admin pipeline (`/admin/trade`, issue #150)
+
+The `tradeLeads` store is worked through an authenticated pipeline:
+
+- `GET /api/admin/trade-leads` returns all leads plus the active-admin
+ directory; `POST` creates a manual lead (source is explicit and never
+ `trade_form`). `GET/PATCH /api/admin/trade-leads/[id]` return/update one
+ lead with its activity timeline; `POST .../activities` appends a note.
+ Every route calls `requireAdminActor()`; mutations re-read the lead inside
+ a transaction and write lead + `activities` atomically so a failed update
+ never leaves a dangling history entry.
+- The UI (`components/admin-trade-workspace.tsx` behind
+ `components/admin-trade-page.tsx`, routed at `app/(pages)/admin/trade`)
+ holds the list + detail in one client surface, with filters (status,
+ owner, business type, follow-up state, text search) applied **in memory**
+ over the single list fetch — expected volume is low, so no Firestore
+ composite indexes were added (`firestore.indexes.json` stays empty).
+- Status/owner/follow-up changes are validated by `buildLeadUpdate()` in
+ `lib/trade-leads-admin-common.ts`, which produces both the document patch
+ and the matching timeline entries. Reads never write, so viewing a lead
+ does not extend its retention.
+
## 12. Analytics and observability
- **GA4 via GTM:** `app/layout.tsx` renders `components/gtm-bootstrap.tsx`
@@ -687,19 +726,23 @@ placeholder values exist anywhere in CI.
`node --test "tests/**/*.test.ts"`). The glob requires Node ≥ 21 — satisfied
by the repository's Node 24 runtime (on Node 20 the pattern silently matched
zero files, which is why the runtime was normalized).
-- **Coverage (104 tests, all in `tests/`):** admin auth/claim parsing
+- **Coverage (`tests/`):** admin auth/claim parsing
(`admin-auth`), admin-users record building/serialization, invitation
policy/email/resend/cooldown logic, audit helpers, `admin-policy` mutation
- guards and the `checkAdminActorRecord` active-record policy,
+ guards and the `checkAdminActorRecord` active-record policy, the trade-lead
+ pipeline domain (`trade-leads-admin`: status model, follow-up
+ classification, update planning, manual-lead validation, serializers,
+ route-auth wiring),
service-initialization config (`resend-config` key validation and sender
selection, `lib/firebase.ts` import-time laziness/first-use caching), and
**rules-content tests** that read `firestore.rules` and `storage.rules` as
text and assert required patterns.
-- **Emulator rules tests (`rules-tests/`, 27 tests):** `npm run test:rules`
+- **Emulator rules tests (`rules-tests/`):** `npm run test:rules`
wraps `firebase emulators:exec --only firestore,storage` and evaluates the
real rules via `@firebase/rules-unit-testing` — active/disabled/missing
`adminUsers` records, role mismatches in both directions, unauthenticated
- and non-admin callers, public reads, the `tradeLeads` schema, superadmin
+ and non-admin callers, public reads, the `tradeLeads` schema and its
+ `activities` subcollection deny-all, superadmin
collections, audit immutability, and the Storage cross-service lookup. Each
test file uses its own `demo-*` project so parallel `node:test` files never
share emulator state. (`firebase-tools` and `@firebase/rules-unit-testing`
diff --git a/docs/admin/README.md b/docs/admin/README.md
index 53d1460..96152e8 100644
--- a/docs/admin/README.md
+++ b/docs/admin/README.md
@@ -11,6 +11,7 @@ The admin dashboard lets authorized users:
- View, create, and edit beer records.
- View, create, and edit venue/partner records.
- Upload beer card and hero images to Firebase Storage.
+- Manage trade leads — statuses, owners, notes/history, and follow-ups — in the Trade leads pipeline at `/admin/trade` (see [Trade inquiries](./trade-inquiries.md)).
- Trigger a site rebuild so static pages (such as the beer listing and Where to Buy page) show the latest content.
In addition, users with the **superadmin** role can:
diff --git a/docs/admin/trade-inquiries.md b/docs/admin/trade-inquiries.md
index cb3ad2b..a915f22 100644
--- a/docs/admin/trade-inquiries.md
+++ b/docs/admin/trade-inquiries.md
@@ -1,6 +1,6 @@
# Trade Inquiries
-This guide explains how wholesale and trade inquiries reach the team, what information is collected, and how to handle submissions safely.
+This guide explains how wholesale and trade inquiries reach the team, what information is collected, how to work the lead pipeline in the admin app, and how to handle submissions safely.
## Where trade inquiries originate
@@ -10,6 +10,8 @@ https://deepdivebrewing.com/trade
The form is also linked from the beer listing and individual beer pages.
+Administrators can also record leads by hand — a phone call, a WhatsApp message, an in-person conversation, a referral, or a trade-show contact — from the trade pipeline (see below). Manual leads carry their own `source` value; `trade_form` is reserved for the public form.
+
## Fields collected
The form asks for the following information:
@@ -25,6 +27,8 @@ The form asks for the following information:
There is also a hidden honeypot field named `website`. Humans never see it. If it is filled, the submission is treated as spam and silently discarded.
+For manual leads the requirements are looser: business name is required, plus at least one contact channel (email **or** phone/WhatsApp), since some real-world contacts arrive without an email address.
+
## Backend handling
When a visitor submits the form:
@@ -33,7 +37,7 @@ When a visitor submits the form:
2. The server validates that the required fields are present.
3. If the honeypot field is filled, the request is ignored but responds with success so bots do not know they were blocked.
4. The server checks a per-IP rate limit (5 submissions per 10 minutes).
-5. The server **persists the inquiry to the `tradeLeads` Firestore collection** — this is the durable record.
+5. The server **persists the inquiry to the `tradeLeads` Firestore collection** — this is the durable record — and writes a `lead_created` entry to the lead's `activities` history.
6. The server sends a notification email through **Resend** to the address configured in `TRADE_INQUIRY_TO_EMAIL`. The email's reply-to address is set to the submitter's email, and the body includes the Firestore lead ID as a reference.
7. If the email fails after the inquiry was saved, the submission still counts as received — the inquiry is safely stored and the failure is logged for operators.
@@ -45,14 +49,58 @@ Trade Inquiry — ()
The email body contains all submitted fields in a simple HTML table, plus a `Reference` row with the lead ID.
+## The trade pipeline (`/admin/trade`)
+
+The normal operational workflow is the **Trade leads** page inside the admin app: sign in at https://deepdivebrewing.com/admin, then open the pipeline via the **Trade leads** summary (or go directly to `/admin/trade`). The Firebase console remains available for inspection and debugging, but it is no longer the day-to-day workflow.
+
+### The list view
+
+The left column lists every lead, newest first by default. Each row shows the business name, contact, business type, status, owner, follow-up state, and created/last-activity dates. Filters cover status, owner, business type, and follow-up state (overdue / due today / upcoming / none), plus a text search and newest/oldest/follow-up-first sorting.
+
+### Lifecycle statuses
+
+| Status | Meaning |
+|---|---|
+| **New** | Freshly received, not yet contacted. |
+| **Contacted** | First reply or conversation happened. |
+| **Follow-up** | Waiting on a next step — a call-back, samples, pricing. |
+| **Customer** | They buy beer. Terminal; the record is kept. |
+| **Closed** | Not proceeding. Terminal; the record is kept. |
+
+A lead is never deleted when it becomes a customer or is closed — the full history stays. Closing a lead records `closedAt` and an optional free-text **outcome** (e.g. "First order placed", "Not a fit"). Reopening a lead (moving it back to an active status) clears `closedAt` and the outcome.
+
+### Ownership
+
+A lead can be assigned to any **active administrator** — the same `adminUsers` accounts as the rest of the admin app; there is no separate sales-user table. The owner name is snapshotted onto the lead (`assignedToUid` + `assignedToName`) so history stays readable. Assign or unassign from the Owner select in the lead workspace.
+
+### Notes and history
+
+History is **append-only**: each lead has an `activities` subcollection, and entries are never edited or overwritten. A timeline reads like a log:
+
+- Oct 2, Chad: Spoke on WhatsApp. Interested in Saison.
+- Oct 4, Malachy: Dropped off samples.
+- Status changed from Contacted to Follow-up.
+- Follow-up moved to Oct 18.
+
+Staff add notes with the **Add a note** box on the lead workspace — a note is the fastest way to log a conversation. The pipeline automatically records: lead created (with source), status changed (from → to), owner changed, follow-up set/moved/cleared, and outcome recorded or cleared on terminal leads.
+
+### Follow-ups
+
+Each open lead can carry a `nextFollowUpAt` date (optional). The UI highlights **Overdue** and **Due today** states in both the list and the lead workspace; classification is by calendar day in the viewer's timezone. Set, change, or clear the date from the lead workspace. Customer and closed leads cannot hold a follow-up — entering a terminal status clears it so they never show a misleading overdue badge.
+
+### Manual lead creation
+
+**New lead** opens a form recording business name, contact name, email and/or phone/WhatsApp, business type, a source (WhatsApp, Phone call, In person, Referral, Event / trade show, Email, Other), and initial notes. Manual leads start as **New** and flow through the same pipeline.
+
## Where inquiries are stored
**Firestore is the system of record.** Each inquiry is saved to the `tradeLeads` collection with:
- the submitted fields (business name, contact name, email, phone/WhatsApp, venue type, message),
-- `status: "new"`, `source: "trade_form"`, and server-generated `createdAt`/`updatedAt` timestamps.
+- `status`, `source`, pipeline fields (`assignedToUid`/`assignedToName`, `nextFollowUpAt`, `closedAt`, `outcome`), and `createdAt`/`updatedAt`/`lastActivityAt` timestamps,
+- an `activities` subcollection holding the append-only history.
-The collection is **not readable by the website or admin dashboard** — Firestore rules deny all client access, and only server-side code (the inquiry API route) writes to it. Administrators can view leads in the **Firebase console** (Firestore → `tradeLeads`); an admin UI may be added later if needed.
+The collection is **not readable by the website or admin dashboard client code** — Firestore rules deny all client access to `tradeLeads` and its `activities` subcollection. Every read and write flows through authenticated `/api/admin/*` routes that verify the Firebase ID token, the admin custom claims, **and** an active `adminUsers` record (`requireAdminActor`). Server-side code (Admin SDK) is the only data path.
The email to `TRADE_INQUIRY_TO_EMAIL` is a **notification**, not the only copy — if the email fails, the inquiry is still stored.
@@ -62,7 +110,7 @@ The email to `TRADE_INQUIRY_TO_EMAIL` is a **notification**, not the only copy
Trade inquiries are retained for **up to 24 months after the last meaningful activity** on the record (`updatedAt`), unless there is a legitimate business, legal, accounting, dispute, or security reason to keep them longer. They may be deleted earlier when no longer needed.
-Today nothing modifies a lead after submission, so `updatedAt` equals `createdAt` and retention effectively runs from the submission date. If lead-management tooling is added later and updates records on genuine activity, `updatedAt` remains the correct anchor.
+**Meaningful activity** is a real lead mutation: adding a note, changing status, assigning an owner, or setting/changing a follow-up. These advance `updatedAt` and `lastActivityAt`. Merely **viewing** a lead in the pipeline writes nothing and does not extend its retention.
### Pruning expired leads
@@ -73,7 +121,7 @@ npm run prune:trade-leads # dry run: prints cutoff, counts, documen
npm run prune:trade-leads -- --delete # permanently deletes the expired documents
```
-The script uses the same `FIREBASE_ADMIN_*` credentials as `bootstrap-superadmin` (loaded from `.env.local`). It prints only document IDs and aggregate counts — never inquiry contents — and leads whose timestamps cannot be read are reported and kept, never deleted. Before deleting, an operator may review a lead in the Firebase console to confirm no retention exception applies.
+The script uses the same `FIREBASE_ADMIN_*` credentials as `bootstrap-superadmin` (loaded from `.env.local`). It prints only document IDs and aggregate counts — never inquiry contents — and leads whose timestamps cannot be read are reported and kept, never deleted. Deleting a lead also deletes its `activities` subcollection (Firestore document deletes do not cascade). Before deleting, an operator may review a lead in the pipeline or the Firebase console to confirm no retention exception applies.
## Expected success behavior
@@ -92,10 +140,14 @@ If submission fails:
## How administrators should respond
-1. Monitor the inbox configured in `TRADE_INQUIRY_TO_EMAIL`.
-2. Reply directly to the submitter using the reply-to address.
-3. Keep business and contact details confidential; do not forward inquiry emails to unauthorized recipients.
-4. Track follow-up status in your preferred CRM or email workflow. The website does not currently provide a lead-management interface.
+1. Watch the **Trade leads** summary on the admin dashboard (or the `TRADE_INQUIRY_TO_EMAIL` inbox) for new inquiries.
+2. Open the lead in `/admin/trade`, then reply using the contact links — **email** (`mailto:`), **call** (`tel:`), or **WhatsApp** (`wa.me`). Messages are sent from your own mail client/phone, never by the app.
+3. Log what happened with **Add a note**, set a **next follow-up** date when a next step is needed, and move the status forward.
+4. Keep business and contact details confidential; do not forward inquiry details to unauthorized recipients.
+
+## Communications scaffolding (future work)
+
+Replying to leads directly from the app is intentionally **not implemented** yet. The data model is already channel-agnostic so it can be added later without redesigning: a sent or received message is an `activities` entry with `type: "communication"` plus a payload covering channel, direction, recipient/sender, subject/body, timestamps, provider, provider message ID, delivery state, and thread ID — so future email (Resend) or WhatsApp sends will appear in the same timeline as notes.
## Privacy considerations
@@ -104,7 +156,7 @@ If submission fails:
- Retention: up to 24 months after last meaningful activity — see the retention policy above.
- Do not add submitted emails to marketing lists without consent.
- Do not copy inquiry details into insecure locations.
-- Only authorized staff — those with access to the `TRADE_INQUIRY_TO_EMAIL` inbox or the Firebase project — should handle inquiries.
+- Only authorized staff — administrators with an active `adminUsers` record — can see the pipeline. Lead PII is never sent to analytics; server logs use lead IDs and action names only.
## What information must never be sent to analytics
@@ -113,19 +165,19 @@ Analytics events are configured to avoid personally identifiable information (PI
- Email addresses.
- Phone numbers.
- Business names tied to an individual.
-- Any message text from the form.
+- Any message text from the form or note text from the pipeline.
-The current analytics events (`trade_form_start`, `trade_form_success`, `trade_form_error`) only record the event category, CTA location, and venue type.
+The current analytics events (`trade_form_start`, `trade_form_success`, `trade_form_error`) only record the event category, CTA location, and venue type. The admin pipeline emits no analytics events.
## Safe testing procedures
-> **Warning:** Submitting the live form on https://deepdivebrewing.com/trade stores a real inquiry in Firestore (and sends a real email). Do not use production to run tests; test leads must be deleted from the Firebase console.
+> **Warning:** Submitting the live form on https://deepdivebrewing.com/trade stores a real inquiry in Firestore (and sends a real email). Do not use production to run tests; delete test leads afterward.
To test safely:
1. Use a local development environment (`npm run dev`) with a test email address configured in `TRADE_INQUIRY_TO_EMAIL`.
2. Use the Resend test domain or a controlled inbox.
3. Do not enter real customer data during testing.
-4. Delete test emails after verification.
+4. Delete test leads after verification (Firebase console, or let the prune script catch them once expired).
If you must test on production, coordinate with the email recipient so the test submission is expected and can be deleted.
diff --git a/firestore.rules b/firestore.rules
index 5966005..26c98b4 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -60,11 +60,19 @@ service cloud.firestore {
allow delete: if hasActiveAdmin();
}
- // Trade inquiries are PII and are written exclusively server-side by
- // /api/trade-inquiry via the Admin SDK, which bypasses these rules.
- // No client may read or write this collection.
+ // Trade inquiries are PII and are written exclusively server-side —
+ // /api/trade-inquiry creates them and the /api/admin/trade-leads routes
+ // serve the admin pipeline (#150), all via the Admin SDK, which bypasses
+ // these rules. No client may read or write this collection.
match /tradeLeads/{leadId} {
allow read, write: if false;
+
+ // Per-lead activity history (notes, status/owner/follow-up changes).
+ // The catch-all rule would already deny this; the explicit nested
+ // match documents that the subcollection is part of the PII boundary.
+ match /activities/{activityId} {
+ allow read, write: if false;
+ }
}
match /meta/{docId} {
diff --git a/lib/admin-serializers.ts b/lib/admin-serializers.ts
index a988b27..b2cf2dc 100644
--- a/lib/admin-serializers.ts
+++ b/lib/admin-serializers.ts
@@ -14,7 +14,7 @@ function isTimestampLike(value: unknown): value is TimestampLike {
);
}
-function toIsoString(value: unknown): string | undefined {
+export function toIsoString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
if (value instanceof Date) return value.toISOString();
if (isTimestampLike(value)) return value.toDate().toISOString();
diff --git a/lib/trade-leads-admin-common.ts b/lib/trade-leads-admin-common.ts
new file mode 100644
index 0000000..1ab4607
--- /dev/null
+++ b/lib/trade-leads-admin-common.ts
@@ -0,0 +1,735 @@
+// Shared, dependency-free domain logic for the admin trade-lead pipeline
+// (Issue #150). Same split as trade-leads.ts / trade-leads-common.ts: this
+// module is pure and safe for client components (no firebase imports);
+// lib/trade-leads-admin.ts wires the server-only Admin SDK access.
+import { isValidEmail } from "@/lib/email";
+import { toIsoString } from "@/lib/admin-serializers";
+import {
+ timestampMillis,
+ TRADE_LEAD_FIELD_LIMITS,
+ TRADE_LEAD_SOURCE,
+ TRADE_VENUE_TYPES,
+} from "@/lib/trade-leads-common";
+import { todayCalendarDate, type CalendarDate } from "@/lib/whatsapp";
+
+export const TRADE_LEAD_ACTIVITIES_SUBCOLLECTION = "activities";
+
+// --- Lead lifecycle ---
+
+// Canonical status model. `new` is the value every pre-pipeline lead already
+// carries, so existing records need no migration.
+export const TRADE_LEAD_STATUSES = [
+ { value: "new", label: "New" },
+ { value: "contacted", label: "Contacted" },
+ { value: "follow_up", label: "Follow-up" },
+ { value: "customer", label: "Customer" },
+ { value: "closed", label: "Closed" },
+] as const;
+
+export type TradeLeadStatus = (typeof TRADE_LEAD_STATUSES)[number]["value"];
+
+const TRADE_LEAD_STATUS_VALUES: ReadonlySet = new Set(
+ TRADE_LEAD_STATUSES.map((s) => s.value)
+);
+
+export function isTradeLeadStatus(value: unknown): value is TradeLeadStatus {
+ return typeof value === "string" && TRADE_LEAD_STATUS_VALUES.has(value);
+}
+
+// Defensive read normalization: a record with a missing or unrecognized
+// status (e.g. written outside this codebase) is presented as "new" rather
+// than breaking filters. Stored status is never rewritten on read.
+export function normalizeTradeLeadStatus(value: unknown): TradeLeadStatus {
+ return isTradeLeadStatus(value) ? value : "new";
+}
+
+export function tradeLeadStatusLabel(value: unknown): string {
+ const status = normalizeTradeLeadStatus(value);
+ return (
+ TRADE_LEAD_STATUSES.find((s) => s.value === status)?.label ?? status
+ );
+}
+
+// Terminal statuses: a converted or closed lead never reports an overdue
+// follow-up, and entering one clears `nextFollowUpAt` server-side.
+export function isTerminalLeadStatus(
+ status: TradeLeadStatus
+): status is "customer" | "closed" {
+ return status === "customer" || status === "closed";
+}
+
+// --- Lead source ---
+
+// `trade_form` is written by the public /trade flow (see TRADE_LEAD_SOURCE).
+// The remaining values cover leads an admin records by hand.
+export const TRADE_LEAD_SOURCES = [
+ { value: TRADE_LEAD_SOURCE, label: "Website form" },
+ { value: "whatsapp", label: "WhatsApp" },
+ { value: "phone", label: "Phone call" },
+ { value: "in_person", label: "In person" },
+ { value: "referral", label: "Referral" },
+ { value: "event", label: "Event / trade show" },
+ { value: "email", label: "Email" },
+ { value: "other", label: "Other" },
+] as const;
+
+export type TradeLeadSource = (typeof TRADE_LEAD_SOURCES)[number]["value"];
+
+// Sources an admin may pick when recording a lead manually — `trade_form` is
+// reserved for the public submission pipeline.
+export const MANUAL_LEAD_SOURCES = TRADE_LEAD_SOURCES.filter(
+ (s) => s.value !== TRADE_LEAD_SOURCE
+);
+
+const MANUAL_LEAD_SOURCE_VALUES: ReadonlySet = new Set(
+ MANUAL_LEAD_SOURCES.map((s) => s.value)
+);
+
+export function isManualLeadSource(
+ value: unknown
+): value is Exclude {
+ return typeof value === "string" && MANUAL_LEAD_SOURCE_VALUES.has(value);
+}
+
+export function tradeLeadSourceLabel(value: unknown): string {
+ return (
+ TRADE_LEAD_SOURCES.find((s) => s.value === value)?.label ??
+ (typeof value === "string" && value ? value : "Unknown")
+ );
+}
+
+export function tradeVenueTypeLabel(value: unknown): string {
+ return (
+ TRADE_VENUE_TYPES.find((t) => t.value === value)?.label ??
+ (typeof value === "string" && value ? value : "—")
+ );
+}
+
+// --- Activity / history ---
+
+// Append-only per-lead history stored in the `activities` subcollection.
+// `communication` is reserved for a future outbound/inbound messaging
+// feature (see TradeLeadCommunication below); no code writes it yet.
+export const TRADE_LEAD_ACTIVITY_TYPES = [
+ "note",
+ "lead_created",
+ "status_changed",
+ "owner_changed",
+ "follow_up_set",
+ "follow_up_changed",
+ "follow_up_cleared",
+ "outcome_changed",
+ "communication",
+] as const;
+
+export type TradeLeadActivityType =
+ (typeof TRADE_LEAD_ACTIVITY_TYPES)[number];
+
+// Channel-agnostic shape for a future messaging feature (email via Resend,
+// WhatsApp via a provider integration, …). A sent/received message is stored
+// as an activity with `type: "communication"` plus this payload, so it lands
+// in the same timeline as notes and status changes. `deliveryState` may be
+// updated in place as provider delivery callbacks arrive — the only intended
+// exception to append-only history. Not implemented in this issue.
+export interface TradeLeadCommunication {
+ channel: string;
+ direction: "outbound" | "inbound";
+ recipient?: string;
+ sender?: string;
+ subject?: string;
+ body?: string;
+ sentAt?: string;
+ sentByUid?: string;
+ sentByName?: string;
+ provider?: string;
+ providerMessageId?: string;
+ deliveryState?: string;
+ threadId?: string;
+}
+
+// An administrator who can own a lead — the same identity pool as the rest
+// of the admin app (active adminUsers), not a separate CRM user table.
+export interface TradeLeadAssignee {
+ uid: string;
+ name: string;
+}
+
+export interface TradeLeadRecord {
+ businessName: string;
+ contactName: string;
+ email: string;
+ phoneOrWhatsapp: string;
+ venueType: string;
+ message: string;
+ status?: string;
+ source?: string;
+ assignedToUid?: string;
+ assignedToName?: string;
+ nextFollowUpAt?: unknown;
+ lastActivityAt?: unknown;
+ closedAt?: unknown;
+ outcome?: string;
+ activityCount?: number;
+ createdAt?: unknown;
+ updatedAt?: unknown;
+}
+
+// Serialized view returned to the admin client: all timestamps as ISO
+// strings so nothing Firestore-specific crosses the API boundary.
+export interface TradeLeadView {
+ id: string;
+ businessName: string;
+ contactName: string;
+ email: string;
+ phoneOrWhatsapp: string;
+ venueType: string;
+ message: string;
+ status: TradeLeadStatus;
+ source: string;
+ assignedToUid?: string;
+ assignedToName?: string;
+ nextFollowUpAt?: string;
+ lastActivityAt?: string;
+ closedAt?: string;
+ outcome?: string;
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export interface TradeLeadActivityRecord {
+ type: string;
+ seq?: number;
+ authorUid?: string;
+ authorName?: string;
+ body?: string;
+ details?: Record;
+ communication?: TradeLeadCommunication;
+ createdAt?: unknown;
+}
+
+export interface TradeLeadActivityView {
+ id: string;
+ type: string;
+ seq?: number;
+ authorUid?: string;
+ authorName?: string;
+ body?: string;
+ details?: Record;
+ communication?: TradeLeadCommunication;
+ createdAt?: string;
+}
+
+export function serializeTradeLead(
+ id: string,
+ data: Record
+): TradeLeadView {
+ const str = (key: string) => {
+ const v = data[key];
+ return typeof v === "string" ? v : "";
+ };
+ const optStr = (key: string) => {
+ const v = data[key];
+ return typeof v === "string" && v ? v : undefined;
+ };
+ return {
+ id,
+ businessName: str("businessName"),
+ contactName: str("contactName"),
+ email: str("email"),
+ phoneOrWhatsapp: str("phoneOrWhatsapp"),
+ venueType: str("venueType"),
+ message: str("message"),
+ status: normalizeTradeLeadStatus(data.status),
+ source: str("source") || "unknown",
+ assignedToUid: optStr("assignedToUid"),
+ assignedToName: optStr("assignedToName"),
+ nextFollowUpAt: toIsoString(data.nextFollowUpAt),
+ lastActivityAt: toIsoString(data.lastActivityAt),
+ closedAt: toIsoString(data.closedAt),
+ outcome: optStr("outcome"),
+ createdAt: toIsoString(data.createdAt),
+ updatedAt: toIsoString(data.updatedAt),
+ };
+}
+
+export function serializeTradeLeadActivity(
+ id: string,
+ data: Record
+): TradeLeadActivityView {
+ const optStr = (key: string) => {
+ const v = data[key];
+ return typeof v === "string" && v ? v : undefined;
+ };
+ const details = data.details;
+ const communication = data.communication;
+ return {
+ id,
+ type: optStr("type") ?? "note",
+ seq: typeof data.seq === "number" ? data.seq : undefined,
+ authorUid: optStr("authorUid"),
+ authorName: optStr("authorName"),
+ body: optStr("body"),
+ details:
+ details && typeof details === "object" && !Array.isArray(details)
+ ? (details as Record)
+ : undefined,
+ communication:
+ communication &&
+ typeof communication === "object" &&
+ !Array.isArray(communication)
+ ? (communication as TradeLeadCommunication)
+ : undefined,
+ createdAt: toIsoString(data.createdAt),
+ };
+}
+
+// --- Follow-up classification ---
+
+export type FollowUpState = "overdue" | "due_today" | "upcoming" | "none";
+
+function dayNumber(d: CalendarDate): number {
+ return Date.UTC(d.year, d.month - 1, d.day) / 86_400_000;
+}
+
+// Follow-ups are stored as instants (the admin's local midnight), but "due
+// today"/"overdue" are calendar-day statements. Comparing day numbers in the
+// viewer's local timezone — never `new Date("YYYY-MM-DD")` — keeps the
+// classification on the date the admin picked.
+function calendarDateOf(value: Date): CalendarDate {
+ return {
+ year: value.getFullYear(),
+ month: value.getMonth() + 1,
+ day: value.getDate(),
+ };
+}
+
+export function classifyFollowUp(
+ nextFollowUpAt: string | Date | null | undefined,
+ status: string,
+ today: CalendarDate = todayCalendarDate()
+): FollowUpState {
+ if (!nextFollowUpAt) return "none";
+ // Converted and closed leads are out of the follow-up queue by definition.
+ if (isTerminalLeadStatus(normalizeTradeLeadStatus(status))) return "none";
+ const date =
+ nextFollowUpAt instanceof Date ? nextFollowUpAt : new Date(nextFollowUpAt);
+ if (Number.isNaN(date.getTime())) return "none";
+ const diff = dayNumber(calendarDateOf(date)) - dayNumber(today);
+ if (diff < 0) return "overdue";
+ if (diff === 0) return "due_today";
+ return "upcoming";
+}
+
+// --- Admin mutations (pure planning layer) ---
+
+export class TradeLeadError extends Error {
+ public readonly clientSafe = true;
+ constructor(
+ message: string,
+ public status: number = 400
+ ) {
+ super(message);
+ }
+}
+
+export class TradeLeadNotFoundError extends TradeLeadError {
+ constructor() {
+ super("Trade lead not found.", 404);
+ }
+}
+
+// Sentinel marking a field for deletion in a lead-update plan. The
+// server-only layer translates it to FieldValue.delete(); this module must
+// not import firebase-admin.
+export const DELETE_FIELD: unique symbol = Symbol("tradeLeadDeleteField");
+
+export const TRADE_LEAD_NOTE_MAX_LENGTH = 2000;
+export const TRADE_LEAD_OUTCOME_MAX_LENGTH = 200;
+const FOLLOW_UP_MAX_YEARS_AHEAD = 10;
+
+// Untrusted PATCH body: every key optional; `null` clears a nullable field.
+// Presence of at least one recognized key is required.
+export interface ParsedLeadPatch {
+ status?: TradeLeadStatus;
+ assignedToUid?: string | null;
+ nextFollowUpAt?: Date | null;
+ outcome?: string | null;
+}
+
+export function parseLeadPatchBody(
+ body: unknown
+): { ok: true; patch: ParsedLeadPatch } | { ok: false; error: string } {
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
+ return { ok: false, error: "Invalid request body." };
+ }
+ const raw = body as Record;
+ const patch: ParsedLeadPatch = {};
+ let recognized = false;
+
+ if ("status" in raw) {
+ recognized = true;
+ if (!isTradeLeadStatus(raw.status)) {
+ return { ok: false, error: "Unknown lead status." };
+ }
+ patch.status = raw.status;
+ }
+
+ if ("assignedToUid" in raw) {
+ recognized = true;
+ const value = raw.assignedToUid;
+ if (value === null) {
+ patch.assignedToUid = null;
+ } else if (typeof value === "string" && value.trim()) {
+ patch.assignedToUid = value.trim();
+ } else {
+ return {
+ ok: false,
+ error: "Owner must be an administrator id or null.",
+ };
+ }
+ }
+
+ if ("nextFollowUpAt" in raw) {
+ recognized = true;
+ const value = raw.nextFollowUpAt;
+ if (value === null) {
+ patch.nextFollowUpAt = null;
+ } else if (typeof value === "string" || typeof value === "number") {
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ return { ok: false, error: "Follow-up must be a valid date." };
+ }
+ // Sanity bound: guards a typo'd year from hiding a lead forever.
+ const max = new Date();
+ max.setFullYear(max.getFullYear() + FOLLOW_UP_MAX_YEARS_AHEAD);
+ if (parsed.getTime() > max.getTime()) {
+ return {
+ ok: false,
+ error: `Follow-up cannot be more than ${FOLLOW_UP_MAX_YEARS_AHEAD} years out.`,
+ };
+ }
+ patch.nextFollowUpAt = parsed;
+ } else {
+ return { ok: false, error: "Follow-up must be a date or null." };
+ }
+ }
+
+ if ("outcome" in raw) {
+ recognized = true;
+ const value = raw.outcome;
+ if (value === null || (typeof value === "string" && !value.trim())) {
+ patch.outcome = null;
+ } else if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (trimmed.length > TRADE_LEAD_OUTCOME_MAX_LENGTH) {
+ return { ok: false, error: "Outcome is too long." };
+ }
+ patch.outcome = trimmed;
+ } else {
+ return { ok: false, error: "Outcome must be text or null." };
+ }
+ }
+
+ if (!recognized) {
+ return { ok: false, error: "No valid fields provided to update." };
+ }
+ return { ok: true, patch };
+}
+
+// Patch shape after the route has resolved `assignedToUid` to a verified
+// admin identity.
+export interface NormalizedLeadPatch {
+ status?: TradeLeadStatus;
+ assignee?: { uid: string; name: string } | null;
+ nextFollowUpAt?: Date | null;
+ outcome?: string | null;
+}
+
+export interface TradeLeadActivityDraft {
+ type: TradeLeadActivityType;
+ body?: string;
+ details?: Record;
+}
+
+export interface LeadUpdatePlan {
+ changed: boolean;
+ // Values may be a Date (→ Timestamp), DELETE_FIELD (→ FieldValue.delete()),
+ // or a primitive. The server layer performs the translation.
+ updates: Record;
+ activities: TradeLeadActivityDraft[];
+}
+
+function isoOf(millis: number | null): string | null {
+ return millis === null ? null : new Date(millis).toISOString();
+}
+
+// Computes the field updates + activity entries for a lead mutation. Pure:
+// the caller supplies the current record and `now`. Every produced change is
+// a "meaningful" mutation, so a non-empty plan always advances `updatedAt`
+// and `lastActivityAt` — the retention anchor (see TRADE_LEAD_RETENTION_*).
+// Reads/views never produce a plan, so opening a lead cannot extend it.
+export function buildLeadUpdate(
+ current: Pick<
+ TradeLeadRecord,
+ "status" | "assignedToUid" | "assignedToName" | "nextFollowUpAt" | "outcome"
+ >,
+ patch: NormalizedLeadPatch,
+ now: Date
+): { ok: true; plan: LeadUpdatePlan } | { ok: false; error: string } {
+ const updates: Record = {};
+ const activities: TradeLeadActivityDraft[] = [];
+
+ const fromStatus = normalizeTradeLeadStatus(current.status);
+ const toStatus = patch.status ?? fromStatus;
+ const toTerminal = isTerminalLeadStatus(toStatus);
+ const currentFollowUpMillis = timestampMillis(current.nextFollowUpAt);
+
+ if (patch.status !== undefined && patch.status !== fromStatus) {
+ const wasTerminal = isTerminalLeadStatus(fromStatus);
+ updates.status = toStatus;
+ activities.push({
+ type: "status_changed",
+ details: { from: fromStatus, to: toStatus },
+ });
+ if (toTerminal && !wasTerminal) {
+ updates.closedAt = now;
+ // A terminal lead leaves the follow-up queue; a scheduled follow-up on
+ // it would only ever render as a misleading overdue state.
+ if (currentFollowUpMillis !== null) {
+ updates.nextFollowUpAt = DELETE_FIELD;
+ activities.push({
+ type: "follow_up_cleared",
+ details: { at: isoOf(currentFollowUpMillis) },
+ });
+ }
+ }
+ if (!toTerminal && wasTerminal) {
+ // Reopening a lead clears its terminal bookkeeping.
+ updates.closedAt = DELETE_FIELD;
+ updates.outcome = DELETE_FIELD;
+ }
+ }
+
+ if (patch.outcome !== undefined) {
+ if (!toTerminal) {
+ return {
+ ok: false,
+ error: "Outcome only applies to customer or closed leads.",
+ };
+ }
+ const trimmed = patch.outcome?.trim() || null;
+ const currentOutcome =
+ typeof current.outcome === "string" && current.outcome.trim()
+ ? current.outcome.trim()
+ : null;
+ if (trimmed !== currentOutcome) {
+ updates.outcome = trimmed ?? DELETE_FIELD;
+ activities.push({
+ type: "outcome_changed",
+ details: { from: currentOutcome, to: trimmed },
+ });
+ }
+ }
+
+ if (patch.assignee !== undefined) {
+ const toUid = patch.assignee?.uid ?? null;
+ const fromUid = current.assignedToUid ?? null;
+ if (toUid !== fromUid) {
+ if (patch.assignee) {
+ updates.assignedToUid = patch.assignee.uid;
+ updates.assignedToName = patch.assignee.name;
+ } else {
+ updates.assignedToUid = DELETE_FIELD;
+ updates.assignedToName = DELETE_FIELD;
+ }
+ activities.push({
+ type: "owner_changed",
+ details: {
+ from: current.assignedToName ?? null,
+ to: patch.assignee?.name ?? null,
+ },
+ });
+ }
+ }
+
+ if (patch.nextFollowUpAt !== undefined && !toTerminal) {
+ const toMillis = patch.nextFollowUpAt?.getTime() ?? null;
+ if (toMillis !== currentFollowUpMillis) {
+ if (toMillis === null) {
+ updates.nextFollowUpAt = DELETE_FIELD;
+ activities.push({
+ type: "follow_up_cleared",
+ details: { at: isoOf(currentFollowUpMillis) },
+ });
+ } else {
+ updates.nextFollowUpAt = patch.nextFollowUpAt;
+ activities.push({
+ type:
+ currentFollowUpMillis === null
+ ? "follow_up_set"
+ : "follow_up_changed",
+ details: {
+ at: patch.nextFollowUpAt!.toISOString(),
+ from: isoOf(currentFollowUpMillis),
+ },
+ });
+ }
+ }
+ // A follow-up PATCH that arrives with (or after) a terminal status is a
+ // no-op by design: terminal leads cannot hold a follow-up.
+ }
+
+ if (Object.keys(updates).length === 0 && activities.length === 0) {
+ return { ok: true, plan: { changed: false, updates: {}, activities: [] } };
+ }
+
+ updates.updatedAt = now;
+ updates.lastActivityAt = now;
+ return { ok: true, plan: { changed: true, updates, activities } };
+}
+
+// --- Manual lead creation ---
+
+export interface ManualTradeLeadInput {
+ businessName: string;
+ contactName: string;
+ email: string;
+ phoneOrWhatsapp: string;
+ venueType: string;
+ message: string;
+ source: Exclude;
+}
+
+export function parseManualLeadBody(
+ body: unknown
+):
+ | { ok: true; input: ManualTradeLeadInput }
+ | { ok: false; error: string } {
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
+ return { ok: false, error: "Invalid request body." };
+ }
+ const raw = body as Record;
+ const str = (key: string) =>
+ typeof raw[key] === "string" ? (raw[key] as string).trim() : "";
+
+ const businessName = str("businessName");
+ const contactName = str("contactName");
+ const email = str("email");
+ const phoneOrWhatsapp = str("phoneOrWhatsapp");
+ const venueTypeRaw = str("venueType");
+ const message = str("message");
+ const source = raw.source;
+
+ if (!businessName) {
+ return { ok: false, error: "Business name is required." };
+ }
+ if (businessName.length > TRADE_LEAD_FIELD_LIMITS.businessName) {
+ return { ok: false, error: "Business name is too long." };
+ }
+ if (contactName.length > TRADE_LEAD_FIELD_LIMITS.contactName) {
+ return { ok: false, error: "Contact name is too long." };
+ }
+ // Manual leads may arrive with only a phone number (a WhatsApp chat, a
+ // business card) — require at least one usable contact channel.
+ if (!email && !phoneOrWhatsapp) {
+ return {
+ ok: false,
+ error: "Provide at least an email or a phone/WhatsApp number.",
+ };
+ }
+ if (email && !isValidEmail(email)) {
+ return { ok: false, error: "Enter a valid email address." };
+ }
+ if (email.length > TRADE_LEAD_FIELD_LIMITS.email) {
+ return { ok: false, error: "Email is too long." };
+ }
+ if (phoneOrWhatsapp.length > TRADE_LEAD_FIELD_LIMITS.phoneOrWhatsapp) {
+ return { ok: false, error: "Phone/WhatsApp is too long." };
+ }
+ const venueType = venueTypeRaw || "other";
+ if (!TRADE_VENUE_TYPES.some((t) => t.value === venueType)) {
+ return { ok: false, error: "Choose a valid business type." };
+ }
+ if (message.length > TRADE_LEAD_FIELD_LIMITS.message) {
+ return { ok: false, error: "Message is too long." };
+ }
+ if (!isManualLeadSource(source)) {
+ return { ok: false, error: "Choose how this lead reached you." };
+ }
+
+ return {
+ ok: true,
+ input: {
+ businessName,
+ contactName,
+ email,
+ phoneOrWhatsapp,
+ venueType,
+ message,
+ source,
+ },
+ };
+}
+
+export function validateNoteBody(
+ body: unknown
+): { ok: true; note: string } | { ok: false; error: string } {
+ const note = typeof body === "string" ? body.trim() : "";
+ if (!note) return { ok: false, error: "Write a note before saving." };
+ if (note.length > TRADE_LEAD_NOTE_MAX_LENGTH) {
+ return { ok: false, error: "Note is too long." };
+ }
+ return { ok: true, note };
+}
+
+// --- Timeline presentation ---
+
+function formatDetailsDate(value: unknown): string | null {
+ if (typeof value !== "string" || !value) return null;
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date.toLocaleDateString();
+}
+
+// One readable line per history entry — the "Oct 4, Malachy: Dropped off
+// samples" shape from the issue. Note bodies render separately (verbatim,
+// multi-line).
+export function describeTradeLeadActivity(
+ activity: Pick
+): string {
+ const details = activity.details ?? {};
+ const detailStr = (key: string) =>
+ typeof details[key] === "string" ? (details[key] as string) : null;
+ switch (activity.type) {
+ case "lead_created":
+ return `Lead created — ${tradeLeadSourceLabel(detailStr("source"))}`;
+ case "status_changed":
+ return `Status changed from ${tradeLeadStatusLabel(detailStr("from"))} to ${tradeLeadStatusLabel(detailStr("to"))}`;
+ case "owner_changed": {
+ const to = detailStr("to");
+ return to ? `Assigned to ${to}` : "Owner unassigned";
+ }
+ case "follow_up_set": {
+ const at = formatDetailsDate(detailStr("at"));
+ return at ? `Follow-up set for ${at}` : "Follow-up set";
+ }
+ case "follow_up_changed": {
+ const at = formatDetailsDate(detailStr("at"));
+ return at ? `Follow-up moved to ${at}` : "Follow-up changed";
+ }
+ case "follow_up_cleared":
+ return "Follow-up cleared";
+ case "outcome_changed": {
+ const to = detailStr("to");
+ return to ? `Outcome updated: ${to}` : "Outcome cleared";
+ }
+ case "communication": {
+ const channel = activity.communication?.channel;
+ const direction = activity.communication?.direction;
+ const verb = direction === "inbound" ? "received" : "sent";
+ return channel ? `Message ${verb} via ${channel}` : `Message ${verb}`;
+ }
+ case "note":
+ default:
+ return "Note";
+ }
+}
diff --git a/lib/trade-leads-admin.ts b/lib/trade-leads-admin.ts
new file mode 100644
index 0000000..9b2ade6
--- /dev/null
+++ b/lib/trade-leads-admin.ts
@@ -0,0 +1,265 @@
+import "server-only";
+import { FieldValue, Timestamp } from "firebase-admin/firestore";
+import { getFirebaseAdminDb } from "@/lib/firebase-admin-db";
+import { getAdminUser, listAdminUsers } from "@/lib/admin-users";
+import { TRADE_LEADS_COLLECTION } from "@/lib/trade-leads-common";
+import {
+ buildLeadUpdate,
+ DELETE_FIELD,
+ TRADE_LEAD_ACTIVITIES_SUBCOLLECTION,
+ TradeLeadError,
+ TradeLeadNotFoundError,
+ type ManualTradeLeadInput,
+ type NormalizedLeadPatch,
+ type TradeLeadAssignee,
+} from "@/lib/trade-leads-admin-common";
+
+// Identity the activity timeline records. `name` is a stable display string
+// (displayName, falling back to email) snapshotted at write time so history
+// stays readable even if an admin record later changes.
+export interface TradeLeadActor {
+ uid: string;
+ name: string;
+}
+
+// Builds the timeline identity for an authenticated AdminActor: prefer the
+// adminUsers record's displayName, then the token's name/email.
+export function tradeLeadActorOf(actor: {
+ token: { uid: string; name?: string; email?: string };
+ record: { displayName?: string; email: string };
+}): TradeLeadActor {
+ return {
+ uid: actor.token.uid,
+ name:
+ actor.record.displayName?.trim() ||
+ actor.token.name?.trim() ||
+ actor.token.email?.trim() ||
+ actor.record.email,
+ };
+}
+
+// Admin SDK reads — never imported by client components. `tradeLeads` is
+// deny-all in firestore.rules; these functions are the only read/write
+// surface and are always called behind requireAdminActor.
+
+export function getTradeLeadsCollection() {
+ return getFirebaseAdminDb().collection(TRADE_LEADS_COLLECTION);
+}
+
+export async function listTradeLeads(): Promise<
+ { id: string; data: Record }[]
+> {
+ // Single-field ordering only — no composite index needed; filters and
+ // secondary sorts run client-side over the returned list (lead volume is
+ // modest for a single brewery).
+ const snapshot = await getTradeLeadsCollection()
+ .orderBy("createdAt", "desc")
+ .get();
+ return snapshot.docs.map((doc) => ({ id: doc.id, data: doc.data() }));
+}
+
+export async function getTradeLead(
+ id: string
+): Promise<{ id: string; data: Record } | null> {
+ const doc = await getTradeLeadsCollection().doc(id).get();
+ if (!doc.exists) return null;
+ return { id: doc.id, data: doc.data() ?? {} };
+}
+
+export async function listTradeLeadActivities(
+ leadId: string
+): Promise<{ id: string; data: Record }[]> {
+ // `seq` is a per-lead monotonically increasing counter assigned inside the
+ // same transaction as the write, so ordering is deterministic even when
+ // several entries share a server timestamp.
+ const snapshot = await getTradeLeadsCollection()
+ .doc(leadId)
+ .collection(TRADE_LEAD_ACTIVITIES_SUBCOLLECTION)
+ .orderBy("seq", "asc")
+ .get();
+ return snapshot.docs.map((doc) => ({ id: doc.id, data: doc.data() }));
+}
+
+// Leads may only be assigned to active administrators — the same identity
+// pool as the rest of the admin app, no separate CRM user table.
+export async function listAssignableAdmins(): Promise {
+ const users = await listAdminUsers();
+ return users
+ .filter((user) => user.status === "active")
+ .map((user) => ({
+ uid: user.uid,
+ name: user.displayName?.trim() || user.email,
+ }));
+}
+
+// Resolves a client-supplied uid to a verified active admin. Throws a
+// client-safe error when the uid is not an active administrator.
+export async function resolveAssignee(uid: string): Promise {
+ const record = await getAdminUser(uid);
+ if (!record || record.status !== "active") {
+ throw new TradeLeadError(
+ "Selected owner is not an active administrator.",
+ 400
+ );
+ }
+ return {
+ uid: record.uid,
+ name: record.displayName?.trim() || record.email,
+ };
+}
+
+// Translates the pure update plan into Firestore terms: Date → Timestamp,
+// DELETE_FIELD → FieldValue.delete().
+function translateUpdates(
+ updates: Record
+): Record {
+ const translated: Record = {};
+ for (const [key, value] of Object.entries(updates)) {
+ if (value === DELETE_FIELD) {
+ translated[key] = FieldValue.delete();
+ } else if (value instanceof Date) {
+ translated[key] = Timestamp.fromDate(value);
+ } else {
+ translated[key] = value;
+ }
+ }
+ return translated;
+}
+
+function activityDoc(
+ draft: { type: string; body?: string; details?: Record },
+ seq: number,
+ actor: TradeLeadActor | null
+) {
+ return {
+ type: draft.type,
+ seq,
+ ...(draft.body ? { body: draft.body } : {}),
+ ...(draft.details ? { details: draft.details } : {}),
+ // Web-submitted creation entries have no admin author.
+ ...(actor ? { authorUid: actor.uid, authorName: actor.name } : {}),
+ createdAt: FieldValue.serverTimestamp(),
+ };
+}
+
+// Records a lead an admin took down by hand (WhatsApp, phone, walk-in, …).
+// Manual leads enter the same pipeline as web submissions: status "new",
+// with a `lead_created` activity so the timeline is never empty.
+export async function createManualTradeLead(
+ input: ManualTradeLeadInput,
+ actor: TradeLeadActor
+): Promise {
+ const db = getFirebaseAdminDb();
+ const leadRef = db.collection(TRADE_LEADS_COLLECTION).doc();
+ const activityRef = leadRef
+ .collection(TRADE_LEAD_ACTIVITIES_SUBCOLLECTION)
+ .doc();
+
+ const batch = db.batch();
+ batch.set(leadRef, {
+ businessName: input.businessName,
+ contactName: input.contactName,
+ email: input.email,
+ phoneOrWhatsapp: input.phoneOrWhatsapp,
+ venueType: input.venueType,
+ message: input.message,
+ status: "new",
+ source: input.source,
+ activityCount: 1,
+ createdAt: FieldValue.serverTimestamp(),
+ updatedAt: FieldValue.serverTimestamp(),
+ lastActivityAt: FieldValue.serverTimestamp(),
+ });
+ batch.set(
+ activityRef,
+ activityDoc(
+ { type: "lead_created", details: { source: input.source } },
+ 0,
+ actor
+ )
+ );
+ await batch.commit();
+ return leadRef.id;
+}
+
+export interface LeadPatchResult {
+ changed: boolean;
+ activityTypes: string[];
+}
+
+// Applies a validated patch inside a transaction: the update plan is built
+// from the fresh record so concurrent edits can't silently drop an
+// intermediate change, and every produced activity gets a `seq` continuing
+// the lead's `activityCount`.
+export async function applyLeadPatch(
+ leadId: string,
+ patch: NormalizedLeadPatch,
+ actor: TradeLeadActor
+): Promise {
+ const ref = getTradeLeadsCollection().doc(leadId);
+ const now = new Date();
+
+ return getFirebaseAdminDb().runTransaction(async (tx) => {
+ const snap = await tx.get(ref);
+ if (!snap.exists) throw new TradeLeadNotFoundError();
+
+ const outcome = buildLeadUpdate(
+ (snap.data() ?? {}) as Parameters[0],
+ patch,
+ now
+ );
+ if (!outcome.ok) throw new TradeLeadError(outcome.error, 400);
+ const { plan } = outcome;
+ if (!plan.changed) return { changed: false, activityTypes: [] };
+
+ const base =
+ typeof snap.data()?.activityCount === "number"
+ ? (snap.data()!.activityCount as number)
+ : 0;
+ const updates = translateUpdates(plan.updates);
+ updates.activityCount = base + plan.activities.length;
+ tx.update(ref, updates);
+
+ plan.activities.forEach((draft, index) => {
+ tx.set(
+ ref.collection(TRADE_LEAD_ACTIVITIES_SUBCOLLECTION).doc(),
+ activityDoc(draft, base + index, actor)
+ );
+ });
+
+ return {
+ changed: true,
+ activityTypes: plan.activities.map((a) => a.type),
+ };
+ });
+}
+
+// Appends an internal note and marks it as meaningful activity (the lead's
+// `updatedAt`/`lastActivityAt` advance — that is what extends the retention
+// window). Notes are never edited or overwritten.
+export async function addTradeLeadNote(
+ leadId: string,
+ note: string,
+ actor: TradeLeadActor
+): Promise {
+ const ref = getTradeLeadsCollection().doc(leadId);
+
+ await getFirebaseAdminDb().runTransaction(async (tx) => {
+ const snap = await tx.get(ref);
+ if (!snap.exists) throw new TradeLeadNotFoundError();
+ const base =
+ typeof snap.data()?.activityCount === "number"
+ ? (snap.data()!.activityCount as number)
+ : 0;
+
+ tx.set(
+ ref.collection(TRADE_LEAD_ACTIVITIES_SUBCOLLECTION).doc(),
+ activityDoc({ type: "note", body: note }, base, actor)
+ );
+ tx.update(ref, {
+ activityCount: base + 1,
+ updatedAt: FieldValue.serverTimestamp(),
+ lastActivityAt: FieldValue.serverTimestamp(),
+ });
+ });
+}
diff --git a/lib/trade-leads-common.ts b/lib/trade-leads-common.ts
index 583bc3c..a804f5b 100644
--- a/lib/trade-leads-common.ts
+++ b/lib/trade-leads-common.ts
@@ -99,7 +99,7 @@ export function tradeLeadRetentionStatus(
}
// Accepts Date, firebase-admin Timestamp (toMillis), or a { seconds } shape.
-function timestampMillis(value: unknown): number | null {
+export function timestampMillis(value: unknown): number | null {
if (value == null) return null;
if (value instanceof Date) {
const t = value.getTime();
diff --git a/lib/trade-leads.ts b/lib/trade-leads.ts
index fd41855..105a39f 100644
--- a/lib/trade-leads.ts
+++ b/lib/trade-leads.ts
@@ -8,21 +8,39 @@ import {
buildTradeLeadRecord,
processTradeInquiry,
TRADE_LEADS_COLLECTION,
+ TRADE_LEAD_SOURCE,
type TradeInquiryOutcome,
type TradeLeadInput,
} from "@/lib/trade-leads-common";
+import { TRADE_LEAD_ACTIVITIES_SUBCOLLECTION } from "@/lib/trade-leads-admin-common";
export type { TradeLeadInput };
export async function persistTradeLead(input: TradeLeadInput): Promise {
- const ref = await getFirebaseAdminDb()
- .collection(TRADE_LEADS_COLLECTION)
- .add({
- ...buildTradeLeadRecord(input),
- createdAt: FieldValue.serverTimestamp(),
- updatedAt: FieldValue.serverTimestamp(),
- });
- return ref.id;
+ const db = getFirebaseAdminDb();
+ const leadRef = db.collection(TRADE_LEADS_COLLECTION).doc();
+ const activityRef = leadRef
+ .collection(TRADE_LEAD_ACTIVITIES_SUBCOLLECTION)
+ .doc();
+
+ // Lead document + its first history entry in one batch so every new lead
+ // opens with a populated timeline (#150).
+ const batch = db.batch();
+ batch.set(leadRef, {
+ ...buildTradeLeadRecord(input),
+ activityCount: 1,
+ createdAt: FieldValue.serverTimestamp(),
+ updatedAt: FieldValue.serverTimestamp(),
+ lastActivityAt: FieldValue.serverTimestamp(),
+ });
+ batch.set(activityRef, {
+ type: "lead_created",
+ seq: 0,
+ details: { source: TRADE_LEAD_SOURCE },
+ createdAt: FieldValue.serverTimestamp(),
+ });
+ await batch.commit();
+ return leadRef.id;
}
function escapeHtml(input: string): string {
diff --git a/rules-tests/firestore.rules.test.ts b/rules-tests/firestore.rules.test.ts
index 6c68f54..3b349c1 100644
--- a/rules-tests/firestore.rules.test.ts
+++ b/rules-tests/firestore.rules.test.ts
@@ -336,4 +336,40 @@ describe("tradeLeads denies all client access", () => {
deleteDoc(doc(db, "tradeLeads", "lead1"))
);
});
+
+ // Issue #150: lead history lives in a `activities` subcollection that is
+ // part of the same PII boundary — server-only via the Admin SDK.
+ it("denies all client access to the lead activities subcollection", async () => {
+ await testEnv.withSecurityRulesDisabled(async (ctx) => {
+ await setDoc(doc(ctx.firestore(), "tradeLeads", "lead1"), validTradeLead);
+ await setDoc(
+ doc(ctx.firestore(), "tradeLeads", "lead1", "activities", "a1"),
+ { type: "note", seq: 0 }
+ );
+ });
+
+ const anonDb = testEnv.unauthenticatedContext().firestore();
+ await assertFails(
+ getDoc(doc(anonDb, "tradeLeads", "lead1", "activities", "a1"))
+ );
+ await assertFails(
+ getDocs(collection(anonDb, "tradeLeads", "lead1", "activities"))
+ );
+ await assertFails(
+ addDoc(collection(anonDb, "tradeLeads", "lead1", "activities"), {
+ type: "note",
+ })
+ );
+
+ await seedAdminRecord("admin1", "admin", "active");
+ const db = adminContext("admin1", "admin").firestore();
+ await assertFails(
+ getDoc(doc(db, "tradeLeads", "lead1", "activities", "a1"))
+ );
+ await assertFails(
+ addDoc(collection(db, "tradeLeads", "lead1", "activities"), {
+ type: "note",
+ })
+ );
+ });
});
diff --git a/scripts/prune-trade-leads.ts b/scripts/prune-trade-leads.ts
index eac81bd..1308684 100644
--- a/scripts/prune-trade-leads.ts
+++ b/scripts/prune-trade-leads.ts
@@ -17,16 +17,43 @@
*/
import { cert, getApps, initializeApp, type App } from "firebase-admin/app";
-import { getFirestore } from "firebase-admin/firestore";
+import {
+ getFirestore,
+ type DocumentReference,
+} from "firebase-admin/firestore";
import {
tradeLeadRetentionCutoff,
tradeLeadRetentionStatus,
TRADE_LEADS_COLLECTION,
TRADE_LEAD_RETENTION_MONTHS,
} from "@/lib/trade-leads-common";
+import { TRADE_LEAD_ACTIVITIES_SUBCOLLECTION } from "@/lib/trade-leads-admin-common";
const DELETE = process.argv.includes("--delete");
+// Comfortably below Firestore's 500-write batch limit.
+const ACTIVITY_DELETE_BATCH = 450;
+
+// Deletes every document in a lead's `activities` subcollection in bounded
+// write batches. Deleting the parent does not cascade, so this runs after
+// the lead's own transaction commits.
+async function deleteLeadActivities(
+ leadRef: DocumentReference
+): Promise {
+ const activities = leadRef.collection(TRADE_LEAD_ACTIVITIES_SUBCOLLECTION);
+ let removed = 0;
+ for (;;) {
+ const page = await activities.limit(ACTIVITY_DELETE_BATCH).get();
+ if (page.empty) return removed;
+ const batch = leadRef.firestore.batch();
+ for (const doc of page.docs) {
+ batch.delete(doc.ref);
+ }
+ await batch.commit();
+ removed += page.size;
+ }
+}
+
function getPrivateKey() {
const key = process.env.FIREBASE_ADMIN_PRIVATE_KEY;
if (!key) return "";
@@ -98,7 +125,13 @@ async function main() {
}
// Re-verify each document inside a transaction before deleting so a lead
- // updated between scan and delete (new activity extends retention) is kept.
+ // updated between scan and delete (new activity extends retention) is
+ // kept. Deleting a document does not cascade to subcollections — the
+ // activities history is removed afterwards in bounded batches. Doing it
+ // after commit (rather than inside the transaction) keeps the transaction
+ // under Firestore's per-transaction write limit no matter how long a
+ // lead's history is, and can never wipe a live lead's timeline: if the
+ // recheck fails, the lead — and its history — is left untouched.
const collection = db.collection(TRADE_LEADS_COLLECTION);
let deleted = 0;
for (const id of expired) {
@@ -114,11 +147,26 @@ async function main() {
tx.delete(ref);
return true;
});
- if (removed) {
- deleted++;
- console.log(` deleted ${id}`);
- } else {
+ if (!removed) {
console.log(` kept ${id} (changed since scan)`);
+ continue;
+ }
+
+ deleted++;
+ let activitiesRemoved = 0;
+ try {
+ activitiesRemoved = await deleteLeadActivities(ref);
+ console.log(` deleted ${id} (+${activitiesRemoved} activity records)`);
+ } catch (err) {
+ // The lead document is already gone; leftover subcollection docs are
+ // unreachable by the app and denied to clients — report for a manual
+ // sweep rather than failing the whole run.
+ console.warn(
+ ` deleted ${id} (+${activitiesRemoved} activity records) — ` +
+ "remaining history cleanup failed; sweep the activities " +
+ `subcollection for ${id} manually.`,
+ err
+ );
}
}
console.log(`Deleted ${deleted} lead(s).`);
diff --git a/smoke-tests/admin-accessibility.spec.ts b/smoke-tests/admin-accessibility.spec.ts
index 417d18e..f24955f 100644
--- a/smoke-tests/admin-accessibility.spec.ts
+++ b/smoke-tests/admin-accessibility.spec.ts
@@ -294,3 +294,127 @@ test("admin list loading state is announced", async ({ page }) => {
await expect(page.getByRole("status")).toContainText("Loading...");
await expect(page.getByText("boss@example.com")).toBeVisible();
});
+
+// --- Trade-lead pipeline fixture (Issue #150) ---
+
+const TRADE_FIXTURE = "/admin-trade-fixture";
+
+const TRADE_LEAD = {
+ id: "lead-1",
+ businessName: "Fixture Harbour Bar",
+ contactName: "Sam Keeper",
+ email: "sam@harbour.example",
+ phoneOrWhatsapp: "+599 555 1234",
+ venueType: "bar",
+ message: "Interested in carrying the Pilsner.",
+ status: "contacted",
+ source: "trade_form",
+ assignedToName: "Chad",
+ assignedToUid: "u-1",
+ nextFollowUpAt: "2026-10-18T04:00:00.000Z",
+ createdAt: "2026-10-01T12:00:00.000Z",
+ updatedAt: "2026-10-02T12:00:00.000Z",
+ lastActivityAt: "2026-10-02T12:00:00.000Z",
+};
+
+const TRADE_ACTIVITIES = [
+ {
+ id: "a-0",
+ type: "lead_created",
+ seq: 0,
+ details: { source: "trade_form" },
+ createdAt: "2026-10-01T12:00:00.000Z",
+ },
+ {
+ id: "a-1",
+ type: "note",
+ seq: 1,
+ authorUid: "u-1",
+ authorName: "Chad",
+ body: "Spoke on WhatsApp. Interested in the Pilsner.",
+ createdAt: "2026-10-02T12:00:00.000Z",
+ },
+];
+
+function mockTradeApi(page: import("playwright/test").Page) {
+ return page.route(/\/api\/admin\/trade-leads/, (route) => {
+ const url = route.request().url();
+ const method = route.request().method();
+ const json = (body: unknown, status = 200) =>
+ route.fulfill({
+ status,
+ contentType: "application/json",
+ body: JSON.stringify(body),
+ });
+
+ if (method === "GET" && url.endsWith("/api/admin/trade-leads")) {
+ return json({
+ ok: true,
+ leads: [TRADE_LEAD],
+ admins: [{ uid: "u-1", name: "Chad" }],
+ });
+ }
+ if (method === "GET" && url.includes("/api/admin/trade-leads/")) {
+ return json({ ok: true, lead: TRADE_LEAD, activities: TRADE_ACTIVITIES });
+ }
+ if (method === "PATCH" || method === "POST") {
+ return json({ ok: true, lead: TRADE_LEAD, activities: TRADE_ACTIVITIES });
+ }
+ return json({ ok: false, error: "Unexpected fixture request" });
+ });
+}
+
+test("axe: trade-lead pipeline workspace has no serious/critical violations", async ({
+ page,
+}) => {
+ await mockTradeApi(page);
+ await page.goto(TRADE_FIXTURE);
+ await waitForAnimations(page);
+ await expect(page.getByText("Fixture Harbour Bar")).toBeVisible();
+
+ // List view.
+ let blocking = await scanAxe(page, `${TRADE_FIXTURE} (list)`);
+ expect(blocking, formatBlocking(`${TRADE_FIXTURE} (list)`, blocking)).toEqual(
+ []
+ );
+
+ // Detail workspace with the populated history timeline.
+ await page.getByRole("button", { name: /Fixture Harbour Bar/ }).press("Enter");
+ await expect(page.getByText("Spoke on WhatsApp")).toBeVisible();
+ blocking = await scanAxe(page, `${TRADE_FIXTURE} (detail)`);
+ expect(
+ blocking,
+ formatBlocking(`${TRADE_FIXTURE} (detail)`, blocking)
+ ).toEqual([]);
+});
+
+test("trade-lead list announces loading and rows expose selected state", async ({
+ page,
+}) => {
+ await mockTradeApi(page);
+ await page.goto(TRADE_FIXTURE);
+ const row = page.getByRole("button", { name: /Fixture Harbour Bar/ });
+ await expect(row).toBeVisible();
+ expect(await row.evaluate((el) => el.closest("ul > li") !== null)).toBe(true);
+ await row.press("Enter");
+ await expect(row).toHaveAttribute("aria-current", "true");
+ await expect(
+ page.getByRole("heading", { name: "Fixture Harbour Bar" })
+ ).toBeVisible();
+});
+
+test("trade-lead note entry is labeled and announces its result", async ({
+ page,
+}) => {
+ await mockTradeApi(page);
+ await page.goto(TRADE_FIXTURE);
+ await page.getByRole("button", { name: /Fixture Harbour Bar/ }).press("Enter");
+ await expect(page.getByText("Spoke on WhatsApp")).toBeVisible();
+
+ const noteBox = page.getByRole("textbox", { name: "Add a note" });
+ await noteBox.fill("Dropped off samples.");
+ await page.getByRole("button", { name: "Add note" }).press("Enter");
+ await expect(
+ page.getByRole("status").filter({ hasText: "Note added." })
+ ).toBeVisible();
+});
diff --git a/smoke-tests/fixtures.ts b/smoke-tests/fixtures.ts
index 92b81d8..dee5933 100644
--- a/smoke-tests/fixtures.ts
+++ b/smoke-tests/fixtures.ts
@@ -69,6 +69,18 @@ export const test = base.extend({
return route.continue();
});
+ // The admin workspace renders a trade-lead summary that fetches this
+ // endpoint on every /admin-fixture mount; default to an empty pipeline so
+ // specs that don't exercise the pipeline stay hermetic. Specs register
+ // their own route afterwards — Playwright matches last-registered first.
+ await page.route(/\/api\/admin\/trade-leads/, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ ok: true, leads: [], admins: [] }),
+ })
+ );
+
page.on("pageerror", (err) => problems.push(`pageerror: ${err.message}`));
page.on("console", (msg) => {
if (
diff --git a/smoke-tests/seo.spec.ts b/smoke-tests/seo.spec.ts
index 44bbbd0..bdbafe6 100644
--- a/smoke-tests/seo.spec.ts
+++ b/smoke-tests/seo.spec.ts
@@ -83,7 +83,9 @@ test("private and placeholder routes are marked noindex", async ({ page }) => {
// must never be indexable.
for (const route of [
"/admin",
+ "/admin/trade",
"/admin-fixture",
+ "/admin-trade-fixture",
"/where-to-buy-fixture",
"/trade/login",
"/trade/order",
@@ -114,6 +116,7 @@ test("robots.txt disallows private surfaces and advertises the canonical sitemap
for (const path of [
"/admin",
"/admin-fixture",
+ "/admin-trade-fixture",
"/where-to-buy-fixture",
"/trade/login",
"/api/",
@@ -134,6 +137,7 @@ test("sitemap.xml covers public routes only on the canonical host", async ({
for (const forbidden of [
"/admin",
"/admin-fixture",
+ "/admin-trade-fixture",
"/where-to-buy-fixture",
"/trade/login",
"/trade/order",
diff --git a/tests/lib/trade-leads-admin.test.ts b/tests/lib/trade-leads-admin.test.ts
new file mode 100644
index 0000000..599d0fc
--- /dev/null
+++ b/tests/lib/trade-leads-admin.test.ts
@@ -0,0 +1,513 @@
+import { describe, it } from "node:test";
+import assert from "node:assert";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import {
+ buildLeadUpdate,
+ classifyFollowUp,
+ DELETE_FIELD,
+ describeTradeLeadActivity,
+ isManualLeadSource,
+ isTerminalLeadStatus,
+ isTradeLeadStatus,
+ normalizeTradeLeadStatus,
+ parseLeadPatchBody,
+ parseManualLeadBody,
+ serializeTradeLead,
+ serializeTradeLeadActivity,
+ tradeLeadSourceLabel,
+ tradeLeadStatusLabel,
+ TRADE_LEAD_NOTE_MAX_LENGTH,
+ validateNoteBody,
+ type TradeLeadActivityView,
+} from "@/lib/trade-leads-admin-common";
+
+const NOW = new Date("2026-10-02T15:00:00.000Z");
+const TODAY = { year: 2026, month: 10, day: 2 };
+
+// Firestore-shaped timestamp stand-in (admin Timestamp has toMillis).
+const ts = (iso: string) => ({
+ toMillis: () => new Date(iso).getTime(),
+ toDate: () => new Date(iso),
+});
+
+describe("status model", () => {
+ it("keeps the pre-pipeline 'new' status valid for existing leads", () => {
+ assert.strictEqual(isTradeLeadStatus("new"), true);
+ assert.strictEqual(normalizeTradeLeadStatus("new"), "new");
+ });
+
+ it("normalizes missing/unknown statuses to 'new' for reads and filters", () => {
+ for (const value of [undefined, null, "", "garbage", 42]) {
+ assert.strictEqual(normalizeTradeLeadStatus(value), "new");
+ }
+ });
+
+ it("treats customer and closed as terminal", () => {
+ assert.strictEqual(isTerminalLeadStatus("customer"), true);
+ assert.strictEqual(isTerminalLeadStatus("closed"), true);
+ assert.strictEqual(isTerminalLeadStatus("follow_up"), false);
+ assert.strictEqual(isTerminalLeadStatus("new"), false);
+ });
+
+ it("labels statuses for display", () => {
+ assert.strictEqual(tradeLeadStatusLabel("follow_up"), "Follow-up");
+ assert.strictEqual(tradeLeadStatusLabel("bogus"), "New");
+ });
+});
+
+describe("source model", () => {
+ it("labels known sources and preserves unknown strings", () => {
+ assert.strictEqual(tradeLeadSourceLabel("trade_form"), "Website form");
+ assert.strictEqual(tradeLeadSourceLabel("whatsapp"), "WhatsApp");
+ assert.strictEqual(tradeLeadSourceLabel("carrier_pigeon"), "carrier_pigeon");
+ assert.strictEqual(tradeLeadSourceLabel(""), "Unknown");
+ });
+
+ it("keeps trade_form out of the manual-source set", () => {
+ assert.strictEqual(isManualLeadSource("trade_form"), false);
+ assert.strictEqual(isManualLeadSource("whatsapp"), true);
+ assert.strictEqual(isManualLeadSource("referral"), true);
+ assert.strictEqual(isManualLeadSource("bogus"), false);
+ });
+});
+
+describe("classifyFollowUp", () => {
+ const followUpIso = (year: number, month: number, day: number) =>
+ // Local-midnight instant, matching what the admin UI writes.
+ new Date(year, month - 1, day).toISOString();
+
+ it("classifies overdue, due today, and upcoming by calendar day", () => {
+ assert.strictEqual(
+ classifyFollowUp(followUpIso(2026, 10, 1), "contacted", TODAY),
+ "overdue"
+ );
+ assert.strictEqual(
+ classifyFollowUp(followUpIso(2026, 10, 2), "contacted", TODAY),
+ "due_today"
+ );
+ assert.strictEqual(
+ classifyFollowUp(followUpIso(2026, 10, 3), "contacted", TODAY),
+ "upcoming"
+ );
+ });
+
+ it("returns none when no follow-up is set or the value is unparseable", () => {
+ assert.strictEqual(classifyFollowUp(undefined, "new", TODAY), "none");
+ assert.strictEqual(classifyFollowUp(null, "new", TODAY), "none");
+ assert.strictEqual(classifyFollowUp("not a date", "new", TODAY), "none");
+ });
+
+ it("never reports overdue on customer/closed leads", () => {
+ const past = followUpIso(2026, 9, 1);
+ assert.strictEqual(classifyFollowUp(past, "customer", TODAY), "none");
+ assert.strictEqual(classifyFollowUp(past, "closed", TODAY), "none");
+ });
+});
+
+describe("parseLeadPatchBody", () => {
+ it("accepts a status patch", () => {
+ const result = parseLeadPatchBody({ status: "contacted" });
+ assert.ok(result.ok);
+ assert.strictEqual(result.patch.status, "contacted");
+ });
+
+ it("rejects unknown status values", () => {
+ assert.deepStrictEqual(parseLeadPatchBody({ status: "nope" }), {
+ ok: false,
+ error: "Unknown lead status.",
+ });
+ });
+
+ it("accepts uid assignment and null unassignment", () => {
+ const assign = parseLeadPatchBody({ assignedToUid: " uid-1 " });
+ assert.ok(assign.ok);
+ assert.strictEqual(assign.patch.assignedToUid, "uid-1");
+ const clear = parseLeadPatchBody({ assignedToUid: null });
+ assert.ok(clear.ok);
+ assert.strictEqual(clear.patch.assignedToUid, null);
+ });
+
+ it("rejects malformed assignee values", () => {
+ assert.strictEqual(parseLeadPatchBody({ assignedToUid: "" }).ok, false);
+ assert.strictEqual(parseLeadPatchBody({ assignedToUid: 42 }).ok, false);
+ });
+
+ it("accepts a follow-up instant, null clear, and rejects garbage", () => {
+ const set = parseLeadPatchBody({ nextFollowUpAt: "2026-10-18T04:00:00.000Z" });
+ assert.ok(set.ok);
+ assert.strictEqual(set.patch.nextFollowUpAt?.toISOString(), "2026-10-18T04:00:00.000Z");
+ const clear = parseLeadPatchBody({ nextFollowUpAt: null });
+ assert.ok(clear.ok);
+ assert.strictEqual(clear.patch.nextFollowUpAt, null);
+ assert.strictEqual(parseLeadPatchBody({ nextFollowUpAt: "junk" }).ok, false);
+ assert.strictEqual(parseLeadPatchBody({ nextFollowUpAt: {} }).ok, false);
+ });
+
+ it("rejects a follow-up implausibly far in the future", () => {
+ const result = parseLeadPatchBody({ nextFollowUpAt: "2999-01-01T00:00:00.000Z" });
+ assert.strictEqual(result.ok, false);
+ });
+
+ it("trims outcome text, clears on empty/null, and enforces the cap", () => {
+ const set = parseLeadPatchBody({ outcome: " Became a customer " });
+ assert.ok(set.ok);
+ assert.strictEqual(set.patch.outcome, "Became a customer");
+ const clear = parseLeadPatchBody({ outcome: null });
+ assert.ok(clear.ok);
+ assert.strictEqual(clear.patch.outcome, null);
+ assert.strictEqual(
+ parseLeadPatchBody({ outcome: "x".repeat(201) }).ok,
+ false
+ );
+ });
+
+ it("requires at least one recognized field", () => {
+ assert.strictEqual(parseLeadPatchBody({}).ok, false);
+ assert.strictEqual(parseLeadPatchBody({ unrelated: true }).ok, false);
+ assert.strictEqual(parseLeadPatchBody("string").ok, false);
+ assert.strictEqual(parseLeadPatchBody([1, 2]).ok, false);
+ });
+});
+
+describe("buildLeadUpdate", () => {
+ const lead = {
+ status: "new",
+ assignedToUid: "admin-1",
+ assignedToName: "Chad",
+ nextFollowUpAt: ts("2026-10-05T04:00:00.000Z"),
+ };
+
+ it("records a status change with from/to in the timeline", () => {
+ const result = buildLeadUpdate(lead, { status: "contacted" }, NOW);
+ assert.ok(result.ok);
+ assert.strictEqual(result.plan.updates.status, "contacted");
+ assert.deepStrictEqual(result.plan.activities, [
+ {
+ type: "status_changed",
+ details: { from: "new", to: "contacted" },
+ },
+ ]);
+ });
+
+ it("is a no-op when the patch repeats current values", () => {
+ const result = buildLeadUpdate(lead, { status: "new" }, NOW);
+ assert.ok(result.ok);
+ assert.strictEqual(result.plan.changed, false);
+ assert.deepStrictEqual(result.plan.updates, {});
+ assert.deepStrictEqual(result.plan.activities, []);
+ });
+
+ it("sets closedAt and clears a pending follow-up when a lead goes terminal", () => {
+ const result = buildLeadUpdate(lead, { status: "closed" }, NOW);
+ assert.ok(result.ok);
+ assert.deepStrictEqual(result.plan.updates.closedAt, NOW);
+ assert.strictEqual(result.plan.updates.nextFollowUpAt, DELETE_FIELD);
+ assert.deepStrictEqual(
+ result.plan.activities.map((a) => a.type),
+ ["status_changed", "follow_up_cleared"]
+ );
+ });
+
+ it("stores a provided outcome when a lead goes terminal", () => {
+ const result = buildLeadUpdate(
+ lead,
+ { status: "customer", outcome: "First order placed" },
+ NOW
+ );
+ assert.ok(result.ok);
+ assert.strictEqual(result.plan.updates.outcome, "First order placed");
+ // Outcome is part of the append-only history, not a silent field update.
+ assert.deepStrictEqual(
+ result.plan.activities.map((a) => a.type),
+ ["status_changed", "follow_up_cleared", "outcome_changed"]
+ );
+ });
+
+ it("records outcome changes and clears in the timeline", () => {
+ const closed = { status: "closed", outcome: "No fit" };
+ const changed = buildLeadUpdate(
+ closed,
+ { outcome: "Went with a competitor" },
+ NOW
+ );
+ assert.ok(changed.ok);
+ assert.deepStrictEqual(changed.plan.activities, [
+ {
+ type: "outcome_changed",
+ details: { from: "No fit", to: "Went with a competitor" },
+ },
+ ]);
+ assert.strictEqual(changed.plan.updates.outcome, "Went with a competitor");
+
+ const cleared = buildLeadUpdate(closed, { outcome: null }, NOW);
+ assert.ok(cleared.ok);
+ assert.strictEqual(cleared.plan.updates.outcome, DELETE_FIELD);
+ assert.deepStrictEqual(cleared.plan.activities[0]?.details, {
+ from: "No fit",
+ to: null,
+ });
+ });
+
+ it("clears closedAt/outcome when a terminal lead reopens", () => {
+ const result = buildLeadUpdate(
+ { ...lead, status: "closed", outcome: "No fit" },
+ { status: "contacted" },
+ NOW
+ );
+ assert.ok(result.ok);
+ assert.strictEqual(result.plan.updates.closedAt, DELETE_FIELD);
+ assert.strictEqual(result.plan.updates.outcome, DELETE_FIELD);
+ });
+
+ it("rejects an outcome on a non-terminal lead", () => {
+ const result = buildLeadUpdate(lead, { outcome: "won" }, NOW);
+ assert.strictEqual(result.ok, false);
+ });
+
+ it("ignores a follow-up set on a terminal lead", () => {
+ const result = buildLeadUpdate(
+ { status: "closed" },
+ { nextFollowUpAt: new Date("2026-10-20T00:00:00.000Z") },
+ NOW
+ );
+ assert.ok(result.ok);
+ assert.strictEqual(result.plan.changed, false);
+ });
+
+ it("records owner assignment, reassignment, and unassignment", () => {
+ const assigned = buildLeadUpdate(
+ { status: "new" },
+ { assignee: { uid: "u-1", name: "Chad" } },
+ NOW
+ );
+ assert.ok(assigned.ok);
+ assert.strictEqual(assigned.plan.updates.assignedToUid, "u-1");
+ assert.strictEqual(assigned.plan.updates.assignedToName, "Chad");
+ assert.deepStrictEqual(assigned.plan.activities[0]?.details?.to, "Chad");
+
+ const reassigned = buildLeadUpdate(lead, { assignee: { uid: "u-2", name: "Malachy" } }, NOW);
+ assert.ok(reassigned.ok);
+ assert.deepStrictEqual(reassigned.plan.activities[0]?.details, {
+ from: "Chad",
+ to: "Malachy",
+ });
+
+ const unassigned = buildLeadUpdate(lead, { assignee: null }, NOW);
+ assert.ok(unassigned.ok);
+ assert.strictEqual(unassigned.plan.updates.assignedToUid, DELETE_FIELD);
+ assert.deepStrictEqual(unassigned.plan.activities[0]?.details, {
+ from: "Chad",
+ to: null,
+ });
+ });
+
+ it("distinguishes follow-up set vs change vs clear", () => {
+ const set = buildLeadUpdate(
+ { status: "contacted" },
+ { nextFollowUpAt: new Date("2026-10-10T00:00:00.000Z") },
+ NOW
+ );
+ assert.ok(set.ok);
+ assert.strictEqual(set.plan.activities[0]?.type, "follow_up_set");
+ assert.strictEqual(
+ set.plan.activities[0]?.details?.at,
+ "2026-10-10T00:00:00.000Z"
+ );
+
+ const moved = buildLeadUpdate(
+ lead,
+ { nextFollowUpAt: new Date("2026-10-18T00:00:00.000Z") },
+ NOW
+ );
+ assert.ok(moved.ok);
+ assert.strictEqual(moved.plan.activities[0]?.type, "follow_up_changed");
+ assert.deepStrictEqual(moved.plan.activities[0]?.details, {
+ at: "2026-10-18T00:00:00.000Z",
+ from: "2026-10-05T04:00:00.000Z",
+ });
+
+ const cleared = buildLeadUpdate(lead, { nextFollowUpAt: null }, NOW);
+ assert.ok(cleared.ok);
+ assert.strictEqual(cleared.plan.activities[0]?.type, "follow_up_cleared");
+ assert.strictEqual(cleared.plan.updates.nextFollowUpAt, DELETE_FIELD);
+ });
+
+ it("advances updatedAt/lastActivityAt only on meaningful changes", () => {
+ const changed = buildLeadUpdate(lead, { status: "contacted" }, NOW);
+ assert.ok(changed.ok);
+ assert.deepStrictEqual(changed.plan.updates.updatedAt, NOW);
+ assert.deepStrictEqual(changed.plan.updates.lastActivityAt, NOW);
+
+ const noop = buildLeadUpdate(lead, { status: "new" }, NOW);
+ assert.ok(noop.ok);
+ assert.strictEqual("updatedAt" in noop.plan.updates, false);
+ });
+});
+
+describe("parseManualLeadBody", () => {
+ const base = {
+ businessName: "Harbour Bar",
+ contactName: "Sam",
+ email: "sam@harbour.example",
+ phoneOrWhatsapp: "+599 555 1234",
+ venueType: "bar",
+ message: "Met at the trade show.",
+ source: "event",
+ };
+
+ it("accepts a complete manual lead", () => {
+ const result = parseManualLeadBody(base);
+ assert.ok(result.ok);
+ assert.strictEqual(result.input.businessName, "Harbour Bar");
+ assert.strictEqual(result.input.source, "event");
+ });
+
+ it("requires a business name", () => {
+ assert.strictEqual(parseManualLeadBody({ ...base, businessName: " " }).ok, false);
+ });
+
+ it("requires at least one contact channel", () => {
+ const result = parseManualLeadBody({ ...base, email: "", phoneOrWhatsapp: "" });
+ assert.strictEqual(result.ok, false);
+ const phoneOnly = parseManualLeadBody({ ...base, email: "" });
+ assert.ok(phoneOnly.ok);
+ const emailOnly = parseManualLeadBody({ ...base, phoneOrWhatsapp: "" });
+ assert.ok(emailOnly.ok);
+ });
+
+ it("rejects trade_form as a manual source — that source is written by the public flow", () => {
+ assert.strictEqual(parseManualLeadBody({ ...base, source: "trade_form" }).ok, false);
+ });
+
+ it("rejects unknown sources and validates optional email format", () => {
+ assert.strictEqual(parseManualLeadBody({ ...base, source: "smoke_signal" }).ok, false);
+ assert.strictEqual(parseManualLeadBody({ ...base, email: "not-an-email" }).ok, false);
+ });
+
+ it("defaults venueType to 'other' and rejects non-canonical values", () => {
+ const defaulted = parseManualLeadBody({ ...base, venueType: "" });
+ assert.ok(defaulted.ok);
+ assert.strictEqual(defaulted.input.venueType, "other");
+ assert.strictEqual(parseManualLeadBody({ ...base, venueType: "garbage" }).ok, false);
+ });
+});
+
+describe("validateNoteBody", () => {
+ it("requires non-empty trimmed text within the cap", () => {
+ assert.deepStrictEqual(validateNoteBody(" hello "), { ok: true, note: "hello" });
+ assert.strictEqual(validateNoteBody(" ").ok, false);
+ assert.strictEqual(validateNoteBody(42).ok, false);
+ assert.strictEqual(
+ validateNoteBody("x".repeat(TRADE_LEAD_NOTE_MAX_LENGTH + 1)).ok,
+ false
+ );
+ assert.ok(validateNoteBody("x".repeat(TRADE_LEAD_NOTE_MAX_LENGTH)).ok);
+ });
+});
+
+describe("serializers", () => {
+ it("serializes a lead with ISO timestamps and normalized status", () => {
+ const view = serializeTradeLead("lead-1", {
+ businessName: "Bar",
+ contactName: "Sam",
+ email: "s@x.example",
+ phoneOrWhatsapp: "",
+ venueType: "bar",
+ message: "",
+ status: "legacy-value",
+ source: "trade_form",
+ createdAt: ts("2026-01-01T00:00:00.000Z"),
+ updatedAt: ts("2026-01-02T00:00:00.000Z"),
+ nextFollowUpAt: ts("2026-10-18T04:00:00.000Z"),
+ });
+ assert.strictEqual(view.status, "new");
+ assert.strictEqual(view.createdAt, "2026-01-01T00:00:00.000Z");
+ assert.strictEqual(view.nextFollowUpAt, "2026-10-18T04:00:00.000Z");
+ assert.strictEqual(view.assignedToUid, undefined);
+ });
+
+ it("serializes an activity", () => {
+ const view = serializeTradeLeadActivity("a-1", {
+ type: "status_changed",
+ seq: 3,
+ authorUid: "u-1",
+ authorName: "Chad",
+ details: { from: "new", to: "contacted" },
+ createdAt: ts("2026-10-02T15:00:00.000Z"),
+ });
+ assert.strictEqual(view.type, "status_changed");
+ assert.strictEqual(view.seq, 3);
+ assert.strictEqual(view.authorName, "Chad");
+ assert.strictEqual(view.createdAt, "2026-10-02T15:00:00.000Z");
+ });
+});
+
+describe("describeTradeLeadActivity", () => {
+ const act = (
+ type: string,
+ details?: TradeLeadActivityView["details"]
+ ) => describeTradeLeadActivity({ type, details });
+
+ it("renders each mutation type as a readable line", () => {
+ assert.strictEqual(act("lead_created", { source: "whatsapp" }), "Lead created — WhatsApp");
+ assert.strictEqual(
+ act("status_changed", { from: "contacted", to: "follow_up" }),
+ "Status changed from Contacted to Follow-up"
+ );
+ assert.strictEqual(act("owner_changed", { to: "Chad" }), "Assigned to Chad");
+ assert.strictEqual(act("owner_changed", { to: null }), "Owner unassigned");
+ assert.strictEqual(act("follow_up_cleared"), "Follow-up cleared");
+ assert.strictEqual(
+ act("outcome_changed", { to: "First order placed" }),
+ "Outcome updated: First order placed"
+ );
+ assert.strictEqual(act("outcome_changed", { to: null }), "Outcome cleared");
+ assert.strictEqual(act("note"), "Note");
+ });
+
+ it("renders the reserved communication type for future messages", () => {
+ assert.strictEqual(
+ describeTradeLeadActivity({
+ type: "communication",
+ communication: { channel: "email", direction: "outbound" },
+ }),
+ "Message sent via email"
+ );
+ });
+});
+
+// Route + script wiring asserted on source — the same convention as
+// tests/lib/trade-leads.test.ts for modules that pull in server-only deps.
+describe("admin trade-lead wiring", () => {
+ const read = (rel: string) =>
+ readFileSync(join(process.cwd(), rel), "utf8");
+
+ it("every trade-lead API route goes through requireAdminActor", () => {
+ for (const rel of [
+ "app/api/admin/trade-leads/route.ts",
+ "app/api/admin/trade-leads/[id]/route.ts",
+ "app/api/admin/trade-leads/[id]/activities/route.ts",
+ ]) {
+ assert.ok(
+ read(rel).includes("requireAdminActor"),
+ `${rel} must enforce the admin-actor invariant`
+ );
+ }
+ });
+
+ it("the public submission writes a lead_created activity with the lead", () => {
+ const source = read("lib/trade-leads.ts");
+ assert.ok(source.includes("TRADE_LEAD_ACTIVITIES_SUBCOLLECTION"));
+ assert.ok(source.includes('"lead_created"'));
+ });
+
+ it("the prune script removes the activities subcollection with the lead", () => {
+ const source = read("scripts/prune-trade-leads.ts");
+ assert.ok(source.includes("TRADE_LEAD_ACTIVITIES_SUBCOLLECTION"));
+ // Activities are batch-deleted after the lead's transaction commits —
+ // a long history must never overflow a single transaction's write limit.
+ assert.ok(source.includes("deleteLeadActivities"));
+ assert.ok(source.includes("batch.delete(doc.ref)"));
+ });
+});
diff --git a/tests/security-rules.test.ts b/tests/security-rules.test.ts
index 1b5e421..1117640 100644
--- a/tests/security-rules.test.ts
+++ b/tests/security-rules.test.ts
@@ -74,6 +74,32 @@ describe("firestore.rules", () => {
"Expected the venue island allowlist on writes"
);
});
+
+ it("keeps tradeLeads and its activities subcollection fully server-side (Issue #150)", () => {
+ // Scope assertions to the tradeLeads block itself (everything until the
+ // next sibling match) so a deny elsewhere in the file can't mask a
+ // missing deny here.
+ const leadStart = rules.indexOf("match /tradeLeads/{leadId}");
+ const leadEnd = rules.indexOf("match /meta/{docId}", leadStart);
+ assert.ok(leadStart > -1, "Expected the tradeLeads deny-all match");
+ assert.ok(leadEnd > leadStart, "Expected meta match after tradeLeads");
+ const leadSection = rules.slice(leadStart, leadEnd);
+ assert.ok(
+ leadSection.includes("allow read, write: if false"),
+ "Expected tradeLeads to deny all client access"
+ );
+ const activitiesStart = leadSection.indexOf(
+ "match /activities/{activityId}"
+ );
+ assert.ok(
+ activitiesStart > -1,
+ "Expected the nested tradeLeads activities deny-all match"
+ );
+ assert.ok(
+ leadSection.slice(activitiesStart).includes("allow read, write: if false"),
+ "Expected the activities subcollection to deny all client access"
+ );
+ });
});
describe("storage.rules", () => {