The Firm's current admin system has one seat: the administrator. There is no concept of a clerk — a person who handles a subset of cases, receives a subset of correspondence, and is responsible for a defined portion of the Firm's outstanding work. Every engagement is managed by whoever last looked at the dashboard. This is adequate for one person. It is not adequate for a firm that has been in continuous operation since 1836.
The Counting Room adds a clerk layer beneath the administrator. Each clerk has an assigned caseload, a restricted view of the dashboard, and a daily brief generated each morning that tells them exactly what requires attention before close of business. They cannot see what they have not been assigned. They cannot change what they have not been permitted to change.
| Action | Clerk | Administrator |
|---|---|---|
| View assigned cases | ✓ Yes | ✓ Yes (all cases) |
| View unassigned cases | ✗ No | ✓ Yes |
| Record a payment received | ✓ Yes (assigned cases only) | ✓ Yes |
| Add a case note | ✓ Yes (assigned cases only) | ✓ Yes |
| Approve a letter in queue | ✓ Yes (their queue only, non-pre-legal) | ✓ Yes (all) |
| Change debt terms (principal, rate, status) | ✗ No | ✓ Yes |
| Assign or reassign cases | ✗ No | ✓ Yes |
| Create or edit letter templates | ✗ No | ✓ Yes |
| View the despatch log | ✓ Yes (their cases only) | ✓ Yes (all) |
| Create or deactivate clerk accounts | ✗ No | ✓ Yes |
The existing admin auth uses a single password in an environment variable. It cannot support multiple named users. The clerk auth is a parallel track — it does not replace or modify the existing admin pattern.
Clerks authenticate at /clerk/login with email and password. On success, a clerk_session cookie is set containing the clerk's UUID (signed, httpOnly, 7-day expiry). Middleware checks for a valid clerk_session on all /clerk/* routes. The existing admin_session check is unchanged.
Passwords are hashed with bcrypt before storage. The lib/admin-auth.ts pattern is extended with a parallel lib/clerk-auth.ts that extracts and validates the clerk UUID from the clerk_session cookie. Every clerk-facing API route calls getClerkId() from this lib before executing any query.
case_assignments
before returning any debt data. Filtering in the frontend is not sufficient —
a clerk who can fetch unassigned cases by constructing a direct API call has
broken the system's central promise. The scoping query below is the minimum
acceptable pattern. Do not omit it.
-- Every clerk API query must follow this pattern
-- Replace the SELECT list with the columns required for the specific endpoint
SELECT d.*
FROM debts d
INNER JOIN case_assignments ca
ON ca.debt_id = d.id
AND ca.clerk_id = $1 -- $1 = clerk UUID from clerk_session cookie
WHERE d.id = $2; -- $2 = requested debt ID (if single record)
-- For list endpoints, omit the WHERE clause on debt ID:
SELECT d.id, d.status, d.principal_pence, d.creditor_name,
db.name AS debtor_name
FROM debts d
INNER JOIN case_assignments ca ON ca.debt_id = d.id AND ca.clerk_id = $1
INNER JOIN debtors db ON db.id = d.debtor_id
ORDER BY d.updated_at DESC;
At 07:00 each business day, a Vercel cron job generates a daily brief for each active clerk. The brief is delivered by email via Resend and is available on the clerk's dashboard. It is generated fresh each morning. It reflects the state of the clerk's caseload at the moment of generation, not at midnight.
The brief contains five sections, evaluated in this order. Each section lists only what is relevant to that clerk's assigned cases.
Cases with a scheduled payment due on today's date. For each: debtor name, amount due, expected payment method. Ordered by amount descending.
Cases with a missed scheduled payment in the last seven days. For each: debtor name, amount overdue, days since due date, current dunning assignment status. Ordered by days overdue descending.
Letters in the queue assigned to this clerk that have not been approved. For each: debtor name, template name, register, days in queue. Does not include pre-legal letters — those remain with the administrator.
Cases the administrator has explicitly flagged for this clerk's attention. For each: debtor name, flag text, date flagged.
Active cases in the clerk's caseload with no payment received, no note added, and no letter despatched in the last 14 calendar days. A reminder, not a reprimand. Ordered by last activity date ascending — the most dormant first.
If all five sections are empty, the brief reads a single line:
Nothing requires your attention today.
The Firm considers this a satisfactory outcome.
-- Clerk accounts
CREATE TABLE IF NOT EXISTS clerks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, -- bcrypt, cost factor 12
active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_login TIMESTAMPTZ
);
ALTER TABLE clerks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_clerks" ON clerks
FOR ALL USING (true) WITH CHECK (true);
-- Case assignments — one clerk per debt at any time
CREATE TABLE IF NOT EXISTS case_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
debt_id UUID NOT NULL REFERENCES debts(id) ON DELETE CASCADE,
clerk_id UUID NOT NULL REFERENCES clerks(id) ON DELETE RESTRICT,
assigned_at TIMESTAMPTZ DEFAULT NOW(),
assigned_by TEXT NOT NULL, -- 'admin' or admin identifier — audit trail
UNIQUE(debt_id) -- enforces single assignment
);
ALTER TABLE case_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_assignments" ON case_assignments
FOR ALL USING (true) WITH CHECK (true);
-- Case notes — immutable once written
CREATE TABLE IF NOT EXISTS case_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
debt_id UUID NOT NULL REFERENCES debts(id) ON DELETE RESTRICT,
clerk_id UUID REFERENCES clerks(id), -- NULL if written by admin
note TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() -- no updated_at — notes are not edited
);
ALTER TABLE case_notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_notes_write" ON case_notes
FOR INSERT WITH CHECK (true);
CREATE POLICY "service_role_notes_read" ON case_notes
FOR SELECT USING (true);
-- No UPDATE or DELETE — notes are permanent
-- Administrator flags on cases (visible to assigned clerk in brief)
CREATE TABLE IF NOT EXISTS case_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
debt_id UUID NOT NULL REFERENCES debts(id) ON DELETE CASCADE,
flag_text TEXT NOT NULL,
flagged_at TIMESTAMPTZ DEFAULT NOW(),
resolved BOOLEAN DEFAULT false,
resolved_at TIMESTAMPTZ
);
ALTER TABLE case_flags ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_flags" ON case_flags
FOR ALL USING (true) WITH CHECK (true);
-- Daily briefs — generated and stored for the record
CREATE TABLE IF NOT EXISTS daily_briefs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clerk_id UUID NOT NULL REFERENCES clerks(id) ON DELETE CASCADE,
brief_date DATE NOT NULL,
content_html TEXT NOT NULL, -- rendered at generation time, immutable
generated_at TIMESTAMPTZ DEFAULT NOW(),
delivered BOOLEAN DEFAULT false,
resend_message_id TEXT,
UNIQUE(clerk_id, brief_date) -- one brief per clerk per day
);
ALTER TABLE daily_briefs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_briefs" ON daily_briefs
FOR ALL USING (true) WITH CHECK (true);
| Item | Detail |
|---|---|
| Effort estimate | High — 16–20 days |
| New database tables | 5 (clerks, case_assignments, case_notes, case_flags, daily_briefs) |
| New clerk routes | 5 pages |
| New admin routes | 2 pages |
| New API routes | 11, plus 1 cron endpoint |
| External dependencies | Resend (brief delivery). The Ledger (required). The Dead Letter Office (recommended — clerk letter queue requires it). |
| New environment variables | CRON_SECRET (if not already added for Dead Letter Office) |
The schema and routes are methodical rather than complex. The effort is concentrated in two places. First, the parallel auth track — building a second credential system that does not interfere with the existing admin pattern requires careful middleware design. Second, the data scoping — every clerk-facing query must enforce assignment at the database layer. There are eleven clerk API routes; each one must include the scoping join. A single missed join is a data breach.
The daily brief cron is simpler than it appears. Five queries, one HTML template, one Resend call per clerk. The UNIQUE(clerk_id, brief_date) constraint on daily_briefs makes the cron idempotent — running it twice produces one brief, not two.