|
|
# 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 1–6 + review fixes via the build swarm; 7–10 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 WS1–WS5 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@sims.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 1–50 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 1–25 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); PROFORMA→INVOICE 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; QT→PI 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 (1–2), 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.
|