You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/superpowers/specs/2026-07-17-quote-to-close-f...

299 lines
33 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# HQ Console — Growth Features Spec (Employee Foundation, Pipeline, Escalating Reminders, Module Roster, Convert/Supersede)
> **STATUS: DELIVERED 2026-07-17.** All ten phases of PART B landed the same day
> (phases 16 + review fixes via the build swarm; 710 in the second pass), verified
> at 293 tests / 57 files with a live boot smoke. Delivery record: D16 in
> `docs/06-DECISIONS.md`; current capabilities: `STATUS.md`.
*Final merged spec. Reconciles the combined WS1WS5 design against the simplicity, house-rules, and completeness critiques. Where critics conflicted I took the simplest option that breaks no locked decision and no hard rule. Every deviation is flagged inline.*
---
# PART A — THE SPEC
## 1. Overview & goals
Five capabilities, built on the existing HQ Console, in strict "reuse first / derive over store / least surface" spirit:
1. **Employee foundation (D-EMP, build step 1).** Promote `staff_user` into a first-class *Employee* entity (management UI, `owner|manager|staff` roles, ownership + role-gated visibility). Everything else depends on it.
2. **Pipeline chase-list (D-PIPE, option B).** One cross-client ranked list of enquiries/quotes — most-overdue on top, colour-coded, explicit *Next action*, `All | Mine | Overdue | Owner` filters. Stages derived, not stored.
3. **Escalating quote follow-up (D-REMIND).** A `sent` quote that isn't accepted nudges the owning employee (dashboard) and emails the client (Gmail, share link) at dated intervals; stops on accept/lost/convert.
4. **Module → client roster (D-ROSTER).** Pick a banking module → every client on it, what they pay, next renewal, plus *Notify all* / *Export CSV*.
5. **One-click convert + easy supersede (D-CONVERT).** One-click *Convert proforma → invoice & send*; trivially easy proforma cancel/recreate; invoices also get escalating reminder mails. Invoice immutability preserved — corrections are credit notes only.
Non-negotiable throughout: integer paise; GST only via `computeBill`; **dated** config rows; immutable issued documents; portable repos; append-only audit in-txn; `client.id` never re-keyed; no silent caps; strict TS + green tests before landing.
## 2. Assumptions, defaults, non-goals
**Defaulted decisions (user did not override — flagged):**
- **A1 — Module roster emphasis = balanced.** Support and revenue given equal weight in the roster panel (a client's pay + next renewal *and* a Notify-all/CSV impact action).
- **A2 — Employee screen = standard table** with Add / Edit / Deactivate and a role dropdown. No org chart, no per-permission matrix.
**Other flagged assumptions:**
- **A3 — Document owner = `created_by` for v1 (simplicity critic #4, accepted).** No `document.owner_id` column, no reassignment/handoff endpoint. A quote's owner *is* its creator; the pipeline "Mine/Owner" filter and quote-follow-up routing read `created_by`. This does **not** break D-EMP (every ownable thing still carries an owner). The reassignable `document.owner_id` + `PATCH /documents/:id/owner` are deferred until a handoff feature is actually requested.
- **A4 — `client.owner_id` is the one genuinely new owner column.** `client` has no creator field, so an enquiry (lead with no quote) is otherwise unroutable. Additive, nullable.
- **A5 — Notify-all message is owner-typed ad-hoc text** (subject + body entered in the panel), not dated config — it is a one-off human broadcast, not recurring automated config text, so rule 3 does not apply to it.
- **A6 — Roster is visible to any signed-in employee** (support view); **Notify-all is owner/manager only** (bulk client email is privileged).
- **A7 — Invoice-overdue anchor = `doc_date`.** There is no `due_date` column and `payload.terms` ("Net 30") is free text; v1 escalation counts days since issue, not since a parsed due date. Known limitation, surfaced in the reminder body wording.
**Non-goals / explicitly deferred:** support-desk/ticketing; HR (leave, payroll, attendance); upsell/expansion engine; WhatsApp or any non-email channel; a client self-service portal; kanban board (D-PIPE is a list, option B); reassignable document ownership/handoff (A3); parsed payment-term due dates (A7); true delete of a wrong unissued draft (pre-existing no-delete gap; superseded drafts are filtered via `ref_doc_id`).
## 3. Reused vs genuinely new
**Reused (no change or minor extension):** `staff_user` table (already carries id/email/display_name/role/active + scrypt hashes — it *is* the employee table); `@sims/auth` scrypt PIN + `createStaff`; `document` lifecycle (`prepareDraft`/`createDraft`/`issueDocument`/`markStatus`/`convertDocument`/`raiseCreditNote`/`cancelDocument`) and `ref_doc_id`; `@sims/billing-engine` `computeBill`; `document_event` (`kind='sent'` → age anchor); `client.status='lead'` + `client.notes` (= enquiry); the reminder engine (`scheduler.ts`, `upsertReminder`, `UNIQUE(rule_kind,subject_id,due_period)`, `listQueue`, `sendReminder`, `reminderContext`, `setReminderStatus`, `dismissReminder`); Gmail send path + `email_log` + bounce handling; `document_share` (non-revoked/non-expired reuse, `expires_at` semantics); `priceOn` + `client_module` (`kind`, `edition`, `next_renewal`); `@sims/ui` `DataTable`; existing `GET /documents`, `POST /documents/:id/status`, `POST /documents/:id/convert`, `POST /reminders/:id/send`, `GET /reminders/:id/preview`.
**Genuinely new:** `reminder_schedule` table (dated cadence + dated quote-follow-up message text); `client.owner_id` column; `quote_followup` `rule_kind`; `repos-employees.ts`; `repos-pipeline.ts` + `GET /pipeline`; `listClientsByModule` reverse query + roster endpoints; `convert-and-send` + `supersede` endpoints; the `Employees` and `Pipeline` pages + Modules roster panel + doc-view buttons + ClientDetail owner dropdown; two auth fixes.
## 4. Data-model changes
Each earns its place; migrations are additive or run through **one shared guarded rebuild helper** (see the SQLite note).
| Change | Kind | Justification (one line) |
|---|---|---|
| **`reminder_schedule`** `(id TEXT PK, rule_kind TEXT NOT NULL, effective_from TEXT NOT NULL, effective_to TEXT, day_offsets TEXT NOT NULL, subject TEXT, body TEXT)` | **New table** | The single **dated** home (rule 3 + locked D-REMIND) for reminder cadence *and* quote-follow-up message text; `day_offsets` = CSV like `'3,7,14'`; `subject`/`body` nullable (dated message text for `quote_followup`, code-constant fallback elsewhere). Serves both `quote_followup` and `invoice_overdue`. |
| **`client.owner_id TEXT`** (nullable → `staff_user.id`) | **New column, additive** | Account/enquiry owner so a lead with no quote (the derived "Enquiry" stage) is routable; `client` has no creator field, so it cannot be derived (A4). |
| **`staff_user.role`** — drop the `CHECK (role IN ('owner','staff'))` | **Rebuild-once** | Add `manager`; enum validated in the repo (`Role = owner\|manager\|staff`), matching how GSTIN/state are validated in code. |
| **`reminder.rule_kind`** — widen `CHECK` to add `'quote_followup'` | **Rebuild-once** | New sendable reminder kind for quote follow-up. |
| **Employee columns** | **None** | Grounding confirms `staff_user` already has `id`/`email`/`display_name`/`role`/`active` + scrypt hashes — *no additive employee columns are needed*. The only employee DDL is the `role` CHECK relaxation above. |
| **`document.owner_id`** | **Not added (A3)** | Deferred; `created_by` serves as owner for v1. |
**SQLite CHECK-migration note (one shared helper, used for both `staff_user` and `reminder`).** SQLite cannot `ALTER`/`DROP` a `CHECK` in place, and `migrate()` is additive-only, so each affected table is rebuilt once via the standard sequence, **wrapped in `db.transaction`** (crash-safe; F11) and **guarded** so it is idempotent and a no-op on fresh DBs (which are born correct from the `SCHEMA` constant):
```
rebuildTable(db, table, newDDL, guardToken):
sql := SELECT sql FROM sqlite_master WHERE type='table' AND name=table
if sql does NOT already satisfy guardToken: // e.g. staff_user still has "CHECK (role IN ('owner','staff'))"
db.transaction: // or reminder sql lacks 'quote_followup'
CREATE TABLE <table>__new ( …newDDL… );
INSERT INTO <table>__new (<explicit cols>) SELECT <explicit cols> FROM <table>; // explicit column list, never SELECT *
DROP TABLE <table>;
ALTER TABLE <table>__new RENAME TO <table>;
```
- `staff_user__new` omits the `role` CHECK (validate in repo); preserves PK + `email UNIQUE`.
- `reminder__new` keeps the `status`/`policy_applied` CHECKs, widens `rule_kind` to include `quote_followup`, and preserves `UNIQUE (rule_kind, subject_id, due_period)` verbatim (the idempotency key).
- No FK references either table, so no `foreign_key_check` dance.
- **Postgres (prod, D15):** each is a one-line `ALTER TABLE … DROP/ADD CONSTRAINT` in the prod migration set; `migrate()` remains the SQLite-only path.
`reminder_schedule` and `client.owner_id` are plain additive (`CREATE TABLE IF NOT EXISTS` in `SCHEMA`; `ALTER TABLE client ADD COLUMN owner_id TEXT` guarded by `PRAGMA table_info`, matching the existing additive-column pattern).
## 5. API endpoints
`[E]` existing/reused · `[N]` new. All list endpoints paginate with returned `total`/`page`/`pageSize` (rule 8).
| Method | Path | Gate | Repo / notes |
|---|---|---|---|
| `[N]` GET | `/employees` | `requireAuth` | `listEmployees` — any signed-in user needs the name/owner picker |
| `[N]` POST | `/employees` | `requireOwner` | `createEmployee` |
| `[N]` PATCH | `/employees/:id` | `requireOwner` | `updateEmployee` ({displayName?, role?}) |
| `[N]` POST | `/employees/:id/deactivate` | `requireOwner` | `deactivateEmployee` (+ purge sessions) |
| `[N]` POST | `/employees/:id/reactivate` | `requireOwner` | `reactivateEmployee` |
| `[N]` POST | `/employees/:id/password` | `requireOwner` | `setEmployeePassword` |
| `[N]` GET | `/pipeline` | `requireAuth`, role-gated in query | `listPipeline({filter,ownerId,viewerRole,viewerId,page,pageSize})` |
| `[N]` PATCH | `/clients/:id/owner` | `requireAuth`; owner/manager only (inline check) | set `client.owner_id`; audited |
| `[N]` GET | `/modules/:id/clients` | `requireAuth` | `listClientsByModule` |
| `[N]` GET | `/modules/:id/clients.csv` | `requireAuth` | streamed export, no silent cap |
| `[N]` POST | `/modules/:id/notify` | owner/manager (inline check) | bulk notify (A5); audit + email_log per recipient |
| `[N]` POST | `/documents/:id/convert-and-send` | `requireAuth` | orchestrates convert→issue→send |
| `[N]` POST | `/documents/:id/supersede` | `requireAuth` | `supersedeProforma` |
| `[E]` POST | `/documents/:id/status`, `/convert`; `/reminders/:id/send`, `/preview`; auth routes | — | reused unchanged by pipeline action buttons and the queue |
**`requireManager` middleware is NOT added** (simplicity critic #5 — it would be dead code; role gating lives inside queries and inline checks). Add it later if a route needs it.
## 6. Screens (ASCII sketches)
**(a) Employees — `/employees`, owner-only nav item** (beside *Document Template*):
```
Employees [ + Add employee ]
────────────────────────────────────────────────────────────────────────────
Name Email Role Active Actions
Asha Rao asha@tecnostac.com owner ● Edit · Reset pw
Vikram Shah vikram@… manager ● Edit · Reset pw · Deactivate
Priya Nair priya@… staff ○ Edit · Reset pw · Reactivate
────────────────────────────────────────────────────────────────────────────
3 employees (guards: cannot deactivate/demote last owner or yourself)
```
Add/Edit form: email (immutable on edit — it is the login id), name, role dropdown `owner|manager|staff`, initial password (create only). Password reset is a separate action (never logs the hash).
**(b) Pipeline chase-list — `/pipeline`, nav right after Dashboard** (D-PIPE option B):
```
Pipeline [ All ] [ Mine ] [ Overdue ] [ Owner ▾ ]
──────────────────────────────────────────────────────────────────────────────────
Client Amount Owner Stage Age Next action
🔴 Sahakari Bank ₹4,20,000 Vikram Quoted/Waiting 22d [Final nudge] ⋯
🔴 Janata CCS ₹1,80,000 Asha Quoted/Waiting 15d [Nudge] ⋯
🟠 Adarsh Credit ₹ 95,000 Priya Quoted/Waiting 5d [Chase] ⋯
🟢 Mahanagar Bank ₹2,10,000 Asha Quoted/Waiting 2d Waiting ⋯
⚪ Nagari Sah. (lead) — Vikram Enquiry — [Send quote] ⋯
🟢 Pragati CCS ₹ 60,000 Priya Won — [Convert→invoice] ⋯
──────────────────────────────────────────────────────────────────────────────────
Showing 150 of 137 Prev · Page 1/3 · Next (Lost hidden — filter to show)
```
One `DataTable`; row tone green/amber/red by age band; *Owner* dropdown hidden for staff; action buttons fire existing lifecycle endpoints then reload; pager honours `total`. Staff see only their own rows (server-forced).
**(c) Module → client roster panel (inside Modules page):**
```
Modules ▸ Core Banking
──────────────────────────────────────────────────────────────────────
Clients on this module [ Notify all ] [ Export CSV ]
Client Edition Pays / yr Next renewal
Sahakari Bank enterprise ₹1,20,000 2026-09-01
Janata CCS standard ₹ 60,000 2026-08-15
Adarsh Credit standard ₹ 60,000 2027-01-10
──────────────────────────────────────────────────────────────────────
42 clients · total ₹34,80,000/yr Prev · Page 1/1 · Next
```
*Notify all* opens a subject+body composer (A5) → bulk send with per-recipient audit; owner/manager only. *Export CSV* streams all rows (no cap).
**(d) Document view — convert+send & supersede (proforma only):**
```
PROFORMA PI/26-27-0007 status: sent Sahakari Bank ₹4,20,000
──────────────────────────────────────────────────────────────────────────────
[ Convert to invoice & send ] [ Supersede & recreate ] [ Download PDF ]
INVOICE INV/26-27-0031 status: sent (immutable)
──────────────────────────────────────────────────────────────────────────────
[ Raise credit note ] [ Cancel ] (only if unpaid & unallocated) [ Download PDF ]
```
Invoices show **no** convert/supersede — corrections are credit notes or cancel-if-unpaid only.
**(e) Where reminders surface — Dashboard queue** (existing component, now paginated):
```
Reminders queue [ Mine ] [ All ]
──────────────────────────────────────────────────────────────────────
Quote follow-up — chase client Sahakari Bank PI review [Send] [Dismiss]
Invoice overdue (d15) Janata CCS INV/…-31 [Send] [Dismiss]
──────────────────────────────────────────────────────────────────────
Showing 125 of 61 Prev · Next (staff: only your queue)
```
ClientDetail gains one **Account owner ▾** dropdown writing `client.owner_id` (critic B — else leads are unroutable).
## 7. Escalating quote follow-up reminder
**Rule kind:** new sendable `quote_followup`. Its queued row *is* the owner nudge; its send *is* the client email.
**Config (rule 3 + locked D-REMIND — both dated):** `resolveSchedule(db,'quote_followup',today)` reads the active `reminder_schedule` row (`effective_from <= today AND (effective_to IS NULL OR effective_to > today)`), yielding `day_offsets` (default `3,7,14`) and the dated `subject`/`body`. Code-constant fallback body ships it working:
> *Subject:* `Following up on our quotation {ref} ({companyName})`
> *Body:* `Dear {clientName}, we wanted to check whether you had a chance to review our quotation {ref}. You can view it any time here: {shareUrl}. We'd be glad to answer any questions. Warm regards, {companyName}.`
`{ref}` degrades gracefully when the quote has no `docNo` (quotes reach `sent` without issuance — F10): use client-facing date/reference, never render "quotation null". Send policy (`auto|manual`, default **manual**) is a flat operational `setting` (`quote.followup.policy`) — a toggle, not date-varying config.
**Scan** (new block in `runDailyScan`, injected `today`/`now`, deterministic):
- Select quotes `doc_type='QUOTATION' AND status='sent'`; age anchor = `MIN(document_event.at_wall WHERE kind='sent')` (F15 — first-sent).
- For each crossed offset `d` (loop breaks once `age < d`, ascending list → catch-up-safe): `upsertReminder({ruleKind:'quote_followup', subjectId: quoteId, duePeriod: 'd'+d, docId: quoteId, clientId, policyApplied})`.
- **Idempotency key** = `UNIQUE(rule_kind, subject_id='quote id', due_period='d3'|'d7'|'d14')` → each interval fires at most once per quote, forever. Threshold-day bucket (not index) so reordering offsets never refires consumed buckets.
- **Catch-up burst guard (F7):** on the *first* scan after deploy (or for any quote already past the last offset), enqueue only the **single highest crossed milestone**, not the whole ladder — prevents "×3 emails at once". Default policy `manual` keeps the burst review-gated regardless.
**Owner nudge vs client email:**
- **Owner nudge** = the queued row, surfaced by `listQueue` with a `quote_followup` label. Routing to the owner is **derived** (A3): `reminder.doc_id → document.created_by`. No `owner_id` on `reminder`.
- **Client email** = `sendReminder(...)` — same Gmail path as the manual Send button. **Share-link handling (fixes rule-6 bug F5):** `reminderContext` (shared with the preview route, which must write nothing) only **resolves an existing** live share (non-revoked, non-expired) — read-only. **Minting** happens **only** inside the real send path, never in the context builder. Confirm the public `/share` route renders a `QUOTATION` (critic A). Mint with an **expiry covering the escalation window (~60 days), not never-expiring** (F12), reuse an existing live share rather than duplicating, and it is audited by `mintShare`.
- **Auto mode** drains its send queue strictly **after** the scan transaction commits, at-most-once via the unique key.
**Stop conditions:** the scan reads only `status='sent'`, so `accepted`/`lost` (via `markStatus`), `invoiced` (via `convertDocument`), and `cancelled` all leave the set — no further reminders, no extra stop logic. **STOP cleanup:** when `markStatus` sets `accepted`/`lost` and when `convertDocument` produces the invoice, dismiss still-open (`queued`/`failed`) `quote_followup` rows for that quote **in the same transaction** — looping `setReminderStatus` per row so each dismissal is **audited** (F14), not a silent bulk UPDATE (rule 6).
## 8. Proforma → invoice convert+send; easy cancel/recreate
**One-click `POST /documents/:id/convert-and-send`:**
1. Load source; guard `docType==='PROFORMA'`, `status !== 'cancelled'`, **and no existing non-cancelled INVOICE child** (F4 — see guard below); pre-check Gmail alive (fail before mutating).
2. `db.transaction(() => { d = convertDocument(db,uid,id,'INVOICE'); return issueDocument(db,uid,d.id) })` — convert carries the proforma's lines forward, links `ref_doc_id`, flips proforma → `invoiced`; issue assigns `INV/FY-NNNN`. better-sqlite3 savepoints make it all-or-nothing.
3. **After commit** (never inside the txn): render PDF, send via Gmail, which marks the invoice `draft→sent` and logs `email_log`. A failed send never rolls back an issued invoice.
**Rule 2 fix (F3) — recompute on convert.** PROFORMA→INVOICE must build the invoice draft through the **same `computeBill` path** a normal draft save uses, recomputing the carried lines on the **invoice's own `doc_date`** — mirroring `createCreditNote`. This guarantees a tax invoice uses the rate that is law on its issue date even if the proforma sat across a dated `tax_class` boundary. (QT→PI may stay copy-only — both are non-legal.) The server trusts no client numbers.
**Rule 4 fix (F4) — no duplicate legal invoices.** `convertDocument` currently only rejects `cancelled`. Add: reject when a non-cancelled forward child of the target type already exists (`SELECT 1 FROM document WHERE ref_doc_id=:src AND doc_type=:to AND status!='cancelled'`) and/or when `src.status='invoiced'`. `convert-and-send` re-checks the same condition before mutating so double-click/retry is idempotent — one sale, one invoice number, one client email.
**Easy cancel / recreate — precise matrix (rule 4 firewall):**
| Document | Cancel | Recreate | Correct after issue |
|---|---|---|---|
| PROFORMA (draft) | n/a (no number) | new PI draft carrying lines | re-draft |
| PROFORMA (issued, unallocated) | ✅ `cancelDocument` (number stays consumed) | ✅ **supersede** = cancel + new PI draft, `ref_doc_id`-linked | supersede |
| INVOICE (issued, unpaid, unallocated) | ✅ `cancelDocument` (number consumed) | ❌ never | **credit note** |
| INVOICE (allocated/paid) | ❌ | ❌ | **credit note** only |
**`POST /documents/:id/supersede` + `supersedeProforma(db,uid,id)`:** guard `docType==='PROFORMA'`**reject INVOICE loudly** ("Invoices are immutable — cancel if unpaid, or raise a credit note"). In one `db.transaction`: if issued, `cancelDocument` (its unpaid/unallocated guard always passes for proformas; number stays consumed), then insert a new PROFORMA draft carrying `payload` verbatim via `insertDocRow` with `ref_doc_id →` the superseded doc, plus a `superseded` event. `convertDocument` is not reused (its CHAIN guard is forward-only). This keeps D-CONVERT's "trivially easy" cancel/recreate while honouring immutability.
**Invoice reminder mails (reuse `invoice_overdue`, no CHECK change):** switch the scan's `due_period` from the monthly bucket to **day-past-due milestones** `dN` resolved from `reminder_schedule('invoice_overdue')` (default `7,15,30`), `upsertReminder` firing each milestone once; populate `ctx.daysOverdue = today doc_date` (A7 limitation) so the existing "N days past due" template text renders; auto-stop is already handled (scan skips `paid|cancelled|lost` and `outstanding<=0`). Apply the same **highest-milestone-only backfill** (F7) on cutover so aged invoices already reminded under the month scheme don't double-fire; note the `due_period` scheme change in `reminders.test.ts`. A freshly issued invoice from convert-and-send enters this scan automatically.
## 9. Pipeline stage derivation (minimal storage)
Stage is **never stored** — computed per row from `client.status` + the client's latest `QUOTATION` status:
| Stage | Derivation |
|---|---|
| **Enquiry** | `client.status='lead'` AND client has no `QUOTATION` |
| **New Project** | latest quotation `status='draft'` |
| **Quoted / Waiting** | latest quotation `status='sent'` (the chase target) |
| **Won** | latest quotation `status IN ('accepted','invoiced')` |
| **Lost** | latest quotation `status='lost'` OR `client.status='lost'` |
Age = `MIN(document_event.at_wall WHERE kind='sent')` (F15, no `sent_at` column). Owner = `document.created_by` for quote rows (A3), `client.owner_id` for bare-lead enquiries (A4). **Next action** reads the same `resolveSchedule(db,'quote_followup',today).day_offsets` the reminder engine uses (single source — no hardcoded 3/7/14):
| Row state | Next action |
|---|---|
| Enquiry / draft quotation | Send quote |
| sent, age < offset | Waiting |
| sent, age offset / / | Chase / Nudge / Final nudge |
| accepted | Convert to invoice |
Sort: descending age for actionable rows (oldest unanswered on top); Waiting/Won sink; Lost hidden by default (filterable). Colour band by age: green `<offset₁`, amber `offset₁₃`, red `≥offset₃`. Server-side `LIMIT/OFFSET` + `total` (rule 8). **New storage across §8§9: zero tables, zero columns beyond `client.owner_id`.**
## 10. Module → client roster
**Reverse query:** `listClientsByModule(db, moduleId, {page,pageSize})` `client_module JOIN client WHERE module_id=? ORDER BY client.name`, paginated, `total` returned (rule 8). State whether `active=0` links are included (default: exclude, and say so no silent drop).
**Per-row derivation (A1 balanced):** price via existing `priceOn(db, moduleId, client_module.kind, client_module.edition, today)`; next renewal from `client_module.next_renewal`. Footer sums yearly revenue.
**`GET /modules/:id/clients`** (roster panel) and **`GET /modules/:id/clients.csv`** (streamed export, no cap).
**`POST /modules/:id/notify` (owner/manager, A6):** owner-typed subject+body (A5); resolves each client's primary contact email, sends via the existing Gmail path, writes **`email_log` + one audit row per recipient** (rule 6), internally paginated + rate-limited, returns sent/failed counts and warns rather than truncating (rule 8). No new table, no dated template (ad-hoc human broadcast).
## 11. Employee entity, roles, ownership routing
- **`staff_user` is the employee table** not renamed (churns `session.staff_id`, `interaction.staff_id`, audit `entity='staff_user'` for zero gain). "Employee" in UI/API/repo names only.
- **`repos-employees.ts`** (plain functions, DB first arg, audited in-txn, `entity='staff_user'`, never returns password columns): `listEmployees`, `getEmployee`, `createEmployee` (folds `createStaff`; validates `role∈{owner,manager,staff}` + `password.length≥8`), `updateEmployee`, `setEmployeePassword` (reuses `hashPin`, audits `reset_password`, never logs the hash), `deactivateEmployee`, `reactivateEmployee`. **Guards:** cannot deactivate/demote the last active owner; cannot deactivate yourself.
- **Roles:** `owner` (full + employee management), `manager` (sees everyone in pipeline/queue/roster; may Notify-all), `staff` (server-forced to own rows).
- **Ownership routing (A3/A4):** quote/enquiry owner = `document.created_by` (default) or `client.owner_id` (bare lead); reminder owner derived via `doc_id → document.created_by`. `client.owner_id` set on ClientDetail (owner/manager) needed so the ~300 APEX-imported / unassigned leads (owner-`null`) can reach a staffer's queue; owner/manager unfiltered view is the safety net for `created_by='system'` recurring invoices and still-unassigned leads (F13).
- **Role gate (server-side, from `res.locals.staff.{id,role}`, never trusted from client):** `staff` query forces `created_by = me` (quotes) / `client.owner_id = me` (leads) and ignores any widening param; `owner|manager` no filter by default, optional `?owner=` narrows.
- **Auth fixes (headline capability of the employee screen):** (1) add `AND u.active = 1` to the `verifySession` join a deactivated employee with an unexpired 14-day token is currently still authenticated; (2) `deactivateEmployee` does `DELETE FROM session WHERE staff_id=?` in the same txn. Deactivation takes effect immediately.
- **First boot:** `seedIfEmpty` already creates the owner via `createStaff`→`createEmployee`; that UUIDv7 row *is* the founding employee (appears in the table automatically, satisfies the last-owner guard, can create the first manager/staff). No promote migration.
---
# PART B — THE ROADMAP
TDD-friendly, employee-foundation first, each phase small and independently shippable. **`npm run typecheck` + `npm test` green before every landing.** Money math changes require test changes (locked).
**Phase 1 — Employee foundation (D-EMP core).** `staff_user.role` CHECK relaxed via the shared `rebuildTable` helper (txn-wrapped, guarded); `repos-employees.ts` (7 fns, folds `createStaff`, last-owner + self-deactivate guards); auth fixes (`verifySession` active-check + session purge on deactivate).
*Accept:* create manager/staff; role validated in repo (bad role rejected); deactivated user's session dies immediately (`verifySession` denies + rows purged); last active owner cannot be demoted/deactivated; rebuild is a no-op on a fresh DB and idempotent on re-run.
**Phase 2 — Employee management surface.** Six `/employees` routes (mutations owner-only); `Employees` page + owner-only nav + `api.ts` client.
*Accept:* owner CRUD round-trips; non-owner gets 403 on mutations; `GET /employees` works for any signed-in user (picker); count shown, no silent truncate.
**Phase 3 — Owner plumbing.** `client.owner_id` additive column; `PATCH /clients/:id/owner` (owner/manager, audited); ClientDetail **Account owner** dropdown; shared server-side role-gate helper (`staff` self).
*Accept:* setting an owner writes an audit row; staff request cannot widen scope; a lead gains a routable owner.
**Phase 4 — Dated reminder schedule (rule 3 / D-REMIND home).** `reminder_schedule` table + `resolveSchedule(db,ruleKind,today)` resolver; seed defaults (`quote_followup` 3/7/14, `invoice_overdue` 7/15/30) in `seed.ts` with code fallback.
*Accept:* resolver returns the row whose `effective_from<=today<effective_to|null`; a new dated row changes cadence without a code edit; missing-row falls back to the code default.
**Phase 5 — Pipeline chase-list (D-PIPE option B).** `repos-pipeline.ts` `listPipeline` (union of quote-bearing clients + bare leads, derived stage/age/next-action/tone, role-gated, paginated); `GET /pipeline`; `Pipeline` page + nav + `api.ts`. Next-action bands read Phase-4 resolver.
*Accept:* a bare lead with no quote appears (Enquiry); stages derive correctly from status; staff see only their rows; oldest-overdue sorts top; pager honours `total`; action buttons hit existing lifecycle endpoints and reload.
**Phase 6 — Escalating quote follow-up (D-REMIND).** `quote_followup` `rule_kind` via shared `rebuildTable`; TS union extended (forces exhaustive `switch`); scan block (highest-milestone backfill guard); `reminderContext` resolves (never mints) share + `shareUrl`, dated subject/body with graceful null-`docNo`; mint only in send path with ~60-day expiry, reusing live shares; STOP cleanup dismissing open rows per-row (audited) in `markStatus`/`convertDocument`; `listQueue` paginated + owner-filtered (`doc_id created_by`).
*Accept:* each interval fires at most once per quote (unique key); catch-up fires only the highest crossed milestone; **preview writes nothing** (no share minted); accept/lost/convert dismisses open nudges (audited); auto-send drains after commit at-most-once; queue paginates.
**Phase 7 — Convert + send (D-CONVERT).** Re-convert guard in `convertDocument` (F4); PROFORMAINVOICE recompute via `computeBill` on invoice date (F3); `POST /documents/:id/convert-and-send` (txn convert+issue, send after commit, Gmail pre-check, double-submit idempotent); doc-view button.
*Accept:* double-click/retry yields exactly one INVOICE + one number + one email; a rate-boundary crossing produces date-correct GST (server recompute); a failed send leaves the issued invoice intact; QTPI unaffected.
**Phase 8 — Proforma supersede (D-CONVERT).** `supersedeProforma` + `POST /documents/:id/supersede`; doc-view button (proforma only).
*Accept:* issued proforma cancels (number stays consumed) and a new `ref_doc_id`-linked PI draft carries the lines; INVOICE supersede is hard-rejected credit-note guidance; all steps audited in one txn.
**Phase 9 — Invoice reminder escalation.** Scan `invoice_overdue` `due_period` `dN` milestones from Phase-4 resolver; `ctx.daysOverdue` populated; highest-milestone backfill on cutover; update `reminders.test.ts`.
*Accept:* d7/d15/d30 fire once each; aged invoices don't double-fire across the month→`dN` cutover; template renders "N days past due"; auto-stops on paid/cancelled/settled.
**Phase 10 — Module → client roster (D-ROSTER).** `listClientsByModule`; `GET /modules/:id/clients` + `.csv`; `POST /modules/:id/notify` (owner/manager, audit+email_log per recipient); Modules roster panel.
*Accept:* reverse query paginates with `total` and states `active=0` handling; per-row price via `priceOn` + renewal from `client_module`; Notify-all writes one audit + email_log per recipient and never silently caps; CSV exports all rows.
**Total: 10 phases** employee foundation (12), owner plumbing (3), dated config (4), then one phase per feature (5 pipeline, 6 quote reminders, 7 convert, 8 supersede, 9 invoice reminders, 10 roster), each independently shippable with typecheck + tests green.