Docket — the client-experience platform for Nigerian law firms
One app, every matter, any firm. Docket is the platform; every law firm on it — Attorneys Klinique first — is a tenant with its own site, portal and console. See docs/DOCKET_PLATFORM_MODEL.md and decision 0003.
This repository is the platform: the multi-tenant Postgres schema and row-level security, the server-side business flows (firm creation, booking, payment cascade, court updates, consultation notes, service of process, invites, jobs), the Nigerian reference data (states, courts, holidays), the provider adapters, the payment webhooks, the notification dispatcher, the Next.js app (tenant public sites, client PWA, staff console, platform admin), Klinique's seed data, and test suites that prove isolation between firms and between clients.
Validated: all 54 migrations, the seed and 32 SQL suites (1,466 checks) run clean on PostgreSQL 16. The onboarding runbook is docs/ONBOARDING_A_FIRM.md; every server function is catalogued in docs/RPC_REFERENCE.md.
Any firm, the same way
A firm registers at /firm/start (account → firm → two-factor), which calls create_firm(): the caller becomes owner and seed_firm_defaults() installs the matter statuses, an unpriced consultation service, an intake form and a versioned policies skeleton. The console works immediately; the firm's public site and bookings open when a platform admin verifies and activates it from /admin (RC/BN number, the owner's enrolment number). Owners invite their lawyers with staff_invites → accept_staff_invite(). A platform admin can also open a firm for an existing owner. supabase/seed.sql inserts tenant #1's data directly (already active and verified by the founders) and then calls the same seed_firm_defaults(); its owner accounts and Paystack subaccount are attached exactly as for any firm (docs/ONBOARDING_A_FIRM.md). No other firm needs SQL to join.
What's in the box
supabase/
migrations/
20260909000001_schema.sql types, 35 tables, indexes, grants, firm_public view
20260909000002_rls.sql helpers + policies for every table (staff writes need MFA)
20260909000003_functions.sql booking engine, payments, court updates, notes, invites, jobs, triggers
20260909000004_supabase_storage_cron.sql storage buckets/policies + pg_cron (no-op on plain Postgres)
20260909000005_partner_attribution.sql originating/handling partner on matters, partner_attribution view
20260909000006–08 firm_public/lawyer_public projections, hardening, realtime
20260910000009_platform_firms.sql create_firm(), seed_firm_defaults(), platform_admins, firms.plan/status
20260910000010_nigeria_reference.sql ng_states, courts (hierarchy + suit-number hints), holidays, vacations, SCN/year of call, matters.court_id
20260910000011_counsel_and_service.sql matter_counsel, process_service, serve_process(), acknowledge_service(), service_inbox
20260910000012_platform_hardening.sql review fixes: settlement to the firm's Paystack subaccount, pending→verified lifecycle, version-pinned
exhibit-grade service (opt-in, undertakings, orders, revocation, inbox-only reads), Lagos/FCT divisions,
matter_court_numbers, state holidays + 2027, staff_invites, SCN uniqueness, court-aware post_court_update
20260910000013_security_review.sql served-firm access pinned to process_service.served_firm_id; uploads never follow service; lawyer_profiles
member-only; suspension blocks writes; platform admins limited to firm_admin + set_firm_status(); name/SCN
snapshots; brand validation; vacated/refixed dates, hearing notices, sine die; cause list; verified-only suit hints
20260910000014_review_round_two.sql views read-only and no TRUNCATE for API roles; rows must belong to their matter's firm; invites admin-only with
identity-provider email; slug/domain platform-only; only active firms are public; SCN uniqueness among verified;
settlement mismatches recorded and reported (never a webhook retry loop)
20260910000015_second_firm_walkthrough.sql owner invites by owners; registrant's SCN and profile; no booking on unpublished policies; open_matter()
20260910000016_storage_guards.sql storage policies never throw on a stray object name (try_uuid guards every path-segment cast)
20260910000017_merge_reconciliation.sql one available_slots(): the active-firm check and the p_ignore argument in a single signature
20260910000018_staff_console.sql manual invoicing (create/issue/cancel), matter invitations (invite/revoke), and the two firm-wide reads
the console runs on — firm_sittings_due (the chase list) and firm_overview (every counter in one trip)
20260910000019_console_review.sql console review: an invoice filed against a matter must name a party to it (issuing posts a client-visible
fee entry there); firm_overview money is one figure per currency, never kobo added to cents; documents
gain reviewed_at so "client uploads to review" can reach zero
20260910000020_admin_surfaces.sql what the two admin consoles write: domain_requests (a firm asks, the platform maps), set_firm_domain/
set_firm_plan, set_member_role/remove_member (never yourself, never the last owner, never a lawyer with
consultations still ahead), validated policies and notification_templates, webhook_events, the three
platform health views and retry_notification()
20260910000021_hardening.sql the security pass: RLS on ng_states, a pinned search_path on every definer function, no EXECUTE on a
trigger function, auth.uid() evaluated once per query, FOR ALL write policies split so they stop granting
SELECT, rate_limits + rate_limit_hit(), and an index on every foreign key
seed.sql Attorneys Klinique: brand, policies, 14 services, intake form — then seed_firm_defaults()
functions/
paystack-webhook/ HMAC-verified, re-verified with Paystack, then record_payment()
dispatch-notifications/ drains the outbox to email (Resend), SMS (Termii / Twilio), push (VAPID); pg_cron calls it
storage-manifest/ downloads and hashes every stored document on a rolling schedule, so "the bytes exist" is measured; pg_cron calls it
video-session/ Daily room + owner token per appointment; the only writer of consultation_sessions
tests/
00_local_auth_stub.sql local-only stand-in for Supabase Auth
10_rls_isolation.sql two firms, six users, isolation, booking, payments, court updates, rolls back
20_platform.sql self-serve firm creation, slug rules, caps, platform admin sees lifecycle only
30_nigeria.sql states, court directory (platform vs firm-private), holidays, practitioner fields
40_counsel_service.sql service of process: served firm sees the record + document and nothing else
50_staff_console.sql VAT and line arithmetic, drafts invisible to clients, invitations, the chase list clearing
60_admin_surfaces.sql domain requests, role changes and removals, policy/template validation, platform health, the rate limiter
src/lib/providers/
payments/ PaymentProvider — Paystack (all currencies; decision 0002)
video/ VideoProvider — Daily (private rooms, knocking, per-user tokens, no recording)
messaging/ SmsProvider (Termii, Twilio), EmailProvider (Resend)
src/lib/nigeria.ts states, +234 normalisation, suit-number shape, court outcomes
src/lib/firm-data.ts the console's shared reads (context, overview, chase list, matters, availability)
src/lib/checksum.ts SHA-256 of an upload, so a served document's checksum is a real one
app/
page.tsx Docket landing (firms · clients · courts)
(public)/[firm]/… tenant public site + booking wizard
app/… client PWA
app/(auth)/join matter-invitation landing → accept_invite() (the link a firm sends its client)
firm/(auth)/start self-serve firm registration → create_firm()
firm/(auth)/join staff-invite landing → accept_staff_invite()
firm/(auth)/security/mfa TOTP enrolment; firm/(console) staff console
firm/(console)/ today · overview · sittings · matters · clients · invoices · service inbox · availability
firm/(console)/admin/ the firm's own: settings · services · intake form · people · audit log (owner/admin only)
admin/ platform admin (platform_admins gate; lifecycle only) — firms · domains · plans · health · reference data
api/report/ the browser error sink; the DSN stays server-side and the CSP names no third-party origin
scripts/db-test-local.sh
.env.example
Apply to Supabase
- Create the project (region: London or Frankfurt). Enable Phone and Email auth and MFA (TOTP).
supabase link --project-ref <ref> then supabase db push — migrations apply in order.
psql "$DATABASE_URL" -f supabase/seed.sql — creates Attorneys Klinique. Edit brand, policies, vat_rate and service prices before launch (see "Decisions").
supabase functions deploy paystack-webhook dispatch-notifications and set the secrets from .env.example with supabase secrets set.
- Point Paystack's webhook at
/functions/v1/paystack-webhook (Paystack dashboard → Settings → API Keys & Webhooks; there is no API for it).
scripts/configure-providers.sh sets the Auth Site URL, redirect allow-list, Docket email
template, custom SMTP, Google OAuth and phone OTP when their provider variables are present. It
needs a Supabase personal access token (SUPABASE_ACCESS_TOKEN), SUPABASE_PROJECT_REF and
APP_URL; see the script header for the provider variables. Afterward run npm run auth:check
with those three values to fail fast on configuration drift.
- Dashboard → Integrations → Cron: HTTP request to
/functions/v1/dispatch-notifications every minute (the SQL jobs are already scheduled by migration 4).
- Auth → Hooks → Send SMS: point at a small Edge Function that forwards OTPs to Termii (
channel: 'dnd'). Until then Supabase's built-in Twilio provider works for testing.
supabase gen types typescript --linked > src/lib/db/types.ts whenever the schema changes.
- Slice 2 (video + reminders). Secrets on the project:
DAILY_API_KEY (rooms and owner tokens are minted only by the
video-session Edge Function), VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT (web push), CRON_SECRET.
Migration 9 schedules dispatch-notifications every minute through pg_cron + pg_net, reading dispatch_url and
cron_secret from Vault: select vault.create_secret('https://<ref>.supabase.co/functions/v1/dispatch-notifications', 'dispatch_url');
and select vault.create_secret('<CRON_SECRET>', 'cron_secret');. On Vercel set NEXT_PUBLIC_VAPID_PUBLIC_KEY so the
client can subscribe. Clients knock into the Daily room without a token; only the lawyer holds an owner token and admits.
Run the tests locally
Requires PostgreSQL 16+ (superuser). Never run the stub against Supabase.
DATABASE_URL=postgres://postgres@localhost:5432/postgres bash scripts/db-test-local.sh
Each suite ends with NOTICE: ALL CHECKS PASSED (1,466 PASS lines across 32 suites; scripts/check-suite-counts.sh holds each suite to its own floor, so these are counts CI keeps true rather than counts somebody remembered to update). Coverage: client isolation (matters, updates, documents, invoices), staff isolation across firms, MFA gating of staff writes, the anonymous surface, slot computation with breaks, booking and double-booking, the 15-minute hold, the payment cascade and duplicate-webhook idempotency, the court-update form, consultation notes (internal notes invisible to clients), audit-log access and immutability, the hold-release job; self-serve firm creation and its defaults, slug validation, the three-firm cap, platform admins seeing lifecycle rows and no content; the court directory (platform-wide vs firm-private), holidays and vacations, practitioner fields; service of process across firms (record + served document visible to the served firm, nothing else; acknowledgement once; clients see progress); manual invoicing and the chase list; and the admin surfaces — domain requests, role changes and member removal, policy and template validation, what a platform admin can and cannot see on the health screens, and the rate limiter; matter walls (a restricted matter invisible to a colleague outside its team through rows, bytes, threads, the overview and every RPC); document reads as the door to the bytes; document requests (asked once, answered once, withdrawn never deleted, walled like the documents); and conflict checks (the firm's own register only, a match behind a wall reported without the matter, the lawyer's decision recorded once, and — opt-in — no client joined to a matter until it is cleared); and the onboarding checklist and import (one readiness answer in the booking engine's order, a skipped step stored with who and why, an import staged and filed a bounded batch at a time with dates and old file numbers kept, unknown statuses refused, every client invited and never linked by a number in a file, a phone nobody can read said rather than guessed, and a batch short of its rows refused whole); and the pre-consultation check-in (readiness computed from real rows, a held booking the database refuses to confirm until what the firm asked for is in, the client answering only what is missing, and a conflict item the client sees only as "the firm's own checks"); and retries (a court update posted twice with the same reference is one posting, a message sent twice with the same id lands once, and an upload that stopped leaves a row that answers no request, is counted by the platform, and can be retired by whoever made it); and notifications (the same message to the same person is one row a day, the dispatcher claims before it sends and two claims never overlap, accepted is written from the provider's answer and delivered only from its verified receipt, a transient failure comes back with a growing delay and a permanent one does not, the API can change nothing but read_at whatever column is added, and a rate is what the platform entered — an unpriced message is unpriced); and the legal diary (a court date records who made it and the notice it came from, and is evidenced or merely claimed; a deadline is counted by the database from the rules the platform entered — calendar, clear or working days, months, time stopped in vacation, rolled past a day the court does not sit — with every skipped and rolled day and the reference data it relied on written into the row, proposed until a lawyer confirms it, superseded rather than edited, discharged with a note, never a client's to see); and workflow packs (a versioned, immutable catalogue the platform publishes and a firm installs, which adds the stages the firm lacks and recognises the ones it has without rewriting a label or moving a matter; a stage change as one act that tells the client, closes or reopens the file, and starts the stage's tasks once); and templates and execution (a template that names a fact Docket cannot fill is refused when it is saved, a generation that finds the matter has no value for one refuses and says which rather than guessing, a signature is refused from somebody who has not opened those exact bytes or who types a name that is not their own, it records the version and its checksum, it locks the document so no further version can be added, and an instrument the law executes on paper is recorded — witness, attestation, stamp and registration — rather than signed here); and the pilot baseline (every figure computed from the firm's own rows over a named window, a consultation never written up counted as unrecorded rather than attended or missed, an inference labelled as one, money per currency, the record append-only and unreadable by a client, and what the firm says about the years before Docket kept as a stated claim beside the figures and never inside them); and search (a colleague outside a walled matter's team cannot find it by any word in it, by its suit number or by its Docket reference; a client never finds an internal update, a staff task, the adverse-party register or an internal note; nothing crosses a firm; and the search function is asserted not to be SECURITY DEFINER, because that one word would turn it into a wall-free read of everything); and verified delegation (an authority to act for a client is bound only by redeeming a single-use token AS the person the firm named — a link alone binds nobody and a phone number alone binds nobody; it reaches documents or money only where the firm said so, never a signature; it stops granting the moment it expires with no job having run; and the firm or the client can end it, with the record kept either way); and firm-to-firm collaboration (a referral, joint retainer or agency is worth nothing until the other firm accepts; what they read is a snapshot through one definer view and never the matter row; documents are handed over one named version at a time, so a later version is not shared by an earlier row; an internal note is never shared however wide the arrangement; and ending it — by either firm, or by its own last day with no job run — closes every door at once, though nothing recalls a copy already downloaded); and the partner API (a key is hashed and shown once, carries only the scopes it was issued with, speaks for one firm and no other, and opens nothing once revoked, expired or its firm suspended — every one of those decided by a database function the HTTP layer cannot reach around, since it holds the key and never a firm id; and an event feed a partner pages by sequence, so nothing is missed and nothing is processed twice); and the calendar feed (a subscribed URL carries one person's own diary and nobody else's, the matter wall is applied to its owner by name because a calendar app fetches without a session, a member who has left the firm and a firm that has been suspended both stop having a diary, issuing a second URL revokes the first the same second, and a client's name is in it only where the person deliberately said so); and the words inside a document (a colleague outside a walled matter's team finds a phrase in its file before the wall goes up and not after, a client finds the words in what their firm shared with them and not one word of the internal advice on the same matter, only the current version is searched, nothing crosses a firm, and the two functions that write the text are executable by neither anon nor authenticated with the table beside them shut); and the court-registry pilot (a registry is a principal that is not a firm and reads zero rows of any firm's matters, court events, decisions or notifications, even after firms have acted on its notices; a firm sees the court's notices only for suit numbers it holds at that court, never a draft, never another firm; a colleague off a walled matter's team sees its notice before the wall and not after; nothing reaches a diary until a lawyer confirms, and confirming attaches to a same-day sitting rather than duplicating it; a registry-sourced date a lawyer moves by hand stops being the registry's; a withdrawal is stamped on the sitting and vacates nothing; and the platform measures the pilot in counts that name no firm; a listing is confirmed only by an owner, admin or lawyer, a value longer than its column is refused rather than trimmed, and a colleague who has left the firm is told nothing — the root of which, remove_member() leaving matter_lawyers rows behind, is fixed here for all four fan-outs that read them); and booking consent (a client's acceptance of a firm's terms and privacy notice is recorded for the caller alone, only at the versions the caller was shown and only when those are the versions the firm has published — a stale version is refused and nothing is written — once however often it is asked, and never for a firm that is not active or whose terms or privacy notice is missing or still a draft).
.github/workflows/ci.yml runs the same script on every push, so a change that loosens row-level security fails the build rather than the launch. It also runs a real next build and checks the route table against the content security policy (scripts/check-prerendered-routes.mjs): a page Next.js prerenders carries inline scripts that can hold no per-request nonce, so a route that becomes static without the policy knowing ships as HTML that never hydrates.
Rules the code enforces (don't undo them in later slices)
- Staff writes need an MFA-verified session (
aal2). Build the TOTP enrolment gate in slice 1 before any staff screen; without it staff can read but not write.
- Clients never insert appointments or payments. They call
book_appointment() and cancel_appointment(); webhooks call record_payment() with the service role. The frontend callback page displays state; it never sets it.
- Internal notes never reach clients.
updates.visibility = 'internal' and consultation_internal_notes have no client policy.
- Money settles to the firm. The platform's Paystack account only routes: every prepaid booking needs
firms.paystack_subaccount, the checkout is initialised with subaccount + bearer: 'subaccount', and record_payment() applies a payment only when Paystack reports the firm's own subaccount — anything else is recorded as a flagged failure and reported to the firm for reconciliation (decision 0002, migrations 12 and 14). Docket never holds funds.
audit_log is append-only for every role except the security-definer audit() function.
- Timestamps are UTC; render in the viewer's zone (
profiles.timezone, default Africa/Lagos).
- Storage paths carry the tenant:
documents/{firm_id}/{document_id}/{version_id}.{ext}, intake-uploads/{firm_id}/{client_id}/…, firm-assets/{firm_id}/… — the storage policies parse them.
- No firm is named in code. Brand, copy, services, policies, courts and statuses are rows;
seed.sql is data. Platform-wide reference rows (courts with firm_id is null, ng_states, public_holidays, court_vacations, platform_admins) are written with the service role only.
- Platform admins never see matter content. There is no platform policy on matters, documents, messages, updates or invoices — do not add one.
- A served firm sees only what was served. Its only read path is the
service_inbox view (never the process_service row, note, proof or the serving firm's documents row) and the exact document version served (can_access_document_version, which the storage policy also uses) — never the matter, roster, timeline or later versions. Platform service needs the other firm's opt-in (); an originating process needs counsel's undertaking () or an order for substituted service; platform service is timestamped by the platform; a wrong service is withdrawn with . The in-app acknowledgement is evidence of receipt: where the rules require an affidavit of service (personal, bailiff, substituted service) it supports that affidavit; where counsel has undertaken to accept service, keep the endorsed acknowledgement as the proof the court expects.
Server functions (RPC)
The table below is the shape of the API. docs/RPC_REFERENCE.md is the full reference — every argument, every error a caller can provoke, and what each function audits.
| Function | Who | What |
|---|
available_slots(firm, lawyer, service, date, ignore?) | anon, authenticated | slots in the lawyer's zone minus rules, breaks, exceptions, live appointments, daily cap; 2-hour lead time |
book_appointment(firm, service, lawyer, starts_at, mode, client_tz, intake, intake_form) | client | validates slot, creates held appointment + issued invoice (VAT from firms.vat_rate), stores intake, returns what the payment step needs |
cancel_appointment(appointment, reason) | client (own, future) or staff | cancels and voids the unpaid invoice |
record_consent(firm, terms_version, privacy_version) | any signed-in person, for themselves | records acceptance of the firm's terms and privacy notice at the versions shown, only when they are the versions the firm has published (a stale version is refused, DKC01, and nothing is written); refuses a firm that is not active or has not published them; a repeat call writes nothing |
record_payment(provider, ref, invoice_number, amount, currency, status, raw, subaccount) | service role | idempotent on provider_ref; paid → confirms appointment → notifications; a succeeded payment reported against a subaccount other than the firm's (or for a firm with none) is recorded as failed with settlement_mismatch, audited and reported to the firm — it confirms nothing |
save_consultation_notes(appointment, summary, advice, follow_up, internal, mark_completed) | staff (MFA) | client-visible + internal notes, timeline echo, marks completed |
reschedule_appointment(appointment, starts_at, reason) | staff (MFA) | re-validates the slot through the engine, resets reminders, notifies the client, audits |
mark_no_show(appointment) | staff (MFA) | after the start time; audited |
create_invoice(firm, client, items, matter, currency, due_on, issue, note) | staff (MFA) | numbers from the firm counter, applies firms.vat_rate, writes the items; issuing notifies the client and echoes a fee entry to the matter timeline. With a matter, the client billed must be a party to it — the fee entry is client-visible on that file |
issue_invoice(invoice, due_on) |
Decisions still open before launch (blueprint §14)
Platform domain · platform entity · which entity holds the platform Paystack account (each firm settles to its own subaccount) · first friendly firm for week 11 — which now onboards itself at /firm/start. Tenant #1's own launch decisions (VAT treatment, service prices, published policies) are its data: see docs/MASTER_PROMPT_RECONCILIATION.md.
Platform data the operator maintains
courts (platform rows), court_vacations (from each court's practice direction, with whether time runs), public_holidays (national and state-declared; movable Eid dates when declared; 2026–2027 fixed dates seeded), platform_admins. Platform admins with MFA write the first three at /admin/reference (holidays, including movable Eid dates once declared, and each court's vacation); reference_data_coverage shows how far the data reaches. See docs/DOCKET_PLATFORM_MODEL.md §2.
Guides and runbooks
| Document | For |
|---|
docs/ADMIN_GUIDE.md | the two consoles: what a firm's owner changes, and what only the platform can |
docs/CLIENT_GUIDE.md | what a client can do in the app, in their words |
docs/RPC_REFERENCE.md | every server function: arguments, errors, what it audits |
docs/DEPLOYMENT_RUNBOOK.md | a project from nothing to serving, and what to check after each deploy |
docs/RESTORE_RUNBOOK.md | PITR, the restore rehearsal, and the RPO/RTO this platform commits to |
docs/COMPLIANCE_PACK.md | the privacy notice and DPA wired to firms.policies, the DSR and breach runbooks, the NDPC checklist |
docs/LEGAL_READINESS_CHECKLIST.md | 20 common legal-readiness items: where Docket stands on each, the file-level steps to close each gap, and the decisions to make first |
docs/ONBOARDING_A_FIRM.md | taking a new firm from registration to live |
docs/CLIENT_MONEY_DESIGN.md | a proposal, not a build: how Docket would record a firm's client account without ever holding the money — for the accounting adviser to answer before anything is written |
Next slices
See BUILD_PROMPTS.md — six self-contained prompts for Claude Code that build the Next.js app on top of this foundation, in the order of the 90-day plan. Slices 1–5 are built; slice 6 is the launch slice.
Source requirements
Platform model: docs/DOCKET_PLATFORM_MODEL.md and docs/decisions/0003-platform-first.md. The original Klinique requirements document lives at docs/Attorneys_Klinique_Partner_Platform_Prompt.md; docs/MASTER_PROMPT_RECONCILIATION.md maps it section by section onto this architecture — what's covered, what the blueprint deliberately changed (Daily vs Google Meet, Supabase Storage vs Google Drive), and the partnership-pooling additions (originating/handling partner attribution, migration 5; Firm Overview scheduled into slice 4).