CI has been red on every push: the workflow runs `pnpm format:check`, which I
never included in local validation (I only gated typecheck/lint/build), so 54
files were committed unformatted.
- ran `pnpm format` — all changes are Prettier line-reflow, no logic touched
(verified: `git diff -w` flags exactly the same 54 files CI reported)
- added .gitattributes (`* text=auto eol=lf` + binary rules for fonts/images):
with core.autocrlf and no attributes file, `format:check` counted 141 files
locally vs 54 in CI, which is what let this slip through
All six CI gates now pass locally in workflow order: secret-leak, format:check,
test (97), lint, typecheck, build.
Implements the deferred NEXT items from the clinician UX review, which
targeted the doctor's core loop:
- call_next_patient RPC (advisory-locked so two staff can't double-call) with
a prominent per-shift button and an Alt+N shortcut that lands straight on
the checkup screen
- realtime queue indicator: live/disconnected as colour + icon + text plus a
last-updated time, so a frozen queue is never trusted silently
- unsaved-changes guard on the checkup form (beforeunload + in-app marker)
- diagnosis quick-pick sourced from recent diagnoses, and Vietnamese dose
presets on every prescription row, so the doctor types almost nothing
typecheck/lint/build green; 97 unit tests still passing.
The repo had zero tests despite PLAN §7 mandating them.
- 97 unit tests over the pure logic: checkup/customer/catalog/template Zod
schemas, parseNum, computeAge, and invoice math
- extracted lib/billing/totals.ts (sumLineTotals/formatVnd) so invoice math is
testable, and refactored the invoice route + dashboard to use it
- 16 Playwright smoke tests that need no database: auth gates redirect to
sign-in, VI-default rendering, password-reveal toggle, /en locale, 404
- playwright.config passes placeholder env inline via webServer.env so no
env file is ever needed; pnpm test wired into CI (E2E stays local)
tests/e2e/README.md documents the seeded-data prerequisites for the full
queue -> checkup -> prescription -> paid -> invoice happy path, which is
blocked on a provisioned Supabase project rather than faked with skipped tests.
Closes the last two original-app feature gaps:
- set_queue_counter RPC (admin/receptionist, advisory-locked, VN-local day)
plus per-shift counter display for all staff and a set/reset form for
admin/receptionist (orig SetCounterRequest/GetCounterRequest)
- checkup templates are now actually applied: pick a template on the checkup
screen (filtered to the patient's gender), its fields render as inputs and
persist to the new checkups.template_values jsonb; the ultrasound report PDF
already reads the template title
daily_queue_counters gains a SELECT policy (reads only) — writes stay inside
the DEFINER RPCs.
Restores the original's full print/report surface (it had three JasperReports
templates; only the invoice existed):
- prescription PDF: dosage-prominent, no prices, doctor + signature line
- ultrasound/imaging report PDF: template title, diagnosis/conclusion, up to
4 embedded images (downloaded server-side via signed URL)
- Excel exports added for patient roster, medicine+service catalog (2 sheets),
and monthly paid-revenue with a grand-total row
- browser print CSS (@page A4, chrome hidden via data-print-hidden)
All PDFs use the bundled Be Vietnam Pro family so diacritics render.
- react-pdf invoice route (Node runtime) — clinic header, medicines + services
tables, server-summed VND total, payment status
- bundled Be Vietnam Pro (Regular+Bold) so diacritics render; fonts registered
once per process
- "Print invoice" link on the prescription page (locale-prefix aware)
- reports i18n namespace (vi/en); xlsx + recharts deps added for next slices
- private bsk-checkup-media bucket + checkup_images table; RLS: enrolled read,
clinical write; storage.objects policies scoped to the bucket
- webcam (getUserMedia) + file capture, client canvas JPEG compression to
<=200KB; upload via browser client, metadata recorded server-side with a
path-ownership guard; audit-logged
- gallery with 1h signed URLs + soft-delete; code128 barcode of the checkup id
(no PII); linked from the checkup page; vi/en; bwip-js added
- admin staff list (email + role) over bsk.app_users; invite stays the
create path
- change role / remove access via admin-client Server Actions (audit-logged)
- guards: no self role-change or self-removal; the last admin cannot be
demoted or removed
- sidebar nav + vi/en; completes Phase 2 core entities
- bsk.doctors table (soft-delete) with RLS: reads for enrolled staff,
writes admin-only; user-client writes so RLS is the enforcement point
- add / edit / deactivate Server Actions — admin-gated, Zod-validated,
audit-logged via log_audit, revalidate the list
- doctors admin page (RSC list + inline edit + deactivate) and add form
- sidebar nav entry + vi/en strings
Establishes the Phase 2 CRUD pattern (RLS gate + defense-in-depth role
check + Zod + audit + revalidate) for the remaining core entities.
- claim_first_admin: no-arg, inserts auth.uid(), gated on an email allowlist
table so an arbitrary shared-pool principal can no longer claim admin
- revoke direct writes on app_users from authenticated (least privilege)
- rate-limit sign-in (by platform IP) and invite (by admin id); fail open
on Redis outage so an outage cannot lock staff out
- audit_log table + SECURITY DEFINER log_audit writer, admin-only reads
- invite: map existing-email to a clear error, roll back orphaned auth row
- session: read role + full_name in one own-row query
- supabase/migrations/20260525163400_bsk_admin.sql:
bsk.claim_first_admin(uuid) -> boolean, VOLATILE SECURITY DEFINER.
Advisory lock keyed by hashtext('bsk:claim_first_admin')::bigint
serializes concurrent first-sign-ins; EXISTS-guarded INSERT means
only the first caller wins.
- types/supabase-bsk.ts: added claim_first_admin to bsk.Functions.
- lib/auth/invite-schema.ts: InviteUserSchema (Zod v4: email + role
enum derived from appRoles) + InviteUserState discriminated union.
- app/[locale]/(app)/admin/invite/{actions,page,form}.tsx: admin-only
invite Server Action + page + RHF/useActionState client form.
Caller-role check via getServerSession() (defense in depth; the
(app)/admin layout in phase 06 will gate at the route level).
Insert uses createSupabaseAdminClient() because app_users has no
INSERT RLS policy by design.
- app/[locale]/(auth)/sign-in/actions.ts: extended enrollment-check
branch — when no row AND count == 0, calls claim_first_admin RPC.
On true, re-fetches enrollment row and proceeds; on false (race
lost) or count > 0, falls through to existing sign-out + generic
error (enumeration defense preserved).
- messages/{vi,en}.json: admin.invite.* keys (parity).
- docs/runbooks/first-admin-setup.md: happy path + manual psql
fallback bootstrap procedure.
No audit_log refs — trimmed plan respected.
- proxy.ts: composes Supabase session refresh + next-intl middleware
into a single NextResponse via copyCookies helper. Coarse auth gate
on /dashboard + /admin prefixes redirects unauth users to
/[locale]/sign-in (no ?next= per trimmed plan).
- lib/supabase/session.ts: implements updateSupabaseSession() returning
{ response, user }. Cookies written onto both request.cookies (for
downstream reads) and response.cookies (for browser). PROTECTED_PATH_PREFIXES
exported as the gate list.
- lib/proxy/copy-cookies.ts: small helper that ports Set-Cookie entries
between two NextResponses.
- lib/auth/get-server-session.ts: getServerSession() returning
{ user, role } | null. Derives User type from the factory's return
type so @supabase/supabase-js stays out of allow-listed lib/auth/*
per ESLint no-restricted-imports.
- lib/auth/session-provider.tsx: client-side context exposing user to
client components via useSession() — populated once per request in
the locale layout.
- app/[locale]/layout.tsx: reads user via getUser() outside any
'use cache' scope; wraps children in SessionProvider; explicit
'use cache' warning comment.
- env: cross-check VERCEL_ENV against NEXT_PUBLIC_APP_ENV at boot so prod
credentials cannot silently write into a dev keyspace
- upstash: tighten cache-key regex (kebab + colon only); split SCAN
patterns into their own validator so glob '*' is allowed only there
- eslint: forbid raw @upstash/redis, @upstash/ratelimit, @supabase/supabase-js
imports outside the named factory files
- supabase/admin: harmonize 'use cache' guidance with CONTRIBUTING.md
(safe inside cache; partition key on identity for user-specific reads)
- app/layout: clarify global-error.tsx vs error.tsx shell requirements
given the passthrough root layout
- readme: Next.js 15 -> 16 (matches scaffolded version)