# PRP-001: Contact Requests → Lead Management & Callback Workflow

name: "Grow the Contact Requests module from lead-capture into an owned, callback-driven lead-management workflow (assignment, activity/call timeline, follow-up scheduling, convert-to-ticket), transactional-only (no Brevo contact sync)."
description: |
  Today Contact Requests (Module 3, living in `src/modules/email/`) only *captures*
  leads: a public `POST /api/v1/contact`, a Brevo **contact upsert**, an
  acknowledgement + sales email, and a thin admin list/detail with `status` +
  `admin_notes`. This PRP turns it into a small, owned **lead-management workflow**
  centred on the ShipRex "call me back" promise:

  - Every lead can be **assigned an owner** (an `admin_user`); assigning notifies
    that owner by email.
  - A real **pipeline status** (`new → assigned → in_progress → qualified → won /
    lost / spam`) plus a separate, append-only **activity/call timeline** (calls
    with outcomes, notes, status changes, follow-ups, conversion, system events).
  - The owner schedules a structured **`next_follow_up_at`** that powers a
    **due / overdue** list filter and a dashboard widget (no background job — this
    runs on cPanel/Passenger).
  - An admin can **convert a lead into a support ticket** (reusing the tickets
    module); the lead is linked to the ticket and marked `won`.
  - **Consent is transactional-only:** we stop pushing leads into Brevo contacts
    entirely. We keep only transactional emails (ack, sales notify, owner-assigned).
    Leads are flagged `marketing_consent = false`.

  This is an **enhancement of the existing module**, not a rewrite. We reuse the
  existing service/controller/views and the shared building blocks, and we reuse
  the tickets module for conversion. The public API contract is essentially
  **unchanged** (the form is the same single general form) — almost all the work is
  schema + service + admin UI.

  **Guiding constraint:** match the existing module patterns (see
  `docs/feature-development-guide.md` and the Knowledge Base reference module) and
  the current `contact.service.js`/`contacts.controller.js`; never hand-roll a
  response envelope, validator, email send, or query that a shared util already
  provides; never re-introduce a Brevo contact sync; keep the public API surface
  stable.

---

## Metadata

| Field | Value |
|-------|-------|
| **Date Created** | 2026-06-28 |
| **Status** | Draft |
| **Priority** | 🟠 High |
| **Type** | New feature (enhancement of existing module) + Behavior-alignment (Brevo) |
| **Module** | `src/modules/email/` (the contact slice) + `src/admin/contacts.controller.js` |
| **New files** | `src/models/contactActivity.js`; `migrations/<ts>-evolve-contact-requests.js`; `migrations/<ts>-create-contact-activities.js`; `src/views/contacts/_timeline.ejs` (partial); possibly `src/views/contacts/index.ejs` is reused (`list.ejs`/`show.ejs` are rewritten) |
| **Edited files (integration)** | `src/models/contactRequest.js`, `src/models/index.js` (associations + new model), `src/modules/email/contact.service.js` (most logic), `src/modules/email/contact.api.controller.js` (only if body changes — it does **not**), `src/admin/contacts.controller.js`, `src/admin/dashboard.controller.js`, `src/routes/admin/index.js` (new admin routes), `src/views/contacts/list.ejs` + `show.ejs`, `src/views/dashboard/index.ejs`, `docs/postman/*` (note transactional-only), `.env.example` (only if a new setting is added — none expected) |
| **DB changes** | **`contact_requests`**: change `status` enum to `new,assigned,in_progress,qualified,won,lost,spam` (with data migration); **add** `next_follow_up_at` (DATETIME, null), `last_activity_at` (DATETIME, null), `converted_ticket_id` (FK `support_tickets`, null, SET NULL), `marketing_consent` (BOOLEAN, default false); **remove** `brevo_contact_id`, `brevo_synced_at`. **New table** `contact_activities`. → see [database-guide.md](../database-guide.md). Migration files named above. |
| **Reused (DO NOT re-implement)** | `asyncHandler`, `apiResponse` (`ok`/`created`/`ApiError`), `validate` (zod), `requireAdmin`/`requireRole`, `errorHandler`, `webHelpers` (flash/method-override/locals), `sendAndLog` (`src/modules/email/email.service.js`), `ticketService.create` (`src/modules/support/ticket.service.js`), `config`, `logger`, `layouts/admin.ejs`, `partials/flash.ejs`, `partials/pagination.ejs`, `public/css/admin.css` (`.card/.table-wrap/.toolbar/.btn/.badge.<color>/.thread/.msg`) |
| **API contract** | `docs/postman/ShipRex_API.postman_collection.json` → folder **Contact / Email** (`/api/v1`). The public request shape is unchanged; only the internal behavior (no Brevo contact sync) and the stored fields change. |
| **Reference module (read-only)** | **Knowledge Base** for structure; **Support Tickets** (`src/modules/support/ticket.service.js`, `src/admin/tickets.controller.js`, `src/views/tickets/show.ejs`) for the activity/timeline + reply-thread pattern this PRP mirrors. |
| **Docs to update** | `docs/postman/README.md` + collection (note transactional-only / fields); this PRP's checklist. No new shared pattern → no guide changes expected. |
| **Workflow rule** | `git add -A && git commit` after **every** task. Schema change ships the migration **and** model edit in the same commit. The public contract doesn't change, so no Postman change is required for endpoints — but update the collection's notes in the docs task. |

> ⚠️ **Standards rule:** every task follows
> [feature-development-guide.md](../feature-development-guide.md),
> [architecture.md](../architecture.md), and [database-guide.md](../database-guide.md).
> Each task that changes the **schema** ends with a **"🗄️ DB"** line (model ↔
> migration lockstep, runs clean on a scratch DB, `down` reverses). Each task that
> changes a **visible surface** (admin screen or API response) ends with a
> **"✅ Verify"** line.
>
> ⚠️ **Realm rule:** all new capability is **admin-only** (`/admin`, session). The
> public API stays a single capture endpoint. No lead-management verb is exposed
> under `/api`, and the admin lead screens never accept an API key.

---

## Goal

When this PRP is done:

**Public API (unchanged contract).** `POST /api/v1/contact` accepts the same body
as today (one general form). Internally it no longer calls Brevo contacts; it
stores `marketing_consent = false`, still sends the acknowledgement to the lead and
the notification to sales, and logs a `system` "Lead received" activity.

**Admin — list (`/admin/contacts`).** Columns include **owner**, **status** (new
pipeline), **next follow-up** (with an "overdue"/"due today" badge), reason, and
received date. Filters: status, owner, **follow-up** (overdue / due today /
upcoming / none), reason, free-text search, and a date range. A row quick-action to
**mark spam**. Pagination as today.

**Admin — detail (`/admin/contacts/:id`).** Shows the lead's contact info, a
"other requests from this email" hint, and:
- **Assign owner** (dropdown of active staff) → sets `handled_by_id`, moves
  `new → assigned`, logs an `assignment` activity, emails the owner.
- **Status** control (the new pipeline) → logs a `status_change`.
- **Log a call** (outcome: reached / no-answer / voicemail / busy / wrong-number /
  scheduled-callback; note; optional `next_follow_up_at`) → logs a `call` activity,
  moves `new/assigned → in_progress`, updates `last_activity_at`.
- **Add note** → `note` activity.
- **Set / clear follow-up** (`next_follow_up_at`) → `follow_up` activity.
- **Convert to support ticket** (admin-only button; pick category/priority) →
  creates a linked ticket via the tickets module, stores `converted_ticket_id`,
  sets status `won`, logs a `conversion` activity, links to the ticket.
- **Activity timeline** — the full chronological history (calls, notes, status
  changes, assignment, follow-ups, conversion, system), each with actor + time.
- **Delete (GDPR erasure)** — admin/superadmin only; hard-deletes the lead and its
  activities (cascade), with a confirm.

**Admin — dashboard.** A "Follow-ups" widget showing **overdue** and **due-today**
counts, linking into the filtered list. New-leads count stays.

**Behavior alignment.** No code path writes to Brevo **contacts** anymore;
transactional emails still flow through Brevo's transactional API via `sendAndLog`.

Each role (superadmin/admin/agent) can see and own all leads. Only admin/superadmin
can delete.

## Why

- **The "call me back" promise isn't operationalised.** Today a lead lands as a row
  with a flat status and a free-text note (`contact.service.js` `create`/`update`,
  `contacts.controller.js`). There's no owner, no call history, no follow-up timing
  — so leads get dropped. This PRP makes ownership, follow-up, and call outcomes
  first-class.
- **Status is too flat for a pipeline.** `contactRequest.js` `status` is
  `new/contacted/qualified/closed/spam` — it conflates "where in the pipeline" with
  "what happened on the last call". We split pipeline **status** from per-attempt
  **call outcome** (on a timeline), which is the standard callback model.
- **Brevo contact sync contradicts the consent decision.** `contact.service.js`
  currently calls `brevo.upsertContact(...)` on every submission
  (`contact.service.js` `create`), pushing every visitor into Brevo. The product
  decision is **transactional-only**: we must stop syncing contacts and only keep
  transactional emails. Leaving the upsert in is contract drift and a consent risk.
- **No bridge to support.** Many "contact us" messages are really support issues.
  We already have a tickets module; converting a lead into a ticket
  (`ticketService.create`) avoids re-keying and keeps a link.

## What (surface changes, summarized)

| Surface | Today | After this PRP |
|---------|-------|----------------|
| `POST /api/v1/contact` | stores lead **+ Brevo upsert** + ack + sales email | **same body & response**; **no Brevo contact**; stores `marketing_consent=false`; logs a `system` activity |
| `contact_requests.status` | `new/contacted/qualified/closed/spam` | `new/assigned/in_progress/qualified/won/lost/spam` (migrated) |
| `/admin/contacts` (list) | status/reason/search filters; basic columns | + owner & next-follow-up columns; owner + follow-up(due/overdue) + date-range filters; mark-spam quick action |
| `/admin/contacts/:id` (detail) | view + status + admin_notes | assign owner, log call (outcome+follow-up), add note, set follow-up, convert-to-ticket, full timeline, delete(erasure) |
| `/admin/dashboard` | new-leads count | + Follow-ups overdue / due-today widget |
| Brevo | contact upsert on every lead | **contacts: none**; transactional emails only |

### Public API endpoints (the contract — unchanged)

| Method | Path | Scope | Change |
|--------|------|-------|--------|
| POST | `/api/v1/contact` | `contact` | **No shape change.** Internal: drop Brevo contact sync; set `marketing_consent=false`; log activity. Still 201 with `{ id, status, message }`. |

> No new public endpoints, no new scope. (We deliberately did **not** expose
> lead-management verbs publicly.)

### Admin routes (new/changed, all under `requireAdmin`)

| Method | Path | Role | Action |
|--------|------|------|--------|
| GET | `/admin/contacts` | any staff | list (new filters/columns) |
| GET | `/admin/contacts/:id` | any staff | detail + timeline |
| PUT | `/admin/contacts/:id` | any staff | update status / admin_notes (existing, extended) |
| POST | `/admin/contacts/:id/assign` | any staff | assign owner (+ notify) |
| POST | `/admin/contacts/:id/activities` | any staff | log call / note (type in body) |
| POST | `/admin/contacts/:id/follow-up` | any staff | set/clear `next_follow_up_at` |
| POST | `/admin/contacts/:id/convert` | any staff | convert to ticket |
| POST | `/admin/contacts/:id/spam` | any staff | quick mark-as-spam |
| DELETE | `/admin/contacts/:id` | admin, superadmin | GDPR erasure (cascade) |

---

## DB schema changes

Per [database-guide.md](../database-guide.md). **Two migrations**, both in Task 1.

### Migration A — `migrations/<ts>-evolve-contact-requests.js` (`contact_requests`)

```
~ status ENUM  → 'new','assigned','in_progress','qualified','won','lost','spam'
    data migrate: 'contacted' → 'in_progress';  'closed' → 'lost';  keep new/qualified/spam
    (MySQL-safe: expand enum to the UNION first, UPDATE rows, then contract to the final set)
+ next_follow_up_at   DATETIME    NULL          (drives due/overdue)
+ last_activity_at    DATETIME    NULL          (sort/most-recent)
+ converted_ticket_id INT UNSIGNED NULL  FK → support_tickets(id) ON DELETE SET NULL
+ marketing_consent   BOOLEAN     NOT NULL DEFAULT false
- brevo_contact_id    (drop — deprecated by transactional-only)
- brevo_synced_at     (drop — deprecated)
index: add (next_follow_up_at)   — for the due/overdue query
```

### Migration B — `migrations/<ts>-create-contact-activities.js` (new table)

```
contact_activities
  id                 BIGINT UNSIGNED PK AI
  contact_request_id INT UNSIGNED NOT NULL  FK → contact_requests(id) ON DELETE CASCADE
  admin_id           INT UNSIGNED NULL      FK → admin_users(id) ON DELETE SET NULL  (null = system)
  type               ENUM('note','call','status_change','assignment','follow_up','conversion','system') NOT NULL
  outcome            ENUM('reached','no_answer','voicemail','busy','wrong_number','scheduled_callback') NULL  (calls only)
  body               TEXT NULL              (note text / call summary / human-readable change)
  meta               JSON NULL              (e.g. { from:'new', to:'assigned' } / { ticket_reference })
  occurred_at        DATETIME NOT NULL
  created_at, updated_at
  index: (contact_request_id), (occurred_at)
```

**Associations (in `src/models/index.js`):**
```
ContactRequest.hasMany(ContactActivity, { as:'activities', foreignKey:'contact_request_id', onDelete:'CASCADE' })
ContactActivity.belongsTo(ContactRequest, { as:'contact', foreignKey:'contact_request_id' })
ContactActivity.belongsTo(AdminUser, { as:'actor', foreignKey:'admin_id' })
AdminUser.hasMany(ContactActivity, { as:'contactActivities', foreignKey:'admin_id' })
ContactRequest.belongsTo(SupportTicket, { as:'convertedTicket', foreignKey:'converted_ticket_id' })
```
(Keep the existing `ContactRequest.belongsTo(AdminUser, { as:'handledBy', foreignKey:'handled_by_id' })` — `handledBy` **is** the owner.)

---

## ⛔ STOP — read & understand BEFORE writing code

Read in order; answer the "Check yourself" after each.

1. **[architecture.md](../architecture.md)** + **[feature-development-guide.md](../feature-development-guide.md)** — two realms, module pattern, the response envelope, reused blocks, the build order.
   *Check yourself:* Where does the lead logic live, and what does the admin controller do? (Service = logic + Sequelize + `ApiError`; admin controller is thin → `res.render` + `req.flash` + redirect.)

2. **[database-guide.md](../database-guide.md)** — esp. §5 "change an ENUM" (expand-union → migrate data → contract) and model ↔ migration lockstep.
   *Check yourself:* Why can't you `changeColumn` straight to the new enum? (Rows holding `contacted`/`closed` would violate the new set — expand to the union, UPDATE, then contract.)

3. **The current contact slice** — `src/models/contactRequest.js`,
   `src/modules/email/contact.service.js` (esp. `create`, `update`, `counts`),
   `src/modules/email/contact.api.controller.js`,
   `src/admin/contacts.controller.js`, `src/views/contacts/{list,show}.ejs`.
   *Check yourself:* What exactly must be **removed** for transactional-only? (the `brevo.upsertContact(...)` call + the `brevo_contact_id`/`brevo_synced_at` writes in `contact.service.js` `create`.)

4. **The tickets module (for conversion + the timeline pattern)** —
   `src/modules/support/ticket.service.js` (`create`, `addAdminReply`,
   `getById` with `replies` include), `src/admin/tickets.controller.js`,
   `src/views/tickets/show.ejs` (the `.thread`/`.msg` timeline + the post-a-reply form).
   *Check yourself:* What does `ticketService.create(data, meta)` return, and what do we store on the lead? (the created `SupportTicket`; store its `id` in `converted_ticket_id` and show its `reference`.)

5. **`sendAndLog`** — `src/modules/email/email.service.js`. It sends via Brevo
   **transactional** email and writes an `EmailLog`, never throwing to the caller.
   *Check yourself:* Is `sendAndLog` "Brevo contacts"? (No — it's transactional email; it stays. Only `brevo.upsertContact` goes.)

6. **Integration wiring** — `src/routes/admin/index.js` (the `requireAdmin` block + `?_method` overrides), `src/models/index.js`, `src/admin/dashboard.controller.js`, `src/views/layouts/admin.ejs` (the Contacts nav item already exists).
   *Check yourself:* How do admin update/delete forms send PUT/DELETE? (`webHelpers.methodOverride` via `?_method=PUT|DELETE`.)

> **Mental model:** a Contact Request is a **lead with an owner moving through a
> pipeline**, and everything that happens to it (calls, notes, status changes,
> follow-ups, the conversion) is an **immutable timeline entry**. The lead's
> columns are the *current* state; the `contact_activities` rows are the *history*.
> Brevo is now only a transactional **mail** transport — never a contact store.

---

## Known gotchas & critical edge cases (READ TWICE)

```text
# GOTCHA 1 — ENUM migration is destructive if done wrong. Expand status enum to the UNION of old+new,
#   UPDATE 'contacted'→'in_progress' and 'closed'→'lost', THEN contract to the final 7 values. The
#   model's enum must match the FINAL set. down() reverses (expand→map back→contract to old 5).

# GOTCHA 2 — Remove Brevo CONTACTS, keep Brevo EMAIL. Delete the brevo.upsertContact(...) block in
#   contact.service.js create(); do NOT touch sendAndLog / brevo.sendEmail. EmailLog still records ack/
#   sales/owner-assigned emails.

# GOTCHA 3 — Side-effects never block capture. Owner-assigned email, ack, and sales notify go through
#   sendAndLog (fire-and-log). A mail failure must never fail assignment or capture.

# GOTCHA 4 — Activities are append-only and ALWAYS carry occurred_at + actor. 'system' activities have
#   admin_id = null. Write them inside the same service methods that change state (assign/log/status/
#   follow-up/convert) so history can't drift from state. Update last_activity_at on every activity.

# GOTCHA 5 — Status auto-transitions are gentle, not forced. Assigning moves new→assigned (only if
#   currently 'new'). First call/note moves new|assigned→in_progress. Convert sets 'won'. 'lost'/'spam'/
#   'qualified' are manual. Never silently overwrite a later status with an earlier one.

# GOTCHA 6 — next_follow_up_at is a DATETIME the OWNER sets; the visitor's preferred_time stays free text
#   and is display-only. "overdue" = next_follow_up_at < now AND status not in (won,lost,spam). "due
#   today" = same calendar day. Do the comparison in SQL (indexed) for the list/dashboard, not in JS.

# GOTCHA 7 — Convert-to-ticket reuses ticketService.create — do NOT hand-build a ticket. Map name/email/
#   subject(=a short line from reason+company)/description(=message) + meta { from_contact_id }. Store the
#   returned ticket.id in converted_ticket_id, set status 'won', log a 'conversion' activity with the
#   ticket reference in meta. Guard against double-conversion (already has converted_ticket_id).

# GOTCHA 8 — Public contract is frozen. Do not add fields to contactSchema / the POST body or change its
#   response. marketing_consent is set server-side (false), not read from the request.

# GOTCHA 9 — Admin checkbox/empty-field semantics. Unchecked checkboxes and empty selects submit nothing
#   or ''. Treat '' as "clear" for follow-up; coerce/validate in the controller. Use ?_method=PUT|DELETE
#   for non-POST verbs.

# GOTCHA 10 — Delete is GDPR erasure (hard delete). Gate with requireRole('admin','superadmin'); cascade
#   removes contact_activities (FK CASCADE). Confirm in the UI. Document the irreversibility in Rollback.

# GOTCHA 11 — Dropping brevo_* columns is destructive. Acceptable here (early stage, transactional-only),
#   but down() must re-add them (nullable) — data in them cannot be restored. Note in Rollback.
```

---

## Implementation Blueprint

### Files touched/created
```
src/models/contactRequest.js          # enum + new columns; (model mirrors migration A)
src/models/contactActivity.js          # NEW model (migration B)
src/models/index.js                    # register ContactActivity + associations
src/modules/email/contact.service.js   # most new logic (no Brevo upsert; activities; assign; calls;
                                        #   follow-up; status; spam; convert; delete; list filters;
                                        #   dueFollowUps; counts)
src/admin/contacts.controller.js       # thin handlers for the new admin actions
src/admin/dashboard.controller.js      # follow-up widget counts
src/routes/admin/index.js              # new admin routes (assign/activities/follow-up/convert/spam/delete)
src/views/contacts/list.ejs            # columns + filters + quick actions (rewrite)
src/views/contacts/show.ejs            # detail + actions + timeline (rewrite)
src/views/contacts/_timeline.ejs       # NEW partial: renders activities
src/views/dashboard/index.ejs          # follow-up widget
migrations/<ts>-evolve-contact-requests.js
migrations/<ts>-create-contact-activities.js
docs/postman/* (notes only)
```

### Strategy in one sentence
Keep the public capture endpoint as-is (minus Brevo contacts); put all new behavior
in `contact.service.js` as small methods that **mutate state and append a timeline
activity together**, and render thin admin screens (mirroring the tickets `show`
timeline) over those methods.

---

## Tasks (do them in this order)

> Each task: **Read first**, **Change**, **Pattern to copy**, **Edge cases**,
> **🗄️ DB** / **✅ Verify**, **✅ Commit**. Small, independently committable.

### Task 1 — Schema: model changes + `ContactActivity` + two migrations + associations
**Read first:** `database-guide.md` §4–6; the init-schema migration; `contactRequest.js`; `src/models/index.js`.
**Change:**
- Migration A (`evolve-contact-requests`): enum expand→migrate→contract (GOTCHA 1); add `next_follow_up_at`, `last_activity_at`, `converted_ticket_id` (FK SET NULL), `marketing_consent`; drop `brevo_contact_id`, `brevo_synced_at`; add index on `next_follow_up_at`. Reversible `down`.
- Migration B (`create-contact-activities`): create the table per the schema above.
- `src/models/contactActivity.js`: new model mirroring B.
- `src/models/contactRequest.js`: new enum + new attributes; remove the two brevo attrs.
- `src/models/index.js`: require/register `ContactActivity`; add the associations listed in "DB schema changes".
**Edge cases:** GOTCHA 1, 11; FK `onDelete` (CASCADE for activities, SET NULL for converted ticket).
**🗄️ DB:** run both on a scratch DB; verify `SHOW CREATE TABLE` matches the models; `down` reverses cleanly.
**✅ Verify:** `node -e "require('dotenv').config(); require('./src/models'); console.log('OK')"`.
**✅ Commit:** `PRP-001 Task 1: evolve contact_requests + add contact_activities (models + migrations)`

### Task 2 — Service core: drop Brevo contacts, add activity primitive, update `create`
**Read first:** `contact.service.js` (`create`), `email.service.js` (`sendAndLog`), `brevo.client.js`.
**Change:**
- Add a private `logActivity(contactId, { type, outcome?, body?, meta?, actorId?, occurredAt? })` that creates a `ContactActivity` and bumps `last_activity_at`. Use it everywhere state changes.
- In `create`: **remove** the `brevo.upsertContact` block and the `brevo_contact_id`/`brevo_synced_at` writes; set `marketing_consent: false`; keep the ack + sales `sendAndLog` calls; after creation, `logActivity(id, { type:'system', body:'Lead received', meta:{ source } })`.
**Edge cases:** GOTCHA 2, 3, 4, 8.
**✅ Verify:** require-load; reason that `create` no longer references Brevo contacts (grep).
**✅ Commit:** `PRP-001 Task 2: transactional-only capture + activity log primitive`

### Task 3 — Service: assignment + owner notification
**Read first:** `AdminUser` model; `sendAndLog`; tickets' `addAdminReply` (email pattern).
**Change:** `assign(id, ownerId, actorId)` → set `handled_by_id`; if status `new` → `assigned`; `logActivity('assignment', { meta:{ owner_id } })`; email the owner via `sendAndLog` (type `lead_assigned`, related `{ type:'contact', id }`). `unassign` clears owner + logs.
**Edge cases:** assigning to the same owner is a no-op (no duplicate email); GOTCHA 5; missing owner → `ApiError(422)`.
**✅ Verify:** require-load.
**✅ Commit:** `PRP-001 Task 3: lead assignment + owner-assigned email`

### Task 4 — Service: calls, notes, follow-up scheduling (+ gentle status transitions)
**Read first:** the timeline model from tickets `show`; GOTCHA 4–6.
**Change:**
- `logCall(id, { outcome, body, nextFollowUpAt? }, actorId)` → `logActivity('call', {outcome, body})`; if `nextFollowUpAt` provided, set it + log a `follow_up`; move `new|assigned → in_progress`.
- `addNote(id, body, actorId)` → `logActivity('note', { body })`.
- `setFollowUp(id, datetimeOrNull, actorId)` → set/clear `next_follow_up_at`; `logActivity('follow_up', { meta:{ at } })` (or "cleared").
**Edge cases:** GOTCHA 6, 9; invalid/empty datetime → clear; outcome must be in the enum.
**✅ Verify:** require-load.
**✅ Commit:** `PRP-001 Task 4: call logging, notes, and follow-up scheduling`

### Task 5 — Service: status update, mark-spam, delete (erasure)
**Read first:** existing `update`; `requireRole`.
**Change:** extend `update(id, { status?, admin_notes? }, actorId)` to `logActivity('status_change', { meta:{ from,to } })` when status changes (respect GOTCHA 5 — don't regress). `markSpam(id, actorId)` → status `spam` + activity. `remove(id)` → hard delete (cascade) for erasure.
**Edge cases:** GOTCHA 10; deleting a converted lead is allowed (the ticket remains; `converted_ticket_id` FK is on the lead).
**✅ Verify:** require-load.
**✅ Commit:** `PRP-001 Task 5: status changes, mark-spam, GDPR erasure`

### Task 6 — Service: convert lead → support ticket
**Read first:** `ticketService.create` signature + returned model; `support_tickets` fields.
**Change:** `convertToTicket(id, { category, priority }, actorId)` → guard against re-convert; build ticket data from the lead (`requester_name`=name, `requester_email`=email, `subject`=`reason + (company||'')` short line, `description`=message||'(no message)'), call `ticketService.create(data, { from_contact_id: id })`; set `converted_ticket_id = ticket.id`, status `won`; `logActivity('conversion', { meta:{ ticket_reference: ticket.reference } })`; return the ticket.
**Edge cases:** GOTCHA 7; already-converted → `ApiError(409,'already_converted',…)`.
**✅ Verify:** require-load; on a scratch DB, convert a seeded lead and confirm a ticket row + link.
**✅ Commit:** `PRP-001 Task 6: convert lead to linked support ticket (mark won)`

### Task 7 — Service: list filters, due-follow-ups, counts
**Read first:** existing `list`/`counts`; GOTCHA 6.
**Change:** extend `list({ status, reason, ownerId, followUp, dateFrom, dateTo, search, page })` with `ownerId` (`handled_by_id`), `followUp` ∈ `overdue|today|upcoming|none` (SQL on `next_follow_up_at`, excluding won/lost/spam), and a `created_at` date range; include `handledBy` + (count of) activities; sort by `next_follow_up_at` when filtering follow-ups, else `created_at DESC`. Add `dueFollowUps()` → `{ overdue, today }` counts (indexed query). Extend `counts()` to also return `overdue`.
**Edge cases:** indexed comparison (GOTCHA 6); timezone — compare against server `NOW()` consistently.
**✅ Verify:** require-load; query the scratch DB for overdue/today.
**✅ Commit:** `PRP-001 Task 7: list filters (owner/follow-up/date), due-follow-up + counts`

### Task 8 — Admin detail page: actions + timeline
**Read first:** `tickets/show.ejs` (timeline + action forms), `contacts/show.ejs` (current), `contacts.controller.js`.
**Change:** controller `show` loads the lead with `handledBy`, `convertedTicket`, ordered `activities` (+ actor), the active-staff list (for assignment), and "other requests from this email". Add handlers `assign`, `activities` (call/note by `type`), `followUp`, `convert`, `spam`, `remove`. Rewrite `show.ejs`: info card + same-email hint; an Actions card (assign owner, status, log call, add note, set/clear follow-up, convert-to-ticket with category/priority, delete for admin+); a `_timeline.ejs` partial rendering activities with `.thread/.msg` styles + badges per type/outcome. Wire routes in `routes/admin/index.js` (with `?_method` for PUT/DELETE).
**Edge cases:** GOTCHA 9, 10; show the converted ticket link when present; hide Delete for `agent`.
**✅ Verify:** log in; exercise assign → owner email logged in Email Logs; log a call with a follow-up; add note; convert → ticket created + lead `won` + link shown; mark spam; delete as admin. Confirm flash + timeline updates.
**✅ Commit:** `PRP-001 Task 8: contact detail — assignment, calls, follow-up, convert, timeline`

### Task 9 — Admin list page: columns, filters, quick actions
**Read first:** `contacts/list.ejs` (current), `partials/pagination.ejs`.
**Change:** add owner + next-follow-up columns (overdue/due-today badge), new status badges; add owner, follow-up (overdue/today/upcoming/none), and date-range filters to the toolbar; add a row "mark spam" quick action (POST). Keep pagination.
**Edge cases:** preserve all filters across pagination (the pagination partial reads `query`).
**✅ Verify:** filter by overdue and by owner; confirm counts/rows; mark-spam from the row works.
**✅ Commit:** `PRP-001 Task 9: contact list — owner/follow-up columns, filters, mark-spam`

### Task 10 — Dashboard: follow-ups widget
**Read first:** `dashboard.controller.js`, `dashboard/index.ejs`.
**Change:** add `contactService.dueFollowUps()` to the dashboard data; render an "Follow-ups" stat/card showing **overdue** and **due today**, each linking to the filtered list (`/admin/contacts?followUp=overdue`). Keep the existing new-leads stat.
**✅ Verify:** seed an overdue lead; confirm the widget count + link filter.
**✅ Commit:** `PRP-001 Task 10: dashboard follow-ups (overdue / due today) widget`

### Task 11 — Contract & docs sweep
**Read first:** `docs/postman/ShipRex_API.postman_collection.json` (Contact folder), `postman/README.md`.
**Change:** the public request is unchanged, so update only the **descriptions/notes**: state that submissions are transactional-only (no Brevo contact), and that lead-management lives in the admin portal. Add the new admin form actions to the **Admin Portal** folder optionally (assign/convert as form posts) for smoke-testing. Confirm `.env.example` needs nothing new (it doesn't). Tick this PRP's checklist; flip Status → Done.
**✅ Verify:** run the Contact folder against a live server — response matches; no Brevo contact created (check there's no upsert call in logs).
**✅ Commit:** `PRP-001 Task 11: docs/postman notes for transactional-only + admin actions`

---

## Integration Points

```yaml
MODELS:        src/models/contactRequest.js (enum+cols), src/models/contactActivity.js (new)
               + register/associate in src/models/index.js
MIGRATIONS:    migrations/<ts>-evolve-contact-requests.js, migrations/<ts>-create-contact-activities.js
SERVICE:       src/modules/email/contact.service.js (assign/logCall/addNote/setFollowUp/update/markSpam/
                 convertToTicket/remove/list/dueFollowUps/counts/logActivity)  — reuses ticketService.create
PUBLIC API:    src/modules/email/contact.api.controller.js + contact.routes.js  — UNCHANGED contract
ADMIN ROUTES:  src/routes/admin/index.js  (assign/activities/follow-up/convert/spam under requireAdmin;
                 DELETE under requireRole('admin','superadmin'))
ADMIN VIEWS:   src/views/contacts/{list,show}.ejs + _timeline.ejs ; src/views/dashboard/index.ejs
ADMIN NAV:     src/views/layouts/admin.ejs  (Contacts item already exists — no change)
EMAIL:         src/modules/email/email.service.js sendAndLog (ack/sales kept; +lead_assigned)
SCOPE/ENV:     none new (no new public scope; no new env keys)
CONTRACT:      docs/postman/* (notes only — public shape unchanged)
REUSED (do not re-implement):
  asyncHandler, apiResponse/ApiError, validate, requireAdmin/requireRole, errorHandler, webHelpers,
  sendAndLog, ticketService.create, config, logger, admin layout/partials, admin.css (.thread/.msg/.badge)
```

---

## Validation Loop

### Level 1 — Static / load
```bash
node -e "require('dotenv').config(); require('./src/app'); require('./src/models'); console.log('OK')"
```
### Level 2 — Schema (scratch DB)
```bash
DB_NAME=shiprex_cms_test DB_USER=root DB_PASSWORD= npx sequelize-cli db:migrate
# seed a few leads (incl. one with status 'contacted' before migrating, to prove the data migration);
DB_NAME=shiprex_cms_test DB_USER=root DB_PASSWORD= npx sequelize-cli db:migrate:undo:all   # down reverses
```
### Level 3 — Runtime (public API unchanged)
- `POST /api/v1/contact` (with key) → 201, same body; server log shows **no** Brevo contact upsert; an Email Log row for the ack/sales send exists; a `system` activity exists.
- Negative: missing key → 401; bad body → 422 `details[]`.
### Level 4 — Admin (the heart of this PRP)
- Assign owner → `assigned` + owner email logged. Log a call with a follow-up → `in_progress`, timeline entry, next-follow-up set. Add note. Set/clear follow-up. Convert → ticket created, lead `won`, link shown. Mark spam. Delete (as admin) cascades activities. List filters (overdue/owner/date) and the dashboard widget reflect reality.
### Level 5 — Contract
- Run the Postman **Contact / Email** folder; response matches the documented example; confirm transactional-only behavior.

---

## Final validation checklist
- [ ] App + models load; both migrations run clean on a scratch DB; `down` reverses (status data migration both ways).
- [ ] `contact_requests.status` is the new 7-value enum; `contacted→in_progress`, `closed→lost` migrated.
- [ ] `brevo_contact_id`/`brevo_synced_at` dropped; **no** `brevo.upsertContact` call remains; `marketing_consent=false` stored.
- [ ] `contact_activities` records every state change with actor + `occurred_at`; `last_activity_at` updated.
- [ ] Assignment sets owner, gentle-transitions to `assigned`, emails the owner (Email Log row).
- [ ] Call logging captures outcome + optional follow-up; transitions to `in_progress`.
- [ ] `next_follow_up_at` drives overdue/due-today in the list filter and the dashboard widget (SQL, indexed).
- [ ] Convert creates a **linked** ticket via `ticketService.create`, sets `won`, blocks double-convert.
- [ ] Public `POST /api/v1/contact` body & response unchanged; still ack + sales email.
- [ ] Delete (erasure) gated to admin/superadmin; cascades activities; confirmed in UI.
- [ ] Admin screens handle empty/loading/flash; agents can't see the Delete action.
- [ ] Postman notes updated; `.env.example` unchanged; each task committed separately.

---

## Rollback
Mostly additive at the service/admin layer — `git revert` the per-task commits.
**Schema:** `npx sequelize-cli db:migrate:undo` runs each migration's `down`
(drops `contact_activities`; re-contracts the status enum mapping
`in_progress→contacted`, `lost→closed`, `won→qualified` as a best-effort inverse;
re-adds the dropped `brevo_*` columns as nullable). **Irreversible data:** the
dropped `brevo_contact_id`/`brevo_synced_at` values and any **erased** leads cannot
be restored — note this before running in production. Converted tickets created
during the rollback window remain (intentionally).

---

## Anti-patterns to avoid
- ❌ Re-introducing a Brevo **contact** upsert (transactional email only).
- ❌ Changing the public `/api/v1/contact` request/response shape.
- ❌ Mutating lead state without appending a matching `contact_activities` entry.
- ❌ Forcing status backwards or overwriting a later status on assignment/call.
- ❌ Hand-building a ticket instead of `ticketService.create`; allowing double-convert.
- ❌ Comparing follow-up dates in JS over a full table instead of an indexed SQL query.
- ❌ Contracting the status enum without first migrating `contacted`/`closed` rows.
- ❌ Exposing any lead-management verb under `/api`, or letting agents delete (erasure).
- ❌ Blocking capture/assignment on an email send instead of `sendAndLog`.
- ❌ Reading `process.env` directly; skipping the per-task commit.
```
