18 Commits
Author SHA1 Message Date
tiennm99 8331ad5087 fix: close auth bypass in staff RPCs and tighten data-access boundaries
- set_staff_role/remove_staff guards were NULL-unsafe: an authenticated but
  unenrolled principal (possible on the shared auth pool) skipped the RAISE
  and could promote or delete staff; rewritten with the null-safe form.
- Report/prescription PDFs now require a clinical role and the invoice PDF a
  staff role, matching the screens that link to them; the role sets live in
  lib/db/roles.js and are shared by layouts, actions, and route handlers.
- Storage policies on bsk-checkup-media scoped to clinical roles.
- Billing RPCs serialize on a per-checkup advisory lock so a line rewrite can
  never commit after payment; mark_order_paid rejects deleted checkups and
  zero-line invoices.
- PDF customer lookups skip soft-deleted patients; invite rate limiter fails
  closed (SMTP spend is irrecoverable); patient search escapes LIKE wildcards.
2026-08-18 20:41:45 +07:00
tiennm99 b47d8fc3d1 refactor: convert TypeScript sources to JavaScript with JSDoc types
- app/, components/, lib/, i18n/, tests/, proxy renamed via git mv
- types carried into JSDoc: @template generics, @typedef aliases,
  /** @type */ casts where TS had as-casts or non-null assertions
- role tuple keeps its const assertion; the db-enum drift guard now
  checks the tuple values against the generated enum type
- use client/use server directives and server-only imports preserved
- prettier-formatted; vitest suite unchanged
2026-08-17 23:12:26 +07:00
tiennm99 2b1298eeb8 build: replace TypeScript toolchain with strict checkJs JS setup
- one strict checkJs jsconfig.json replaces tsconfig.json (keeps
  noUncheckedIndexedAccess); typecheck script now tsc -p jsconfig.json
- next.config.ts -> next.config.mjs; vitest/playwright configs -> .js
- db scripts run under plain node as .mjs; tsx devDep removed
- generated Supabase types move to types/supabase-bsk.d.ts and
  db:gen-types redirects there
- eslint drops eslint-config-next/typescript, adds eslint-plugin-jsdoc;
  factory-boundary no-restricted-imports globs now target .js
2026-08-17 23:12:25 +07:00
tiennm99 a44c9a9745 build: move from pnpm to npm
Replace pnpm-lock.yaml with package-lock.json. Four of the five overrides move
across unchanged, ranges intact.

postcss needed a different shape: npm rejects an override that disagrees with a
direct dependency (EOVERRIDE), and postcss was a devDependency at ^8.5.15 while
the override floored it at >=8.5.18. The devDependency now sits at the advisory
floor and the override references it as $postcss, so transitive copies follow
the same resolution the pnpm override produced.

brace-expansion is still deliberately not overridden. It resolves to 1.1.18 for
eslint's minimatch 3.x chain and 5.0.9 elsewhere; 1.1.18 is a patched 1.x, which
did not exist when the exception was written, so eslint runs clean and the
advisory is gone.

.npmrc held only pnpm-specific settings (verify-deps-before-run,
ignored-build-scripts-status-warn-only) and is removed. db:push, the Playwright
webServer command, and the secret-leak pathspec all referenced pnpm and would
have broken.
2026-08-17 13:42:18 +07:00
tiennm99 9f6eb24fb4 feat(reminders): recheck reminders + nightly media sweep (Phase 7)
- in-app reminders page: patients with recheck_date due/overdue or within 7
  days (clinical-gated, VN-local); sidebar nav + vi/en
- nightly Vercel Cron (/api/cron/nightly, CRON_SECRET-gated) enforces the
  7-day image retention window: removes stale Storage objects + soft-deletes
  rows, keeping the free-tier storage budget bounded
- CRON_SECRET added to env schema + .env.example; vercel.json cron 01:00 VN
2026-07-25 13:23:24 +07:00
tiennm99 f4840b8000 feat(reports): Excel export + revenue dashboard (Phase 6)
- visits Excel export route (admin/cashier, Node runtime, xlsx): month range,
  per-visit totals + payment status
- dashboard: today waiting/completed tiles + 7-day paid-revenue chart
  (recharts, admin/cashier only); VN-local dates, server-summed VND
- admin reports page (month -> Excel); sidebar nav; vi/en
2026-07-25 13:19:54 +07:00
tiennm99 835b5237ab feat(catalog): medicine & service catalogs (Phase 4 slice)
- bsk.medicines + bsk.services; integer-VND money (no floats); RLS enrolled
  read, admin write; soft-delete
- medicines: list + new/edit form (name, unit, sale/cost price, company, route)
- services: inline list + add form (name, price)
- admin-gated, audit-logged; sidebar nav + vi/en
2026-07-25 12:03:04 +07:00
tiennm99 1dc1e946a4 feat(queue): queue & checkup workflow with realtime (Phase 3)
- shifts + daily_queue_counters + checkups (status enum, vitals, diagnosis,
  conclusion, recheck); VN-local checkup_date so the daily queue rolls over
  correctly
- register_checkup RPC assigns queue numbers atomically (concurrency-safe);
  role-gated to clinical staff
- queue page: register form + today's list by shift + call-patient, with
  Supabase Realtime live refresh (RLS-authorized, bsk:queue channel)
- checkup form: single keyboard-tabbable screen (vitals -> diagnosis ->
  conclusion -> recheck -> status), audit-logged
- clinical-role route gates; nav + vi/en; Phase 3-8 roadmap under plans/
2026-07-25 11:56:02 +07:00
tiennm99 481259c08e feat(staff): staff management — roles + access removal (Phase 2 slice)
- 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
2026-07-25 11:45:23 +07:00
tiennm99 46d5ff9bd1 feat(templates): checkup template management (Phase 2 slice)
- bsk.checkup_templates (gender + jsonb field layout, soft-delete); RLS:
  enrolled read, admin write
- create/edit/deactivate Server Actions (admin-gated, audit-logged); field
  layout edited one-label-per-line, serialized to jsonb
- admin templates list + new + edit pages; sidebar nav; vi/en
2026-07-25 11:41:55 +07:00
tiennm99 4273cba2bb feat(patients): patient management with VN geo + accent search (Phase 2 slice)
- bsk.customers (soft-delete) + provinces/wards geo tables; RLS: enrolled
  read, clinical-role write; geo read-only
- accent-insensitive search via unaccent wrapper + search_customers RPC
  (SECURITY INVOKER so RLS applies); name/phone contains
- patients list+search, create, edit; shared form with cascading
  province -> ward dropdown; clinical-role route gate; nav + vi/en
- audit-logged mutations; seed-geo script (db:seed-geo) for the geo dataset
2026-07-25 11:33:40 +07:00
tiennm99 b9ff387a6d feat(settings): clinic information management (Phase 2 slice)
- bsk.clinic_settings singleton (boolean PK pinned true) with name,
  address, phone, barcode prefix; seeded blank row
- RLS: enrolled staff read, admin write; user-client upsert so RLS gates
- admin settings page + form; audit-logged; sidebar nav entry; vi/en
2026-07-25 02:30:42 +07:00
tiennm99 e2fb601b2a feat(doctors): admin doctor management (Phase 2 slice)
- 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.
2026-07-25 01:57:01 +07:00
tiennm99 164c6125c2 feat(auth): allowlist-gated admin bootstrap, rate limiting, audit log
- 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
2026-07-25 01:51:35 +07:00
tiennm99 9afd68a741 feat(phase-1): role-gated app shell + dashboard placeholder
- app/[locale]/(app)/layout.tsx: Server gate — getServerSession()
  redirects unauth users (defense in depth) and signs out + redirects
  the authed-but-no-role edge case. Renders AppShell with user/role/locale.
- app/[locale]/(app)/admin/layout.tsx: second gate via requireRole;
  non-admin → /[locale]/dashboard (not 404 — avoids confirming routes).
- app/[locale]/(app)/dashboard/page.tsx: placeholder showing email +
  role badge.
- lib/auth/role-menu.ts: ROLE_MENU mapping per AppRole → MenuItem[]
  with href + i18n labelKey + lucide icon.
- lib/auth/require-role.ts: server helper for the admin gate.
- components/app-shell/{app-shell,sidebar,sign-out-button,locale-switcher}.tsx:
  Server-rendered shell + sidebar that reads ROLE_MENU[role]; client
  locale switcher (native <select> fallback) and sign-out (<form action>
  with useFormStatus pending UX).
- components/ui/{badge,separator}.tsx: shadcn primitives.
- messages/{vi,en}.json: nav.*, app.*, dashboard.* keys (parity).

Gating chain: proxy.ts redirects unauth → /sign-in for /dashboard +
/admin prefixes; (app) layout enforces session+role; (app)/admin layout
additionally enforces role === 'admin'.
2026-05-25 17:53:44 +07:00
tiennm99 eb1af9013e feat(phase-1): admin enrollment (first-admin claim + invite flow)
- 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.
2026-05-25 17:47:47 +07:00
tiennm99 e514d710c4 feat(phase-1): sign-in/sign-out server actions + zod schema
- lib/auth/schemas.ts: SignInSchema (Zod v4, email + password 8-72),
  SignInState discriminated union (idle | error), parseSignIn helper.
  Framework-agnostic — no 'use server' so client RHF resolver can import.
- app/[locale]/(auth)/sign-in/actions.ts: signInAction (RHF/useActionState
  shape) + signOutAction. Flow: safeParse → signInWithPassword → enrollment
  check via bsk.app_users → redirect to /[locale]/dashboard. Wrong-password
  and unenrolled paths return the SAME generic error (auth.users
  enumeration defense). redirect() called outside try/catch per
  React 19 / Next 16 semantics.
- messages/{vi,en}.json: auth.signIn.* keys in parity.

Scope honored: no rate limit, no next= guard, no IP extraction.
2026-05-25 17:36:09 +07:00
tiennm99 129cbb7bf0 feat(phase-1): auth session wiring (proxy + layout + helpers)
- 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.
2026-05-25 17:30:41 +07:00