CLIENTS, SHELF, NOMINEES, RUNBOOKS — and derives everything
else. _api.js replaces the contents of those globals from the API and leaves every page untouched.
Until it is configured, the pack runs exactly as it does today off the flat file.
Turning it on
One block, before the scripts, on whichever pages should run live:
<!-- 1. config (omit entirely to stay on static data) --> <script> window.SUPREME_CONFIG = { mode: 'supabase', // 'static' | 'rest' | 'supabase' supabaseUrl: 'https://<project>.supabase.co', supabaseAnonKey: '<anon key>', token: session.access_token, // the signed-in admin's JWT cacheMinutes: 10, staleAfterDays: 7 }; </script> <!-- 2. load order matters: data, then adapter, then the page --> <script src="_data.js"></script> <script src="_api.js"></script> <script> SupremeData.setAdmin(currentAdminId); await SupremeData.load(); // fills CLIENTS / SHELF / NOMINEES / RUNBOOKS in place render(); // existing page code, unchanged </script>
load() logs the error, returns
source: 'static-fallback' and leaves the static arrays in place. An admin gets yesterday's data and
a warning rather than an empty screen — which matters when the person affected is in another timezone and cannot
just ask someone.
What the API has to return
Two options. Either PostgREST directly against the schema, in which case _api.js already knows
the table and column names, or a single bootstrap endpoint if you would rather assemble it server-side.
/dispatch/bootstrap
Returns { clients[], shelf[], nominees[], runbooks[], rates{} } in the database's own snake_case
shape. _api.js maps it — do not pre-map it to the front-end shape, because then two places would
own that translation.
/dispatch/claim
{ adminId, limit } → the tasks now claimed by that admin. Must be backed by
FOR UPDATE SKIP LOCKED; see below.
/dispatch/tasks/:id
Status transitions. The server must reject done from a non-supervisor rather than trusting the
UI.
/dispatch/packages/:id
Package state. Same rule — done is a supervisor action and needs confirmed_by.
/dispatch/reconcile
{ adminId, packages[], completedTaskIds[] } — merges progress from an admin who worked offline.
The existing export/import in the portal already produces exactly this shape.
The one thing that will break at 15 admins
Three teams of five all pull work at the same time. A naive
select … limit 10 for update makes fourteen of them wait behind the first. The schema ships a claim
function that does not:
update public.tasks t set status = 'claimed', claimed_by = p_admin_id, claimed_at = now() where t.id in ( select id from public.tasks where status = 'open' order by priority, id limit p_limit for update skip locked -- each admin gets DIFFERENT rows ) returning t.*;
Call it as rpc/claim_tasks with { p_admin_id, p_limit }. There is also
release_stale_claims() — run it on a schedule so work claimed by someone who then logged off does
not sit invisible forever.
Schema notes worth keeping
- Credentials are a separate table.
client_credentialsholds the Companies House authentication code and Gateway user ID under tighter RLS than the client record. An admin sees a code only while they hold live work on that client. Do not merge it back intoclientsfor convenience — the whole point is that a company name and an auth code are not the same class of data. - Nominee records are personal identity data. Same pattern: visible to supervisors, and to the admin holding that person's task. Nothing broader.
- RLS wraps
auth.uid()in a subselect so it evaluates once per query rather than once per row, and the helper functions live in aprivateschema withexecuterevoked fromanonandauthenticated. - Every foreign key has its own index. Postgres does not create these automatically and their absence is the usual reason a dashboard like this gets slow six months in.
- The queue index is partial —
where status = 'open'— so it stays small no matter how many completed tasks pile up behind it. ch_verified_atis the freshness contract. Set it on every live Companies House read. Anything older than seven days is surfaced as unverified, because the whole pack is built on the principle that the register is live and our copy of it is not.task_eventsis append-only. There is no update or delete policy on it deliberately.
Scaling to 3 teams of 5
The schema carries teams and admins with a day_capacity defaulting to
ten. v_admin_load gives live per-person load — claimed, awaiting confirmation, and closed in the
last seven days — which is what a team lead needs to see rather than a total.
Order to build in
schema.sql on a branch database firstIt is idempotent — every object uses if not exists or create or replace — so it can be re-run while you iterate.
_data.jsRUNBOOKS and RATES are the two objects that should move into the database first, because they are reference data everything else joins to.
Same file. Set ch_verified_at to the 15 Aug verification date rather than to now — they are real readings with a real age, and pretending they are fresh is how stale data becomes invisible.
The rules are in buildTasks(). Port them to SQL or run the JS on the server — but keep ONE implementation. Two copies of this logic will drift within a month.
Do DISPATCH.html first. If it works there, it works everywhere, because every other page reads the same globals.
This is what makes the whole thing worth building — the register moves and our copy does not. Nightly is enough; update ch_verified_at on every read.