feat(hq-web): standalone Reminders page (go-live WS-C, phase 3)

- /reminders page: Queued/Sent/Failed/Dismissed chips mapping to ?status=,
  managerial-only Mine/All toggle via ?owner= (staff are server-forced to
  their own rows), kind/client/doc/due/status table with Send/Preview/
  Dismiss on open rows, honest server-driven pager (rule 8).
- Extract the Dashboard queue actions into shared
  components/ReminderQueue.tsx (QueueActions, RULE_LABEL, reminderTone)
  with a re-entry guard on Send/Dismiss; Dashboard queue card links
  "View all" to /reminders.
- api.ts: getReminders now takes { status, owner, page, pageSize } and
  returns the full QueueItem rows + page metadata; login stores the
  staff id (hq.id) so Mine can narrow by owner.
- Nav item under WORK + route; no backend changes. A contract test pins
  the sent/dismissed chips, ?owner= narrowing and cross-chip staff scope
  at HTTP level (apps/hq/test/reminders-page.test.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 5d81550e34
commit e612c5cf81

@ -35,7 +35,7 @@ export function Layout() {
// their own queue, and pagination cannot under-count.
useEffect(() => {
if (!hasSession()) return
Promise.all([getReminders('queued'), getReminders('failed')])
Promise.all([getReminders({ status: 'queued' }), getReminders({ status: 'failed' })])
.then(([q, f]) => setQueueCounts({ queued: q.total, failed: f.total }))
.catch(() => { /* badge is best-effort */ })
}, [location.pathname])

@ -3,6 +3,7 @@
const TOKEN_KEY = 'hq.token'
const NAME_KEY = 'hq.name'
const ROLE_KEY = 'hq.role'
const ID_KEY = 'hq.id'
export function hasSession(): boolean {
return (localStorage.getItem(TOKEN_KEY) ?? '') !== ''
@ -12,6 +13,11 @@ export function displayName(): string {
return localStorage.getItem(NAME_KEY) ?? ''
}
/** The signed-in employee's id ('' for sessions minted before it was stored). */
export function staffId(): string {
return localStorage.getItem(ID_KEY) ?? ''
}
/** 'owner' | 'manager' | 'staff' — pages gate write actions on this. */
export function role(): string {
return localStorage.getItem(ROLE_KEY) ?? 'staff'
@ -27,6 +33,7 @@ export function clearSession(): void {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(NAME_KEY)
localStorage.removeItem(ROLE_KEY)
localStorage.removeItem(ID_KEY)
}
/**
@ -62,6 +69,7 @@ export async function login(email: string, password: string): Promise<{ displayN
localStorage.setItem(TOKEN_KEY, out.token)
localStorage.setItem(NAME_KEY, out.staff.displayName)
localStorage.setItem(ROLE_KEY, out.staff.role)
localStorage.setItem(ID_KEY, out.staff.id)
return { displayName: out.staff.displayName, role: out.staff.role }
}
@ -338,6 +346,11 @@ export interface Reminder {
error: string | null; createdAt: string; sentAt: string | null
}
/** One queue row: the reminder + server-derived display fields (client name, doc no, owner, label). */
export type QueueItem = Reminder & {
clientName: string; docNo: string | null; ownerId: string | null; label: string
}
export interface DashboardView {
today: string
overdue: { docId: string; docNo: string | null; clientId: string; clientName: string; outstandingPaise: number; daysOverdue: number }[]
@ -345,7 +358,7 @@ export interface DashboardView {
renewalsThisMonth: { clientModuleId: string; clientId: string; clientName: string; nextRenewal: string }[]
followUpsToday: { id: string; clientId: string; clientName: string; onDate: string; followUpOn: string; notes: string }[]
recentPayments: { id: string; clientId: string; clientName: string; receivedOn: string; amountPaise: number; mode: string }[]
queue: (Reminder & { clientName: string; docNo: string | null; ownerId: string | null; label: string })[]
queue: QueueItem[]
/** Honest queue size — `queue` is the first page only. */
queueTotal: number
totals: { overduePaise: number; queued: number; failed: number }
@ -355,10 +368,22 @@ export interface DashboardView {
export const getDashboard = (): Promise<DashboardView> =>
apiFetch<{ view: DashboardView }>('/dashboard').then((r) => r.view)
/** Viewer-scoped, paginated queue slice + honest total (staff see only their rows). */
export const getReminders = (status?: ReminderStatus): Promise<{ reminders: Reminder[]; total: number }> =>
apiFetch<{ reminders: Reminder[]; total: number }>(`/reminders${status !== undefined ? `?status=${status}` : ''}`)
.then((r) => ({ reminders: r.reminders, total: r.total }))
export interface RemindersPage { reminders: QueueItem[]; total: number; page: number; pageSize: number }
/**
* Viewer-scoped, paginated queue slice + honest total (staff are server-forced to their
* own rows). No `status` = the working set (queued|failed); `owner` narrows managerial only.
*/
export const getReminders = (
opts: { status?: ReminderStatus; owner?: string; page?: number; pageSize?: number } = {},
): Promise<RemindersPage> => {
const p = new URLSearchParams()
if (opts.status !== undefined) p.set('status', opts.status)
if (opts.owner !== undefined && opts.owner !== '') p.set('owner', opts.owner)
if (opts.page !== undefined) p.set('page', String(opts.page))
if (opts.pageSize !== undefined) p.set('pageSize', String(opts.pageSize))
const q = p.toString()
return apiFetch<RemindersPage>(`/reminders${q === '' ? '' : `?${q}`}`)
}
export const sendReminder = (id: string): Promise<Reminder> =>
apiFetch<{ reminder: Reminder }>(`/reminders/${id}/send`, { method: 'POST', body: '{}' }).then((r) => r.reminder)
export const dismissReminder = (id: string): Promise<Reminder> =>

@ -0,0 +1,67 @@
import { useState } from 'react'
import { Button } from '@sims/ui'
import { dismissReminder, getReminderPreview, sendReminder, type Reminder } from '../api'
/** Fallback kind labels for queue rows whose server `label` is empty. */
export const RULE_LABEL: Record<string, string> = {
invoice_overdue: 'Overdue invoice', renewal_due: 'Renewal due', amc_expiring: 'AMC expiring',
follow_up: 'Follow-up', recurring_generated: 'Recurring invoice', email_bounced: 'Email bounced',
quote_followup: 'Quote follow-up',
}
/** Rule kinds with a client-facing email; the rest are internal work items (dismiss only). */
export const SENDABLE = new Set(['invoice_overdue', 'renewal_due', 'amc_expiring', 'recurring_generated', 'quote_followup'])
/** Badge tone for a reminder status: failed red, sent green, dismissed neutral, queued amber. */
export const reminderTone = (status: string): 'ok' | 'warn' | 'err' | undefined =>
status === 'failed' ? 'err' : status === 'sent' ? 'ok' : status === 'dismissed' ? undefined : 'warn'
/**
* Send / Preview / Dismiss for one queue row shared by the Dashboard queue card and
* the Reminders page (WS-C: one set of actions, two views). Reloads via `onDone` even
* on failure: the server has already parked the reminder as 'failed' with its error,
* and the row must flip live.
*/
export function QueueActions(props: { rem: Reminder; onDone: () => void; onError: (m: string) => void }) {
const [busy, setBusy] = useState(false)
const [mail, setMail] = useState<{ subject: string; body: string } | undefined>()
const [loading, setLoading] = useState(false)
const run = (start: () => Promise<unknown>) => {
if (busy) return // Button has no disabled prop — guard re-entry here
setBusy(true); props.onError('')
start()
.catch((e: Error) => props.onError(e.message))
.then(props.onDone)
.finally(() => setBusy(false))
}
const preview = () => {
if (mail !== undefined) { setMail(undefined); return } // toggle closed
if (loading) return
setLoading(true); props.onError('')
getReminderPreview(props.rem.id)
.then(setMail)
.catch((e: Error) => props.onError(e.message))
.finally(() => setLoading(false))
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 6 }}>
{SENDABLE.has(props.rem.ruleKind) && (
<>
<Button onClick={preview}>{loading ? '…' : mail !== undefined ? 'Hide' : 'Preview'}</Button>
<Button tone="primary" onClick={() => run(() => sendReminder(props.rem.id))}>{busy ? '…' : 'Send'}</Button>
</>
)}
<Button onClick={() => run(() => dismissReminder(props.rem.id))}>Dismiss</Button>
</div>
{mail !== undefined && (
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '6px 8px', background: 'var(--bg-raised)', maxWidth: 420 }}>
<div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Subject</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{mail.subject}</div>
<div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Body</div>
<pre style={{ whiteSpace: 'pre-wrap', margin: 0, fontFamily: 'inherit', fontSize: 13 }}>{mail.body}</pre>
</div>
)}
</div>
)
}

@ -18,6 +18,7 @@ import { DocumentView } from './pages/DocumentView'
import { Employees } from './pages/Employees'
import { Pipeline } from './pages/Pipeline'
import { Documents } from './pages/Documents'
import { Reminders } from './pages/Reminders'
function App() {
return (
@ -27,6 +28,7 @@ function App() {
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/pipeline" element={<Pipeline />} />
<Route path="/reminders" element={<Reminders />} />
<Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} />
<Route path="/modules" element={<Modules />} />

@ -1,5 +1,5 @@
import {
BarChart3, Boxes, FilePlus2, Files, FileText, Filter, LayoutDashboard, UserCog, Users,
BarChart3, BellRing, Boxes, FilePlus2, Files, FileText, Filter, LayoutDashboard, UserCog, Users,
type LucideIcon,
} from 'lucide-react'
@ -17,6 +17,7 @@ export const NAV_GROUPS: NavGroup[] = [
items: [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/pipeline', label: 'Pipeline', icon: Filter },
{ to: '/reminders', label: 'Reminders', icon: BellRing },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/documents', label: 'Documents', icon: Files, end: true },
{ to: '/documents/new', label: 'New Document', icon: FilePlus2 },

@ -1,21 +1,13 @@
import { useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, ErrorState, Notice, PageHeader, Skeleton, StatCard, Stats } from '@sims/ui'
import { getDashboard, sendReminder, dismissReminder, getReminderPreview, type Reminder } from '../api'
import { Badge, DataTable, EmptyState, ErrorState, Notice, PageHeader, Skeleton, StatCard, Stats } from '@sims/ui'
import { getDashboard } from '../api'
import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue'
import { useData } from './Clients'
const inr = (p: number) => formatINR(p)
const RULE_LABEL: Record<string, string> = {
invoice_overdue: 'Overdue invoice', renewal_due: 'Renewal due', amc_expiring: 'AMC expiring',
follow_up: 'Follow-up', recurring_generated: 'Recurring invoice', email_bounced: 'Email bounced',
quote_followup: 'Quote follow-up',
}
const SENDABLE = new Set(['invoice_overdue', 'renewal_due', 'amc_expiring', 'recurring_generated', 'quote_followup'])
const toneFor = (status: string): 'ok' | 'warn' | 'err' =>
status === 'failed' ? 'err' : status === 'sent' ? 'ok' : 'warn'
/** Dashboard home — today's money + the manual reminder queue (spec §3). */
export function Dashboard() {
const nav = useNavigate()
@ -37,7 +29,10 @@ export function Dashboard() {
<StatCard label="Follow-ups today" value={String(v.followUpsToday.length)} />
</Stats>
<h3>Reminder queue</h3>
<h3 style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
Reminder queue
<Link to="/reminders" style={{ fontSize: 13, fontWeight: 500 }}>View all </Link>
</h3>
{v.queue.length === 0 ? <EmptyState>Nothing waiting all clear.</EmptyState> : (
<DataTable
columns={[
@ -53,7 +48,7 @@ export function Dashboard() {
kind: rem.label !== '' ? rem.label : RULE_LABEL[rem.ruleKind] ?? rem.ruleKind,
client: rem.clientName,
ref: rem.docNo ?? rem.duePeriod,
status: <Badge tone={toneFor(rem.status)}>{rem.status}</Badge>,
status: <Badge tone={reminderTone(rem.status)}>{rem.status}</Badge>,
error: rem.error ?? '—',
act: <QueueActions rem={rem} onDone={dash.reload} onError={setErr} />,
}))}
@ -62,7 +57,7 @@ export function Dashboard() {
{v.queueTotal > v.queue.length && (
// No silent caps (rule 8): the table is the first page only — say so.
<p style={{ marginTop: 6, fontSize: 13, color: 'var(--text-muted, #666)' }}>
Showing {v.queue.length} of {v.queueTotal} handle these to surface the rest.
Showing {v.queue.length} of {v.queueTotal} <Link to="/reminders">see the full queue</Link>.
</p>
)}
@ -124,45 +119,3 @@ function Section<T>(props: {
)
}
function QueueActions(props: { rem: Reminder & { docNo: string | null }; onDone: () => void; onError: (m: string) => void }) {
const [busy, setBusy] = useState(false)
const [mail, setMail] = useState<{ subject: string; body: string } | undefined>()
const [loading, setLoading] = useState(false)
const run = (p: Promise<unknown>) => {
setBusy(true); props.onError('')
p.catch((e: Error) => props.onError(e.message))
// Reload even on failure: the server has already parked the reminder as
// 'failed' with its error, and the row must flip live.
.then(props.onDone)
.finally(() => setBusy(false))
}
const preview = () => {
if (mail !== undefined) { setMail(undefined); return } // toggle closed
setLoading(true); props.onError('')
getReminderPreview(props.rem.id)
.then(setMail)
.catch((e: Error) => props.onError(e.message))
.finally(() => setLoading(false))
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 6 }}>
{SENDABLE.has(props.rem.ruleKind) && (
<>
<Button onClick={preview}>{loading ? '…' : mail !== undefined ? 'Hide' : 'Preview'}</Button>
<Button tone="primary" onClick={() => run(sendReminder(props.rem.id))}>{busy ? '…' : 'Send'}</Button>
</>
)}
<Button onClick={() => run(dismissReminder(props.rem.id))}>Dismiss</Button>
</div>
{mail !== undefined && (
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '6px 8px', background: 'var(--bg-raised)', maxWidth: 420 }}>
<div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Subject</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{mail.subject}</div>
<div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Body</div>
<pre style={{ whiteSpace: 'pre-wrap', margin: 0, fontFamily: 'inherit', fontSize: 13 }}>{mail.body}</pre>
</div>
)}
</div>
)
}

@ -0,0 +1,120 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Badge, DataTable, EmptyState, ErrorState, FilterChips, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui'
import { getReminders, isManagerial, staffId, type ReminderStatus } from '../api'
import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue'
import { useData } from './Clients'
const PAGE_SIZE = 50
const STATUS_CHIPS: { key: ReminderStatus; label: string }[] = [
{ key: 'queued', label: 'Queued' }, { key: 'sent', label: 'Sent' },
{ key: 'failed', label: 'Failed' }, { key: 'dismissed', label: 'Dismissed' },
]
/**
* Reminders page (go-live cluster WS-C): the full queue the dashboard card is a
* slice of. Status chips map onto `?status=`; the Mine/All toggle (`?owner=`) only
* renders for owner/manager staff are server-forced to their own rows anyway.
* Server-paginated with an honest total (rule 8).
*/
export function Reminders() {
const [status, setStatus] = useState<ReminderStatus>('queued')
const [scope, setScope] = useState<'all' | 'mine'>('all')
const [page, setPage] = useState(1)
const [err, setErr] = useState('')
const me = staffId()
// Mine narrows via ?owner=<my id>; hidden for pre-existing sessions that never stored the id.
const showScope = isManagerial() && me !== ''
const list = useData(
() => getReminders({
status,
...(showScope && scope === 'mine' ? { owner: me } : {}),
page, pageSize: PAGE_SIZE,
}),
[status, scope, page],
)
const data = list.data
const rows = data?.reminders
const totalPages = data !== undefined ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1
const from = data !== undefined && data.total > 0 ? (data.page - 1) * data.pageSize + 1 : 0
const to = data !== undefined ? Math.min(data.page * data.pageSize, data.total) : 0
return (
<div className="wf-page">
<PageHeader
title="Reminders"
desc="Every reminder the scanner has raised — send, preview or dismiss the open ones; sent and dismissed stay on record."
/>
<Toolbar>
<FilterChips
chips={STATUS_CHIPS}
active={status}
onChange={(k) => { setStatus(k as ReminderStatus); setPage(1) }}
/>
{showScope && (
<FilterChips
chips={[{ key: 'all', label: 'All' }, { key: 'mine', label: 'Mine' }]}
active={scope}
onChange={(k) => { setScope(k as 'all' | 'mine'); setPage(1) }}
/>
)}
<span style={{ flex: 1 }} />
{data !== undefined && <Badge>{data.total} total</Badge>}
</Toolbar>
{err !== '' && <Notice tone="err">{err}</Notice>}
{list.error !== undefined && <ErrorState message={list.error} onRetry={list.reload} />}
{list.error !== undefined ? null
: rows === undefined ? <Skeleton rows={6} />
: rows.length === 0 ? <EmptyState>No {status} reminders{scope === 'mine' ? ' of yours' : ''}.</EmptyState> : (
<>
<DataTable
columns={[
{ key: 'kind', label: 'Kind' },
{ key: 'client', label: 'Client' },
{ key: 'doc', label: 'Document', mono: true },
{ key: 'due', label: 'Due' },
{ key: 'status', label: 'Status' },
{ key: 'error', label: 'Error' },
{ key: 'act', label: '' },
]}
rowTone={(i) => (rows[i]!.status === 'failed' ? 'err' : undefined)}
rows={rows.map((rem) => ({
kind: rem.label !== '' ? rem.label : RULE_LABEL[rem.ruleKind] ?? rem.ruleKind,
client: <Link to={`/clients/${rem.clientId}`}>{rem.clientName}</Link>,
doc: rem.docId !== null
? <Link to={`/documents/${rem.docId}`}>{rem.docNo ?? '— draft —'}</Link>
: '—',
due: rem.duePeriod,
status: <Badge tone={reminderTone(rem.status)}>{rem.status}</Badge>,
error: rem.error ?? '—',
// Sent/dismissed rows are history — actions only while there is work to do.
act: rem.status === 'queued' || rem.status === 'failed'
? <QueueActions rem={rem} onDone={list.reload} onError={setErr} />
: null,
}))}
/>
<Toolbar>
<Badge>Showing {from}{to} of {data!.total}</Badge>
<span style={{ flex: 1 }} />
<button
type="button" className="wf" disabled={data!.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span style={{ fontSize: 12, opacity: 0.7 }}>Page {data!.page} / {totalPages}</span>
<button
type="button" className="wf" disabled={data!.page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</Toolbar>
</>
)}
</div>
)
}

@ -0,0 +1,104 @@
// apps/hq/test/reminders-page.test.ts — go-live cluster WS-C: the Reminders page contract.
// The page's four status chips map straight onto GET /reminders?status=; the managerial
// Mine/All toggle onto ?owner=. Queued/failed were already covered (quote-followup.test.ts);
// this pins the sent/dismissed chips, ?owner= at HTTP level, and staff scoping across chips.
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { apiRouter } from '../src/api'
import { createClient, type Client } from '../src/repos-clients'
import { createModule, setPrice, type Module } from '../src/repos-modules'
import { createDraft, markStatus, type Doc } from '../src/repos-documents'
import { saveAccount } from '../src/repos-email'
import { encrypt } from '../src/crypto'
import { dismissReminder, listReminders, setSetting, upsertReminder } from '../src/repos-reminders'
import { sendReminder, type SendReminderDeps } from '../src/send-reminder'
import { runDailyScan, type ScanDeps } from '../src/scheduler'
const KEY = '11'.repeat(32)
const okFetch = (async (url: string) =>
new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'gmsg-1' }), { status: 200 })) as typeof fetch
const deps: ScanDeps & SendReminderDeps = {
gmail: { f: okFetch, clientId: 'cid', clientSecret: 'sec', keyHex: KEY },
renderPdf: async () => Buffer.from('%PDF-fake'),
company: () => ({ 'company.name': 'Tecnostac' }),
now: () => '2026-07-10T09:00:00Z',
}
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
setSetting(db, 'u1', 'share.base_url', 'https://hq.example.in')
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
return { db, c, m }
}
/** A QUOTATION marked sent, with the first-sent event pinned to `sentAt` for age math. */
function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Doc {
const q = createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
markStatus(db, by, q.id, 'sent')
db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`).run(sentAt, q.id)
return q
}
describe('GET /reminders — Reminders page contract (status chips, Mine/All, staff scope)', () => {
const { db, c, m } = world()
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const staff = createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
afterAll(() => server.close())
const tokenOf = async (email: string, password: string) =>
(await (await fetch(`${base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
})).json() as { token: string }).token
const get = async (token: string, qs: string) => {
const res = await fetch(`${base}/reminders${qs}`, { headers: { authorization: `Bearer ${token}` } })
return { status: res.status, json: await res.json() as any }
}
it('every chip maps to ?status= and Mine (?owner=) composes with it; staff scope holds on every chip', async () => {
const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
const mine = sentQuote(db, c, m, '2026-07-01T09:00:00Z', staff.id) // → queued nudge owned by staff
const theirs = sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'someone-else') // → queued nudge owned by someone else
await runDailyScan(db, deps, '2026-07-04')
// Flip someone-else's nudge to sent; queue a doc-less shared renewal and dismiss it.
const nudges = listReminders(db, { ruleKind: 'quote_followup' })
const theirsRem = nudges.find((r) => r.docId === theirs.id)!
expect((await sendReminder(db, deps, theirsRem.id, 'u1')).ok).toBe(true)
const renewal = upsertReminder(db, {
ruleKind: 'renewal_due', subjectId: 'cm1', duePeriod: '2026-08-01',
clientId: c.id, now: '2026-07-04T00:00:00Z',
})
dismissReminder(db, 'u1', renewal.id)
const ownerTok = await tokenOf('owner@test.in', 'owner-password')
// Each chip narrows to exactly its status.
expect((await get(ownerTok, '?status=queued')).json.total).toBe(1)
const sent = await get(ownerTok, '?status=sent')
expect(sent.json.total).toBe(1)
expect(sent.json.reminders[0].id).toBe(theirsRem.id)
expect(sent.json.reminders[0].status).toBe('sent')
const dismissed = await get(ownerTok, '?status=dismissed')
expect(dismissed.json.total).toBe(1)
expect(dismissed.json.reminders[0].status).toBe('dismissed')
expect((await get(ownerTok, '?status=failed')).json.total).toBe(0)
// Mine (?owner=) composes with the chip: staff's queued nudge yes, someone-else's sent one no.
expect((await get(ownerTok, `?status=queued&owner=${staff.id}`)).json.total).toBe(1)
expect((await get(ownerTok, `?status=sent&owner=${staff.id}`)).json.total).toBe(0)
// Staff scope holds on EVERY chip: not their sent row; doc-less dismissed row is shared work.
const staffTok = await tokenOf('staff@test.in', 'staff-password')
const staffQueued = await get(staffTok, '?status=queued')
expect(staffQueued.json.total).toBe(1)
expect(staffQueued.json.reminders[0].docId).toBe(mine.id)
expect((await get(staffTok, '?status=sent')).json.total).toBe(0)
expect((await get(staffTok, '?status=dismissed')).json.total).toBe(1)
})
})
Loading…
Cancel
Save