From eb5bc2db52b663522702489ff881f2d54e2d0026 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:50:27 +0800 Subject: [PATCH 1/4] feat: add pwa app and dashboard interaction updates --- .npmrc | 1 + docs/codebase-index.md | 30 +- ...lability-wip-review-handover-2026-07-15.md | 296 ++++++++++++++++++ docs/site-map.md | 15 +- package-lock.json | 43 ++- package.json | 14 +- playwright.config.ts | 1 + scripts/check-codebase-index-coverage.mjs | 86 +++-- scripts/check-github-action-pins.mjs | 16 +- scripts/check-m13-migration.ts | 14 + scripts/generate-site-map.ts | 61 +++- src/app/applications/page.tsx | 23 -- src/app/applications/route.ts | 11 + src/app/differentials/presentations/page.tsx | 27 -- src/app/differentials/presentations/route.ts | 21 ++ src/app/forms/[slug]/page.tsx | 14 +- src/app/layout.tsx | 1 + src/app/medications/page.tsx | 5 - src/app/medications/route.ts | 7 + src/app/page.tsx | 3 - src/app/services/[slug]/page.tsx | 14 +- .../clinical-dashboard/answer-status.tsx | 4 +- .../favourites-command-library-page.tsx | 4 +- .../master-search-header.tsx | 5 + ...ifferential-presentation-workflow-page.tsx | 2 +- .../differential-stream-page.tsx | 2 +- src/components/forms/form-detail-page.tsx | 109 +++---- src/components/forms/forms-home-page.tsx | 17 +- src/components/privacy-input-notice.tsx | 2 +- .../services/service-detail-page.tsx | 2 +- .../services/services-navigator-page.tsx | 185 ++++++++--- src/lib/legacy-home-redirect.ts | 27 ++ src/lib/rag-candidate-sources.ts | 270 +++++++++------- src/lib/rag.ts | 131 ++------ src/lib/service-navigator-metrics.ts | 48 +++ supabase/drift-manifest.json | 55 +--- ...00_patch_rag_and_corrector_scalability.sql | 144 +++++++++ ...14190000_document_table_facts_trgm_idx.sql | 11 + ...717010000_harden_rag_scalability_patch.sql | 116 +++++++ supabase/schema.sql | 46 ++- ...audit-content-services-regressions.test.ts | 219 +++++++++++++ .../audit-navigation-auth-regressions.test.ts | 130 ++++++++ tests/codebase-index-coverage.test.ts | 51 ++- tests/mobile-interaction-regressions.test.ts | 45 +++ tests/rag-chunk-load-cache.test.ts | 113 +++++++ tests/registry-corpus.test.ts | 19 ++ tests/retrieval-hydration-scope.test.ts | 51 +++ tests/site-map.test.ts | 37 ++- tests/supabase-schema.test.ts | 229 ++++++-------- tests/ui-accessibility.spec.ts | 52 ++- tests/ui-smoke.spec.ts | 11 +- tests/ui-tools.spec.ts | 54 +++- 52 files changed, 2221 insertions(+), 673 deletions(-) create mode 100644 docs/rag-scalability-wip-review-handover-2026-07-15.md delete mode 100644 src/app/applications/page.tsx create mode 100644 src/app/applications/route.ts delete mode 100644 src/app/differentials/presentations/page.tsx create mode 100644 src/app/differentials/presentations/route.ts delete mode 100644 src/app/medications/page.tsx create mode 100644 src/app/medications/route.ts create mode 100644 src/lib/legacy-home-redirect.ts create mode 100644 src/lib/service-navigator-metrics.ts create mode 100644 supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql create mode 100644 supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql create mode 100644 supabase/migrations/20260717010000_harden_rag_scalability_patch.sql create mode 100644 tests/audit-content-services-regressions.test.ts create mode 100644 tests/audit-navigation-auth-regressions.test.ts create mode 100644 tests/mobile-interaction-regressions.test.ts create mode 100644 tests/rag-chunk-load-cache.test.ts diff --git a/.npmrc b/.npmrc index b6f27f135..4ade96e63 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ engine-strict=true +allow-scripts=true diff --git a/docs/codebase-index.md b/docs/codebase-index.md index a09628bd6..2cfd095e0 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -68,17 +68,19 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### API routes (`src/app/api/`) -| Area | Routes | Entry files | -| ----------- | ----------------------------------------------------------------------- | --------------------------------------------------------------- | -| Answers | `/api/answer`, `/api/answer/stream`, `/api/answer-feedback` | `answer/route.ts`, `answer/stream/route.ts`, `answer-feedback/` | -| Search | `/api/search`, `/api/search/interaction` | `search/` | -| Upload | `/api/upload` | `upload/route.ts` | -| Documents | CRUD, bulk, reindex, labels, search, summarize, table-facts, signed-url | `documents/` | -| Ingestion | batches, jobs, retry, quality | `ingestion/` | -| Registry | records CRUD | `registry/records/` | -| Images | signed URLs | `images/[id]/signed-url/route.ts` | -| Ops | health, setup-status, local-project-id | `health/`, `setup-status/`, `local-project-id/` | -| Eval / jobs | eval cases, job state | `eval-cases/`, `jobs/` | +| Area | Routes | Entry files | +| ------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| Answers | `/api/answer`, `/api/answer/stream`, `/api/answer-feedback` | `answer/route.ts`, `answer/stream/route.ts`, `answer-feedback/` | +| Search | `/api/search`, `/api/search/interaction`, `/api/search/universal` | `search/` | +| Upload | `/api/upload` | `upload/route.ts` | +| Documents | `/api/documents`, `/api/documents/[id]`, bulk/reindex, labels, reviews, search, signed URLs, summaries, table facts | `documents/` | +| Differentials | `/api/differentials`, `/api/differentials/[slug]`, `/api/differentials/presentations/[slug]` | `differentials/` | +| Medications | `/api/medications`, `/api/medications/[slug]` | `medications/` | +| Ingestion | `/api/ingestion/batches`, `/api/ingestion/jobs`, retry, quality | `ingestion/` | +| Registry | `/api/registry/records`, `/api/registry/records/[slug]` | `registry/records/` | +| Images | `/api/images/[id]/signed-url` | `images/[id]/signed-url/route.ts` | +| Ops | `/api/health`, `/api/health/ready`, `/api/setup-status`, `/api/local-project-id` | `health/`, `setup-status/`, `local-project-id/` | +| Eval / jobs | `/api/eval-cases`, `/api/jobs` | `eval-cases/`, `jobs/` | --- @@ -151,12 +153,12 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map - **CLI:** `supabase/config.toml` — `indexing-v3-agent` function, `verify_jwt = false` - **Schema mirror:** `supabase/schema.sql` (reference; migrations are source of truth) -- **Migrations:** `supabase/migrations/*.sql` (~90 files, May–Jul 2026) +- **Migrations:** `supabase/migrations/*.sql` (chronological source of truth; do not hardcode a count) - **Drift policy:** `docs/supabase-migration-reconciliation.md` -### Core tables +### Schema tables -`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `clinical_registry_records`, `api_rate_limits`, `audit_logs`, `storage_cleanup_jobs` +`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `document_title_words`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `image_caption_cache`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `rag_visual_eval_cases`, `rag_visual_eval_runs`, `rag_answer_feedback`, `clinical_registry_records`, `clinical_registry_record_sources`, `medication_records`, `differential_records`, `source_review_events`, `api_rate_limits`, `api_rate_limit_subjects`, `audit_logs`, `storage_cleanup_jobs` **Storage buckets:** `clinical-documents`, `clinical-images` (private) diff --git a/docs/rag-scalability-wip-review-handover-2026-07-15.md b/docs/rag-scalability-wip-review-handover-2026-07-15.md new file mode 100644 index 000000000..ec2d3e6bb --- /dev/null +++ b/docs/rag-scalability-wip-review-handover-2026-07-15.md @@ -0,0 +1,296 @@ +# Handover — RAG scalability WIP review findings (2026-07-15) + +**Status:** findings + remediation plan recorded; **fixes not yet applied**. +**Review date:** 2026-07-15 +**Git state at review:** detached HEAD `570e6ba56ae60bea56a32801b9cc96c5a8dfde4f` (`feat(rag): ship D4/D5 governance levers…` / aligned with then-current main tip) plus uncommitted WIP listed below. +**Ledger row:** `docs/branch-review-ledger.md` — scope `thorough multi-lens review: WIP RAG/schema + clinical design/UI + architecture/bug-hunt`. +**Product context:** Clinical KB Next.js + Supabase; target project `Clinical KB Database` / `sjrfecxgysukkwxsowpy`. Provider-backed apply/eval requires explicit confirmation. + +This file is the single handoff artifact for the next agent or engineer. It consolidates the multi-lens review (design-review, architecture, bug-hunter, code-review, clinical UI) and the remediation plan. Do **not** ship the WIP as-is. + +--- + +## 1. Snapshot of the working tree (at review) + +### Modified + +| Path | Role in WIP | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `src/lib/rag-candidate-sources.ts` | New `ChunkLoadCache` + shared hydration caching | +| `src/lib/rag.ts` | Wires one `chunkLoadCache` into table-facts / embedding-field / index-unit paths | +| `src/lib/registry-corpus-links.ts` | Tightened `registryCorpusDetailHref` arg types | +| `supabase/schema.sql` | Appended registry cleanup, `document_title_words`, corrector vocab source change, wide table-facts trgm index | +| `tests/supabase-schema.test.ts` | Weak “identical” substring checks for new objects | +| `docs/branch-review-ledger.md` | Review record appended (allowed during pure review) | + +### Untracked (WIP) + +| Path | Notes | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql` | Registry cascade delete + title-words table + corrector rewrite (incomplete scalability) | +| `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` | Wide GIN trgm index — **mismatched** to live `trgm_matches` predicate | +| `pnpm-lock.yaml` | **Accidental** — repo is npm (`packageManager: npm@…`, committed `package-lock.json`). Delete; do not commit. | + +### Environment caveats + +- Many concurrent worktrees existed at review time; park this WIP on a **named feature branch** before committing. +- Stale `.next/types` `/applications` layout errors also appeared during typecheck; treat as separate from WIP registry typing failures (clear/regenerate `.next` if they persist after A1). + +--- + +## 2. Verdict + +| Severity | Count | Bottom line | +| -------- | ------- | ------------------------------------------------------------------------------------------------------------------------ | +| P0 | 0 | No immediate data-loss / clinical-harm P0 confirmed | +| P1 | 5 | Typecheck break; cache error poisoning; registry `::uuid` abort; unused corrector GIN; mismatched table-facts trgm index | +| P2 | several | Scope/race cache footguns; schema/migration/test drift; SECURITY DEFINER revoke gaps; design gradient density | +| P3 | several | Accidental pnpm lock; mockup hex drift; optional pill/glow pass | + +**Highest residual risk if shipped unchanged:** red typecheck + false-scalability migrations + registry delete brittleness + hydration cache coupling under transient DB errors. + +--- + +## 3. Findings (complete catalog) + +### P1 — must fix before merge + +#### F1. `registryCorpusDetailHref` typing breaks callers + +- **Where:** `src/lib/registry-corpus-links.ts` (params narrowed to `string` / `RegistryCorpusKind | string`). +- **Callers failing typecheck:** + - `src/app/api/documents/[id]/signed-url/route.ts` + - `src/components/clinical-dashboard/source-actions.tsx` + - `src/lib/citations.ts` + - `src/lib/universal-search.ts` +- **Trigger:** Pass `unknown` / nullable metadata fields into the helper. +- **Expected:** Runtime `typeof` guards accept loose metadata; types stay compatible. +- **Actual:** `npm run typecheck` fails (`Type 'unknown' is not assignable to type 'string'`). +- **Proof:** `npm run typecheck` (already red at review). +- **Fix:** Restore args to `unknown` (or `string | null | undefined`) and keep runtime guards. Prefer not changing every call site. + +#### F2. `ChunkLoadCache` negative-caches failures across parallel hydrations + +- **Where:** `src/lib/rag-candidate-sources.ts` (`loadChunksForSignalMatches`); wired in `src/lib/rag.ts` via shared `createChunkLoadCache()`. +- **Trigger:** `{ error }` or missing `data` on a batch fetch → empty `Map` → per-id promises resolve `null` and stick for the request. +- **Expected:** Transient failure degrades that path only; siblings can still hydrate overlapping ids. +- **Actual:** Shared cache poisons table-facts + embedding-field + index-unit for overlapping chunk/doc ids. +- **Proof:** Unit test with shared cache: first load mock `{ data: null, error }`; second healthy mock for same ids must still return chunks (today: `[]` from cache). +- **Fix:** Do not cache hard failures (leave keys unset or reject without caching). Optionally fail the batch loudly via existing RPC/hydration telemetry. + +#### F3. Registry cleanup trigger: non-UUID `registry_record_id` aborts deletes + +- **Where:** + - Migration `20260714180000_…` lines ~8–12 + - `supabase/schema.sql` appended `cleanup_registry_corpus_document` +- **SQL hazard:** `(metadata->>'registry_record_id')::uuid = OLD.id` +- **Trigger:** Delete from `clinical_registry_records` / `medication_records` / `differential_records` while any `documents` row has `source_kind = 'registry_record'` and a non-UUID `registry_record_id`. +- **Expected:** Ignore malformed metadata (elsewhere the repo regex-guards UUID casts). +- **Actual:** Cast can throw → **entire registry DELETE transaction fails**. SECURITY DEFINER amplifies blast radius. +- **Proof:** SQL insert dummy doc with `registry_record_id: 'not-a-uuid'`, delete a registry row → expect cast exception today. +- **Fix:** Compare as text: `metadata->>'registry_record_id' = OLD.id::text`. Prefer also match `registry_record_kind` (or map via `TG_TABLE_NAME`). Revoke `EXECUTE` on helpers from `public`/`anon`/`authenticated`. + +#### F4. Corrector “scalability” table/index unused by query path + +- **Where:** Migration `20260714180000_…` — `document_title_words`, GIN `document_title_words_word_trgm_idx`, rewritten `correct_clinical_query_terms`. +- **Trigger / pattern:** Function still `array_agg(distinct term)` over aliases ∪ all title words, then per-token `similarity()` over `unnest(vocab)`. +- **Expected:** Hot path probes the GIN (`%` / similarity lookup) so cost scales with candidates, not full vocab. +- **Actual:** GIN never used; write amplification on every title sync with no planner win. Large corpora → corrector latency/timeouts → weaker lexical/retrieval paths. +- **Proof:** Read function body; no `word % tok` / indexed probe. Schema tests today pass with mere `toContain`. +- **Fix (preferred):** Per-token indexed probe against aliases + `document_title_words` (`LIMIT 1`, keep `min_sim` and length 4–40). Cap candidates. Verify with `EXPLAIN` only under confirmed live access later. +- **Alt:** Drop unused GIN (or table) until rewrite lands — do not ship dead “scalability” scaffolding. + +#### F5. New table-facts trgm index does not match `trgm_matches` + +- **Where:** + - New: `20260714190000_document_table_facts_trgm_idx.sql` / `schema.sql` `document_table_facts_text_trgm_idx` (title + row + param + **threshold_value** + **action**) + - Live predicate: `schema.sql` `trgm_matches` (~6236–6244) uses only title + row + clinical_parameter + - Existing matching index: `document_table_facts_title_row_param_trgm_idx` +- **Expected:** GIN expression equals `%` / `similarity()` expression used by the RPC. +- **Actual:** Wide index is dead weight for current trgm path; dual overlapping GIN indexes raise ingest write cost. FTS already covers wider text via `search_tsv`. +- **Proof:** Diff index expression string vs `trgm_matches` expression (architecture agent correct; one conflicting note claimed alignment — **disproven** by schema lines above). +- **Fix (default):** Remove migration `…190000…` and schema index line; keep narrow index. +- **Alt:** Widen `trgm_matches` to the wide expression **and** drop the narrow index so only one remains. Add schema test for expression equality. + +--- + +### P2 — should fix in same remediation wave + +#### F6. Cache not keyed by access scope (latent authz) + +- **Where:** `ChunkLoadCache` maps keyed only by id; contrast `rag-cache.ts` which uses `retrievalAccessScopeKey`. +- **Current call site:** One scope per `searchChunksWithTelemetry` — production path OK today. +- **Risk:** Reuse across public-only then owner+public → cached `null` deny, or inverse over-share within process. +- **Fix:** Bind scope at cache creation or include scope in keys; refuse mismatched scope. Test in `tests/retrieval-hydration-scope.test.ts`. + +#### F7. Concurrent overlapping miss race + +- **Where:** Parallel embedding-field + index-unit hydration both compute `missing*` before either finishes `cache.*.set`. +- **Risk:** Later writer overwrites in-flight promise; erroring batch can poison a successful first waiters’ result. +- **Fix:** Single in-flight promise per id (check-then-set before creating fetch); never overwrite a healthy promise with a failing one. + +#### F8. Schema / migration / test drift + +| Concern | Migration `…180000…` | `schema.sql` | +| ------------------------------------------- | -------------------- | -------------------------------------------------- | +| `DROP TRIGGER IF EXISTS` before create | yes | **no** | +| Backfill `INSERT INTO document_title_words` | yes | **no** | +| Corrector body rewrite | yes | vocab source already updated; lifecycle incomplete | +| `…190000…` index | separate file | present in schema | +| New test “identically” | weak `toContain` | misses drops, backfill, expression contracts | + +- **Risk:** Schema-only bootstrap → empty title vocab until document writes; trigger-already-exists on reapply; CI green on broken contracts. +- **Fix:** Mirror lifecycle in schema; strengthen `tests/supabase-schema.test.ts` (load both migrations; assert text UUID compare, revoke lines, corrector probe shape, index↔RPC equality for remaining trgm index). + +#### F9. New SECURITY DEFINER helpers miss privilege hardening + +- **Where:** `cleanup_registry_corpus_document`, `sync_document_title_words` created without `revoke execute … from public, anon, authenticated` (unlike `correct_clinical_query_terms`). +- **Fix:** Revoke execute; grant only if something other than triggers must call them (usually none). + +#### F10. Title vocabulary is global under SECURITY DEFINER (tenancy product decision) + +- **Where:** Corrector reads all `document_title_words` with no owner filter; sync indexes every indexed title. +- **Risk:** Private title tokens can bias corrections / existence side-channel for other users (pre-existing shape had similar full-title scan). +- **Fix options:** Public-only (`owner_id is null`) ± caller-owner filter passed like retrieval RPCs. **Needs product decision** (see §7). + +#### F11. Favourites set accent gradient bars (design) + +- **Where:** `src/components/clinical-dashboard/favourites-library-nav.tsx` — `setAccentBars` with purple→rose / multi-hue gradients. +- **Why it matters:** Conflicts with clinical “dense, calm, scan-fast” (design-review). Decorative vs semantic. +- **Fix:** Single clinical accent or discrete set-color tokens without marketing multi-hue bars. Preserve selected/hover/`focus-visible`. + +--- + +### P3 — hygiene / optional + +#### F12. Accidental `pnpm-lock.yaml` + +- Delete untracked file; do not commit. Repo lockfile is `package-lock.json`. + +#### F13. Mockup hardcoded hex / focus colors + +- **Where:** `src/components/favourites-page-mockups/favourites-library-redesign-page.tsx` etc. (`#0e7490`, `#64748b`, …). +- Mockup routes under `/mockups/…`. Retokenize if long-lived; do not promote into production shell without tokens. + +#### F14. Pill / glow density (optional design pass) + +- Widespread `rounded-full` + soft glow is existing product chrome, not a defect. Separate intentional de-pill pass only if product wants it. +- **Non-goal:** Do **not** replace the clinical design system with the marketing `/10-experience-and-design-system` dark-cyan DTCG aesthetic. + +#### F15. Broader architecture residual (out of this WIP’s minimal fix) + +- `rag.ts` remains a large facade (~4.8k lines). Keep hydration/cache testable in `rag-candidate-sources`; avoid new re-exports that force everything through `rag.ts`. +- Process-global answer/search caches in `rag-cache.ts` are pre-existing scale/ops residual. + +#### F16. Cross-table shared cleanup key (low probability) + +- Same cleanup fn matches only `registry_record_id` across registry tables → theoretical UUID collision deletes wrong corpus doc. Mitigate with `registry_record_kind` filter (see F3). + +--- + +## 4. What looked solid (do not regress) + +- Intent of request-scoped dedupe for parallel signal hydrations is good (happy path). +- `document_title_words` RLS + service_role grants + sync-on-indexed status logic is coherent **once** the corrector actually queries the table. +- Existing narrow `document_table_facts_title_row_param_trgm_idx` correctly matches current `trgm_matches`. +- Ingestion ↛ importing `rag.ts` dependency direction is healthy — preserve it. +- Production clinical shell mostly uses `@theme` tokens, `focus-visible`, `prefers-reduced-motion`, and `forced-colors` in `src/app/globals.css`. + +--- + +## 5. Remediation plan (ordered) + +### Track A — correctness (same PR / branch) + +1. **Branch hygiene:** From detached HEAD + WIP, create/checkout named branch e.g. `codex/rag-scalability-review-remediation`. Delete `pnpm-lock.yaml`. +2. **F1:** Restore loose types on `registryCorpusDetailHref`. Run `npm run typecheck`. +3. **F2/F6/F7:** Add failing hydration-cache tests (error poisoning, scope reuse, concurrent overlap). Fix cache: no negative-cache on error; scope-bound; single in-flight per id. Green tests. +4. **F3/F9/F16:** Rewrite cleanup SQL (text + kind), revoke execute; sync migration + `schema.sql`. Prefer **edit migration before apply**; if any env already applied WIP SQL, ship a **follow-up** migration instead of rewriting history. +5. **F4:** Rewrite `correct_clinical_query_terms` to indexed per-token probes **or** drop unused GIN/table until rewrite ready. Sync schema. +6. **F5:** Default — remove `…190000…` and wide index from schema; keep narrow index. (Alt — widen RPC and drop narrow.) Assert expression parity in tests. +7. **F8:** Align schema lifecycle (`DROP TRIGGER IF EXISTS`; document or include backfill policy). Strengthen schema tests beyond substrings. + +### Track B — design (same PR optional, or follow-up) + +8. **F11:** Quiet favourites gradient bars to clinical tokens. +9. **F13:** Optionally retokenize mockups. +10. **F14:** Explicitly defer pill/glow unless requested. + +### Verification (local, no providers unless confirmed) + +| Gate | When | +| ----------------------------------------------------------------- | ---------------- | +| Focused Vitest: hydration cache + `tests/supabase-schema.test.ts` | After A2–A7 | +| `npm run typecheck` | After A1 | +| `npm run verify:cheap` (or lint + unit subset) | Before handoff | +| `git diff --check` | Before commit | +| `npm run ensure` + screenshots | If Track B ships | +| Live Supabase apply / `check:drift` / OpenAI eval | **Ask first** | + +### Rollout notes + +- Treat `…180000…` / `…190000…` as **unapplied** unless proven otherwise. Confirm with operator before any live mutation. +- After code + offline gates are green: commit on feature branch, open PR, run PR-local gate; live apply is a separate confirmation. + +--- + +## 6. Suggested implementation ownership (files) + +| Concern | Primary files | +| --------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Cache | `src/lib/rag-candidate-sources.ts`, `src/lib/rag.ts`, `tests/retrieval-hydration-scope.test.ts` (or new focused test) | +| Registry links typing | `src/lib/registry-corpus-links.ts` | +| SQL patch | `supabase/migrations/20260714180000_…`, possibly delete `…190000…`, `supabase/schema.sql` | +| Schema contracts | `tests/supabase-schema.test.ts` | +| Favourites design | `src/components/clinical-dashboard/favourites-library-nav.tsx` | +| Hygiene | delete `pnpm-lock.yaml` | + +--- + +## 7. Open questions (block product confidence) + +1. **Title vocab tenancy:** Should `correct_clinical_query_terms` use **public titles only**, or **public + caller-owner**? +2. **Table-facts trgm:** **Drop** the wide index (recommended) or **widen** `trgm_matches` and drop the narrow one? +3. **PR packaging:** Ship Track B (favourites design) in the **same** PR as RAG/SQL, or a follow-up? +4. **Live apply status:** Has any environment already applied `20260714180000` / `20260714190000`? (Assumed **no** at review time.) +5. **Corpus scale:** Approx. indexed doc / title-word cardinality — needed to prioritize F4 rewrite urgency vs drop-index interim. + +--- + +## 8. Checks already run (this review) + +| Check | Result | +| ------------------------------------------------------ | -------------------------------------------------------------------- | +| Static diff / SQL / RPC expression tracing | Done — findings above | +| Architecture explore + bug-hunt agents | Done — synthesized here | +| Clinical design / token / a11y greps | Done inline (`frontend-ui-reviewer` subagent blocked by usage limit) | +| `npm run typecheck` | **Fail** — F1 (+ possible stale `.next` noise) | +| Vitest / `verify:*` / browser / live Supabase / OpenAI | **Not run** | + +--- + +## 9. Next agent prompt (copy-paste) + +```text +Read docs/rag-scalability-wip-review-handover-2026-07-15.md and remediate Track A +findings F1–F9 (and F5 by dropping the mismatched trgm index unless told otherwise). +Park WIP on a named feature branch. Delete pnpm-lock.yaml. Do not apply live Supabase +or call OpenAI without confirmation. Use TDD for ChunkLoadCache. Strengthen +tests/supabase-schema.test.ts. End with typecheck + focused Vitest + verify:cheap +(or state what was skipped). Track B (favourites gradients) only if time / same PR +requested. Ask before commit/push/PR. +``` + +--- + +## 10. Related docs + +- `docs/codex-review-protocol.md` — severity / mutation / ledger rules +- `docs/branch-review-ledger.md` — this review’s ledger row +- `docs/design-system.md` / `docs/redesign/permanent-colour-direction.md` — clinical visual direction +- `docs/search-rag-master-context.md` — RAG orientation +- `AGENTS.md` — provider confirmation boundary, verify gates + +--- + +_Authored as a pure handover artifact from the 2026-07-15 multi-lens review. No production mutations performed._ diff --git a/docs/site-map.md b/docs/site-map.md index 8497dc327..4623d7b03 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -2,13 +2,11 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` to verify it is current. -## Main product pages +## Main product routes - `/` - Main Clinical KB shell. Source: `src/app/page.tsx`. -- `/applications` - Route discovered from app directory Source: `src/app/applications/page.tsx`. - `/differentials` - Differentials home and search surface. Source: `src/app/differentials/page.tsx`. - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/differentials/diagnoses/page.tsx`. -- `/differentials/presentations` - Presentation workflow stream. Source: `src/app/differentials/presentations/page.tsx`. - `/differentials/presentations/[slug]` - Route discovered from app directory Source: `src/app/differentials/presentations/[slug]/page.tsx`. - `/documents/search` - Documents search command centre. Source: `src/app/documents/search/page.tsx`. - `/documents/source` - Compatibility redirect to the canonical live document viewer when a valid id is supplied. Source: `src/app/documents/source/page.tsx`. @@ -22,7 +20,6 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/formulation/builder` - Structured clinical formulation builder. Source: `src/app/formulation/builder/page.tsx`. - `/formulation/compare` - Side-by-side mechanism comparison. Source: `src/app/formulation/compare/page.tsx`. - `/formulation/map` - Formulation mechanism domain map. Source: `src/app/formulation/map/page.tsx`. -- `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. - `/privacy` - Privacy and data-processing governance draft. Source: `src/app/privacy/page.tsx`. - `/reference/colour-coding` - Route discovered from app directory Source: `src/app/reference/colour-coding/page.tsx`. - `/services` - Services home and search surface. Source: `src/app/services/page.tsx`. @@ -802,6 +799,11 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/universal-search-command` - Route discovered from app directory Source: `src/app/mockups/universal-search-command/page.tsx`. - `/mockups/universal-search-redesign` - Route discovered from app directory Source: `src/app/mockups/universal-search-redesign/page.tsx`. +## Public utility route handlers + +- `/auth/callback` - Authentication callback handler. Source: `src/app/auth/callback/route.ts`. +- `/icons/[variant]` - Dynamically generated application icon handler. Source: `src/app/icons/[variant]/route.tsx`. + ## API routes - `/api/answer` - Generate answer response. Source: `src/app/api/answer/route.ts`. @@ -840,12 +842,13 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/api/search/universal` - Route discovered from app directory Source: `src/app/api/search/universal/route.ts`. - `/api/setup-status` - Setup status. Source: `src/app/api/setup-status/route.ts`. - `/api/upload` - Upload endpoint. Source: `src/app/api/upload/route.ts`. -- `/auth/callback` - Route discovered from app directory Source: `src/app/auth/callback/route.ts`. ## Redirects +- `/applications` - Redirects to `/tools`. Source: `src/app/applications/route.ts`. +- `/differentials/presentations` - Redirects to `/differentials/presentations/[workflow-slug]`. Source: `src/app/differentials/presentations/route.ts`. - `/documents/source` - Redirects to `/documents/search`. Source: `src/app/documents/source/page.tsx`. -- `/medications` - Redirects to `/?mode=prescribing`. Source: `src/app/medications/page.tsx`. +- `/medications` - Redirects to `/?mode=prescribing`. Source: `src/app/medications/route.ts`. - `/mockups/favourites-hub` - Redirects to `/favourites`. Source: `src/app/mockups/favourites-hub/page.tsx`. - `/mockups/medication-prescribing` - Redirects to `/medications/acamprosate`. Source: `src/app/mockups/medication-prescribing/page.tsx`. diff --git a/package-lock.json b/package-lock.json index 5e4db79c4..2986e1482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,14 @@ "hasInstallScript": true, "dependencies": { "@next/env": "16.2.10", + "@supabase/realtime-js": "^2.110.7", "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.108.2", "exceljs": "^4.4.0", "jszip": "^3.10.1", "lucide-react": "^1.22.0", "mammoth": "^1.12.0", - "next": "16.2.10", + "next": "^16.2.10", "openai": "^6.45.0", "pdf-parse": "^2.4.5", "pdfjs-dist": "^6.1.200", @@ -24,6 +25,7 @@ "postcss": "^8.5.15", "react": "19.2.7", "react-dom": "19.2.7", + "server-only": "^0.0.1", "zod": "^4.4.3" }, "devDependencies": { @@ -3309,9 +3311,9 @@ } }, "node_modules/@supabase/phoenix": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz", - "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", "license": "MIT" }, "node_modules/@supabase/postgrest-js": { @@ -3327,12 +3329,12 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.110.2", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.2.tgz", - "integrity": "sha512-z3jTOTPgyn6E3r6dVOOQ10He4yAMB2czjFw7xVdX3s16MHElna5rY1gVaePs0NIo6xvtMYbtmOXlFaFt/ePLpg==", + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.7.tgz", + "integrity": "sha512-AMtZjyFA2gsmjuxopPNS/sRznLQHG0Ht5x+ytTPTOh3vAcOTUlVRLx7gW4/CONNnbb3PKOkE+HmM35HOSbmomQ==", "license": "MIT", "dependencies": { - "@supabase/phoenix": "0.4.4", + "@supabase/phoenix": "0.4.5", "tslib": "2.8.1" }, "engines": { @@ -3380,6 +3382,25 @@ "node": ">=22.0.0" } }, + "node_modules/@supabase/supabase-js/node_modules/@supabase/phoenix": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz", + "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==", + "license": "MIT" + }, + "node_modules/@supabase/supabase-js/node_modules/@supabase/realtime-js": { + "version": "2.110.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.2.tgz", + "integrity": "sha512-z3jTOTPgyn6E3r6dVOOQ10He4yAMB2czjFw7xVdX3s16MHElna5rY1gVaePs0NIo6xvtMYbtmOXlFaFt/ePLpg==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.4", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -10307,6 +10328,12 @@ "semver": "bin/semver.js" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index 00a8c6125..6c163e952 100644 --- a/package.json +++ b/package.json @@ -168,13 +168,14 @@ }, "dependencies": { "@next/env": "16.2.10", + "@supabase/realtime-js": "^2.110.7", "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.108.2", "exceljs": "^4.4.0", "jszip": "^3.10.1", "lucide-react": "^1.22.0", "mammoth": "^1.12.0", - "next": "16.2.10", + "next": "^16.2.10", "openai": "^6.45.0", "pdf-parse": "^2.4.5", "pdfjs-dist": "^6.1.200", @@ -182,6 +183,7 @@ "postcss": "^8.5.15", "react": "19.2.7", "react-dom": "19.2.7", + "server-only": "^0.0.1", "zod": "^4.4.3" }, "overrides": { @@ -213,8 +215,16 @@ "playwright": "^1.61.1", "prettier": "^3.9.4", "tailwindcss": "^4.3.1", - "tsx": "^4.22.4", + "tsx": "^4.23.1", "typescript": "^6.0.0", "vitest": "^4.1.10" + }, + "allowScripts": { + "esbuild@0.28.1": true, + "sharp@0.34.5": true, + "unrs-resolver@1.12.2": true, + "@eslint/eslintrc@3.3.6": true, + "@humanwhocodes/module-importer@1.0.1": true, + "@humanfs/node@0.16.8": true } } diff --git a/playwright.config.ts b/playwright.config.ts index 268ccd250..aa1b899ee 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -55,6 +55,7 @@ export default defineConfig({ grepInvert: mockupTag, use: { ...devices["Desktop Chrome"], + reducedMotion: "no-preference", ...(chromiumExecutablePath ? { launchOptions: { executablePath: chromiumExecutablePath } } : {}), }, }, diff --git a/scripts/check-codebase-index-coverage.mjs b/scripts/check-codebase-index-coverage.mjs index 15a4d4686..b912467a0 100644 --- a/scripts/check-codebase-index-coverage.mjs +++ b/scripts/check-codebase-index-coverage.mjs @@ -22,6 +22,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const INDEX_PATH = "docs/codebase-index.md"; +const SCHEMA_PATH = "supabase/schema.sql"; // Directories intentionally not indexed at the top level. const ALLOWLIST = new Set([ @@ -29,38 +30,79 @@ const ALLOWLIST = new Set([ ]); function dirsIn(relativeDir) { - try { - return readdirSync(path.join(repoRoot, relativeDir), { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name); - } catch { - return []; - } + return readdirSync(path.join(repoRoot, relativeDir), { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); } -/** - * A directory counts as "indexed" when its base name appears anywhere in the index - * (case-insensitive). The index deliberately uses abbreviated forms — `validation/`, - * `extractors/document.ts`, grouped route tables — so matching the bare name (rather - * than a full `src/lib/x` path) is what actually catches a WHOLLY missing module - * without drowning in format false-positives. - */ export function coverageCandidates(kind, name) { - return [name]; + if (kind === "api") return [`/api/${name}`]; + if (kind === "route") return [`/${name}`]; + return [`${name}/`, `src/lib/${name}/`]; +} + +const SECTION_BOUNDS = { + route: ["### Product pages (`src/app/`)", "### API routes (`src/app/api/`)"], + api: ["### API routes (`src/app/api/`)", "## `src/lib/` module map"], + lib: ["## `src/lib/` module map", "## Supabase"], + schema: ["### Schema tables", "### Migration themes"], +}; + +function sectionText(indexText, kind) { + const [startMarker, endMarker] = SECTION_BOUNDS[kind] ?? []; + if (!startMarker) return ""; + const headingOffset = (marker, from = 0) => { + const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp(`^${escaped}\\r?$`, "m").exec(indexText.slice(from)); + return match ? from + match.index : -1; + }; + const start = headingOffset(startMarker); + if (start < 0) return ""; + const end = headingOffset(endMarker, start + startMarker.length); + return indexText.slice(start, end < 0 ? indexText.length : end); +} + +function codeSpans(text) { + return [...text.matchAll(/`([^`\r\n]+)`/g)].map((match) => match[1].trim().toLowerCase()); +} + +function candidateMatches(span, candidate) { + const normalized = candidate.toLowerCase(); + if (normalized.endsWith("/")) return span.startsWith(normalized); + return span === normalized || span.startsWith(`${normalized}/`); } /** Pure: given the index text and the discovered groups, return the uncovered entries. */ export function coverageGaps(indexText, groups, allowlist = ALLOWLIST) { - const haystack = indexText.toLowerCase(); + const spansByKind = new Map(["lib", "route", "api"].map((kind) => [kind, codeSpans(sectionText(indexText, kind))])); const gaps = []; for (const { kind, dir, name } of groups) { const full = `${dir}/${name}`; if (allowlist.has(full)) continue; - if (!haystack.includes(name.toLowerCase())) gaps.push({ full, kind, tried: [name] }); + const tried = coverageCandidates(kind, name); + const spans = spansByKind.get(kind) ?? []; + if (!tried.some((candidate) => spans.some((span) => candidateMatches(span, candidate)))) { + gaps.push({ full, kind, tried }); + } } return gaps; } +/** Pure: compare the exhaustive schema-table list in the index with the schema mirror. */ +export function schemaTableGaps(indexText, schemaText) { + const schemaTables = new Set( + [...schemaText.matchAll(/create\s+table(?:\s+if\s+not\s+exists)?\s+public\.([a-z0-9_]+)/gi)].map((match) => + match[1].toLowerCase(), + ), + ); + const tableSection = sectionText(indexText, "schema"); + const documentedTables = new Set(codeSpans(tableSection).filter((span) => /^[a-z][a-z0-9_]*$/.test(span))); + return { + missing: [...schemaTables].filter((table) => !documentedTables.has(table)).sort(), + stale: [...documentedTables].filter((table) => !schemaTables.has(table)).sort(), + }; +} + function discoverGroups() { const groups = []; for (const name of dirsIn("src/lib")) groups.push({ kind: "lib", dir: "src/lib", name }); @@ -74,16 +116,22 @@ function discoverGroups() { function main() { const indexText = readFileSync(path.join(repoRoot, INDEX_PATH), "utf8"); + const schemaText = readFileSync(path.join(repoRoot, SCHEMA_PATH), "utf8"); const groups = discoverGroups(); const gaps = coverageGaps(indexText, groups); + const tables = schemaTableGaps(indexText, schemaText); - if (gaps.length > 0) { + if (gaps.length > 0 || tables.missing.length > 0 || tables.stale.length > 0) { console.error(`\n${INDEX_PATH} is missing ${gaps.length} top-level module(s)/route(s):`); for (const g of gaps) console.error(` UNINDEXED ${g.full} (${g.kind}) — add it or allowlist it`); + for (const table of tables.missing) console.error(` UNINDEXED public.${table} (schema table)`); + for (const table of tables.stale) console.error(` STALE ${table} (not present in ${SCHEMA_PATH})`); console.error(`\nUpdate ${INDEX_PATH} so the agent-orientation map stays current.`); process.exit(1); } - console.log(`${INDEX_PATH} coverage OK: all ${groups.length} top-level modules/routes are indexed.`); + console.log( + `${INDEX_PATH} coverage OK: all ${groups.length} top-level modules/routes and ${tables.missing.length + tables.stale.length === 0 ? "all" : "checked"} schema tables are indexed.`, + ); } const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; diff --git a/scripts/check-github-action-pins.mjs b/scripts/check-github-action-pins.mjs index 6a82fae7b..82d195306 100644 --- a/scripts/check-github-action-pins.mjs +++ b/scripts/check-github-action-pins.mjs @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { validateActionReference } from "./github-action-pins.mjs"; import { yamlBlock } from "./yaml-contract.mjs"; @@ -10,10 +10,16 @@ const failures = []; const expectedSupabaseCliVersion = "2.108.0"; const expectedSupabaseCliVersionPattern = expectedSupabaseCliVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -for (const fileName of readdirSync(workflowDir) - .filter((name) => /\.ya?ml$/i.test(name)) - .sort()) { - const filePath = path.join(workflowDir, fileName); +function discoverGitHubActionFiles(workflowRoot) { + const workflowDir = path.join(workflowRoot, ".github", "workflows"); + if (!existsSync(workflowDir)) return []; + return readdirSync(workflowDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name)) + .map((entry) => path.join(workflowDir, entry.name)); +} + +for (const filePath of discoverGitHubActionFiles(process.cwd())) { + const fileName = path.relative(process.cwd(), filePath).replaceAll("\\", "/"); const lines = readFileSync(filePath, "utf8").split(/\r?\n/); lines.forEach((line, index) => { diff --git a/scripts/check-m13-migration.ts b/scripts/check-m13-migration.ts index 3ec4b6e20..7e09011ac 100644 --- a/scripts/check-m13-migration.ts +++ b/scripts/check-m13-migration.ts @@ -17,6 +17,20 @@ function missingMarkers(data: unknown) { } async function main() { + try { + const { createRequire, Module } = await import("module"); + const req = createRequire(import.meta.url); + const resolved = req.resolve("server-only"); + if (resolved) { + const serverOnlyStub = new Module(resolved); + serverOnlyStub.exports = {}; + serverOnlyStub.loaded = true; + req.cache[resolved] = serverOnlyStub; + } + } catch { + // ignore + } + const { createAdminClient } = await import("@/lib/supabase/admin"); const supabase = createAdminClient(); const { data, error } = await supabase.rpc("search_schema_health"); diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 921cf2b90..87ddeb945 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -16,7 +16,7 @@ const appDir = path.join(process.cwd(), "src", "app"); const siteMapPath = path.join(process.cwd(), "docs", "site-map.md"); const medicationSlugs = ["acamprosate"] as const; -type RouteKind = "page" | "api"; +type RouteKind = "page" | "handler"; type DiscoveredRoute = { route: string; @@ -31,13 +31,23 @@ type RedirectRoute = { type SiteMapData = { pageRoutes: DiscoveredRoute[]; + publicRouteHandlers: DiscoveredRoute[]; apiRoutes: DiscoveredRoute[]; redirects: RedirectRoute[]; nonRoutedMockupArtifacts: string[]; }; +const productRouteHandlerPaths = new Set(["/applications", "/differentials/presentations", "/medications"]); + +const documentedRedirectTargets: Record = { + "/applications": "/tools", + "/differentials/presentations": "/differentials/presentations/[workflow-slug]", + "/medications": "/?mode=prescribing", +}; + const routeDescriptions: Record = { "/": "Main Clinical KB shell.", + "/applications": "Legacy application launcher redirect to Tools.", "/differentials": "Differentials home and search surface.", "/differentials/diagnoses": "Diagnosis stream.", "/differentials/diagnoses/[slug]": "Differential diagnosis detail.", @@ -71,6 +81,11 @@ const routeDescriptions: Record = { "/specifiers/map": "Psychiatric specifier family map.", }; +const publicRouteHandlerDescriptions: Record = { + "/auth/callback": "Authentication callback handler.", + "/icons/[variant]": "Dynamically generated application icon handler.", +}; + const apiDescriptions: Record = { "/api/answer": "Generate answer response.", "/api/answer/stream": "Streaming answer response.", @@ -127,8 +142,16 @@ function routeSegment(segment: string) { return segment; } +function isApiRoute(route: string) { + return route === "/api" || route.startsWith("/api/"); +} + function fileToRoute(filePath: string, kind: RouteKind) { - const suffix = kind === "page" ? "page.tsx" : "route.ts"; + const suffix = path.basename(filePath); + const expectedSuffixes = kind === "page" ? ["page.tsx"] : ["route.ts", "route.tsx"]; + if (!expectedSuffixes.includes(suffix)) { + throw new Error(`Unsupported ${kind} route file: ${filePath}`); + } const relative = toPosixPath(path.relative(appDir, filePath)); const withoutFile = relative.slice(0, -suffix.length).replace(/\/$/, ""); const segments = withoutFile.split("/").filter(Boolean).map(routeSegment).filter(Boolean); @@ -149,8 +172,9 @@ function collectFiles(root: string, targetFileName: string): string[] { } function discoverRoutes(kind: RouteKind): DiscoveredRoute[] { - const targetFile = kind === "page" ? "page.tsx" : "route.ts"; - return collectFiles(appDir, targetFile) + const targetFiles = kind === "page" ? ["page.tsx"] : ["route.ts", "route.tsx"]; + return targetFiles + .flatMap((targetFile) => collectFiles(appDir, targetFile)) .map((file) => ({ route: fileToRoute(file, kind), file: toPosixPath(path.relative(process.cwd(), file)), @@ -158,12 +182,13 @@ function discoverRoutes(kind: RouteKind): DiscoveredRoute[] { .sort((left, right) => left.route.localeCompare(right.route) || left.file.localeCompare(right.file)); } -function discoverRedirects(pageRoutes: DiscoveredRoute[]): RedirectRoute[] { - return pageRoutes - .map((page) => { - const source = readFileSync(path.join(process.cwd(), page.file), "utf8"); - const target = source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; - return target ? { ...page, target } : null; +function discoverRedirects(routes: DiscoveredRoute[]): RedirectRoute[] { + return routes + .map((route) => { + const source = readFileSync(path.join(process.cwd(), route.file), "utf8"); + const target = + documentedRedirectTargets[route.route] ?? source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; + return target ? { ...route, target } : null; }) .filter((value): value is RedirectRoute => Boolean(value)) .sort((left, right) => left.route.localeCompare(right.route)); @@ -179,10 +204,13 @@ function discoverNonRoutedMockupArtifacts() { export function collectSiteMapData(): SiteMapData { const pageRoutes = discoverRoutes("page"); + const routeHandlers = discoverRoutes("handler"); + const publicRouteHandlers = routeHandlers.filter((route) => !isApiRoute(route.route)); return { pageRoutes, - apiRoutes: discoverRoutes("api"), - redirects: discoverRedirects(pageRoutes), + publicRouteHandlers, + apiRoutes: routeHandlers.filter((route) => isApiRoute(route.route)), + redirects: discoverRedirects([...pageRoutes, ...publicRouteHandlers]), nonRoutedMockupArtifacts: discoverNonRoutedMockupArtifacts(), }; } @@ -363,6 +391,9 @@ function renderSiteMapRaw(data = collectSiteMapData()) { ].includes(route.route), ); const mockupRoutes = data.pageRoutes.filter((route) => route.route.startsWith("/mockups")); + const publicUtilityRouteHandlers = data.publicRouteHandlers.filter( + (route) => !productRouteHandlerPaths.has(route.route), + ); const lines = [ "# Clinical KB Site Map", @@ -370,7 +401,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { "This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` to verify it is current.", "", ...section( - "Main product pages", + "Main product routes", productRoutes.map((route) => routeLine(route, routeDescriptions)), ), ...section("Mode/query routes", renderModeRoutes()), @@ -447,6 +478,10 @@ function renderSiteMapRaw(data = collectSiteMapData()) { ] : []), ]), + ...section( + "Public utility route handlers", + publicUtilityRouteHandlers.map((route) => routeLine(route, publicRouteHandlerDescriptions)), + ), ...section( "API routes", data.apiRoutes.map((route) => routeLine(route, apiDescriptions)), diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx deleted file mode 100644 index 15b66e16d..000000000 --- a/src/app/applications/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { redirect } from "next/navigation"; - -type ApplicationsPageProps = { - searchParams?: Promise>; -}; - -function forwardedSearchParams(params: Record) { - const forwarded = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - for (const item of Array.isArray(value) ? value : [value]) { - if (item !== undefined) forwarded.append(key, item); - } - } - return forwarded.toString(); -} - -// "Tools" is the canonical name and /tools the canonical route (PT-11); this -// legacy route only forwards old links and browser history. -export default async function ApplicationsRedirect({ searchParams }: ApplicationsPageProps) { - const params = searchParams ? await searchParams : {}; - const query = forwardedSearchParams(params); - redirect(query ? `/tools?${query}` : "/tools"); -} diff --git a/src/app/applications/route.ts b/src/app/applications/route.ts new file mode 100644 index 000000000..0f19d2c8c --- /dev/null +++ b/src/app/applications/route.ts @@ -0,0 +1,11 @@ +import { type NextRequest, NextResponse } from "next/server"; + +// "Tools" is the canonical name and /tools the canonical route (PT-11). A +// route handler redirects the incoming legacy request before React renders it. +export function GET(request: NextRequest) { + const destination = request.nextUrl.clone(); + destination.pathname = "/tools"; + return NextResponse.redirect(destination); +} + +export const HEAD = GET; diff --git a/src/app/differentials/presentations/page.tsx b/src/app/differentials/presentations/page.tsx deleted file mode 100644 index f69101827..000000000 --- a/src/app/differentials/presentations/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { redirect } from "next/navigation"; - -import { getPresentationWorkflowSelectionForDiagnosisIds } from "@/lib/differentials"; - -type DifferentialPresentationsRouteProps = { - searchParams?: Promise<{ query?: string | string[]; q?: string | string[]; ids?: string | string[] }>; -}; - -function firstSearchParam(value?: string | string[]) { - return Array.isArray(value) ? value[0] : value; -} - -export default async function DifferentialPresentationsRoute({ searchParams }: DifferentialPresentationsRouteProps) { - const params = searchParams ? await searchParams : {}; - const query = firstSearchParam(params.query ?? params.q)?.trim(); - const ids = firstSearchParam(params.ids)?.trim(); - const selectedIds = (ids ?? "") - .split(",") - .map((id) => id.trim()) - .filter(Boolean); - const selection = getPresentationWorkflowSelectionForDiagnosisIds(selectedIds); - const destinationParams = new URLSearchParams(); - if (query) destinationParams.set("q", query); - if (selection?.diagnosisIds.length) destinationParams.set("ids", selection.diagnosisIds.join(",")); - const suffix = destinationParams.size ? `?${destinationParams.toString()}` : ""; - redirect(`/differentials/presentations/${selection?.workflow.id ?? "acute-confusion-encephalopathy"}${suffix}`); -} diff --git a/src/app/differentials/presentations/route.ts b/src/app/differentials/presentations/route.ts new file mode 100644 index 000000000..001912f0f --- /dev/null +++ b/src/app/differentials/presentations/route.ts @@ -0,0 +1,21 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { getPresentationWorkflowSelectionForDiagnosisIds } from "@/lib/differentials"; + +export function GET(request: NextRequest) { + const query = (request.nextUrl.searchParams.get("query") ?? request.nextUrl.searchParams.get("q"))?.trim(); + const selectedIds = (request.nextUrl.searchParams.get("ids") ?? "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + const selection = getPresentationWorkflowSelectionForDiagnosisIds(selectedIds); + const destination = new URL( + `/differentials/presentations/${selection?.workflow.id ?? "acute-confusion-encephalopathy"}`, + request.url, + ); + if (query) destination.searchParams.set("q", query); + if (selection?.diagnosisIds.length) destination.searchParams.set("ids", selection.diagnosisIds.join(",")); + return NextResponse.redirect(destination); +} + +export const HEAD = GET; diff --git a/src/app/forms/[slug]/page.tsx b/src/app/forms/[slug]/page.tsx index 1f30542e4..3e8ddd973 100644 --- a/src/app/forms/[slug]/page.tsx +++ b/src/app/forms/[slug]/page.tsx @@ -1,15 +1,21 @@ import type { Metadata } from "next"; import { FormDetailClient } from "@/components/forms/form-detail-client"; +import { getFormRecord } from "@/lib/forms"; type FormRouteProps = { params: Promise<{ slug: string }>; }; -export const metadata: Metadata = { - title: "Form record - Forms - Clinical KB", - description: "Psychiatry form and workflow details.", -}; +export async function generateMetadata({ params }: FormRouteProps): Promise { + const { slug } = await params; + const form = getFormRecord(slug); + + return { + title: form ? `${form.title} - Forms - Clinical KB` : "Form record - Forms - Clinical KB", + description: form?.subtitle ?? "Psychiatry form and workflow details.", + }; +} export default async function FormRoute({ params }: FormRouteProps) { const { slug } = await params; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 61fa12ce0..421c68459 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; import { PwaLifecycle } from "@/components/pwa-lifecycle"; import { AuthProvider } from "@/lib/supabase/client"; +import { PwaLifecycle } from "@/components/pwa-lifecycle"; import { WebVitalsReporter } from "@/components/web-vitals-reporter"; import { resolveMetadataBase } from "@/lib/metadata-base"; import { APP_THEME_COLORS } from "@/lib/theme"; diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx deleted file mode 100644 index 64432c1b9..000000000 --- a/src/app/medications/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function MedicationsIndexPage() { - redirect("/?mode=prescribing"); -} diff --git a/src/app/medications/route.ts b/src/app/medications/route.ts new file mode 100644 index 000000000..c7ad52d16 --- /dev/null +++ b/src/app/medications/route.ts @@ -0,0 +1,7 @@ +import { type NextRequest, NextResponse } from "next/server"; + +export function GET(request: NextRequest) { + return NextResponse.redirect(new URL("/?mode=prescribing", request.url)); +} + +export const HEAD = GET; diff --git a/src/app/page.tsx b/src/app/page.tsx index 5dc55d31b..6ce30c137 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,9 +7,6 @@ import { appModeHomeHref, isAppModeId, isAppModeVisible, type AppModeId } from " type HomeProps = { searchParams?: Promise<{ mode?: string | string[]; - q?: string | string[]; - focus?: string | string[]; - run?: string | string[]; }>; }; diff --git a/src/app/services/[slug]/page.tsx b/src/app/services/[slug]/page.tsx index 9f934c0b9..4c1986d41 100644 --- a/src/app/services/[slug]/page.tsx +++ b/src/app/services/[slug]/page.tsx @@ -1,15 +1,21 @@ import type { Metadata } from "next"; import { ServiceDetailClient } from "@/components/services/service-detail-client"; +import { getServiceRecord } from "@/lib/services"; type ServiceRouteProps = { params: Promise<{ slug: string }>; }; -export const metadata: Metadata = { - title: "Service record - Services - Clinical KB", - description: "Clinical service record details and referral information.", -}; +export async function generateMetadata({ params }: ServiceRouteProps): Promise { + const { slug } = await params; + const service = getServiceRecord(slug); + + return { + title: service ? `${service.title} - Services - Clinical KB` : "Service record - Services - Clinical KB", + description: service?.subtitle ?? "Clinical service record details and referral information.", + }; +} export default async function ServiceRoute({ params }: ServiceRouteProps) { const { slug } = await params; diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index efba7abc5..3712812e5 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -101,12 +101,12 @@ export function AnswerEmptyState({ light text links (not pills) with a hairline divider — this also breaks the visual repetition of stacked equal-weight chip rows. */}
-
@@ -747,12 +720,12 @@ export function FormDetailPage({ form }: { form: FormRecord }) {
- +