Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@
# generic-api-key rule on historical commit content scanned in PR history.
27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:22253
27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:47690
b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:21520
b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:46330
b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:21520
b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:46330
5 changes: 2 additions & 3 deletions docs/site-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check`
## Main product pages

- `/` - Main Clinical KB shell. Source: `src/app/page.tsx`.
- `/applications` - Application and tool launcher. 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`.
Expand Down Expand Up @@ -40,7 +39,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check`
| Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. |
| Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. |
| Medication | `/?mode=prescribing` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. |
| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | `/applications` launcher and tool detail panels inside tools mode. |
| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`). |

## Documents flow index

Expand Down Expand Up @@ -601,5 +600,5 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check`
| Differentials | `src/app/differentials, src/lib/differentials.ts` |
| Medications | `src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` |
| Documents | `src/app/documents, src/lib/document-flow-routes.ts` |
| Applications and tools | `src/app/applications, src/components/applications-launcher-page.tsx` |
| Tools | `src/components/applications-launcher-page.tsx` |
| Mockups | `src/app/mockups` |
9 changes: 9 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ const nextConfig: NextConfig = {
},
];
},
async redirects() {
return [
{
source: "/applications",
destination: "/?mode=tools",
permanent: true,
},
];
},
};

export default nextConfig;
5 changes: 2 additions & 3 deletions scripts/generate-site-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ type SiteMapData = {

const routeDescriptions: Record<string, string> = {
"/": "Main Clinical KB shell.",
"/applications": "Application and tool launcher.",
"/differentials": "Differentials home and search surface.",
"/differentials/diagnoses": "Diagnosis stream.",
"/differentials/diagnoses/[slug]": "Differential diagnosis detail.",
Expand Down Expand Up @@ -97,7 +96,7 @@ const routeOwnershipRows = [
["Differentials", "src/app/differentials, src/lib/differentials.ts"],
["Medications", "src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx"],
["Documents", "src/app/documents, src/lib/document-flow-routes.ts"],
["Applications and tools", "src/app/applications, src/components/applications-launcher-page.tsx"],
["Tools", "src/components/applications-launcher-page.tsx"],
["Mockups", "src/app/mockups"],
] as const;

Expand Down Expand Up @@ -279,7 +278,7 @@ function renderModePageIndex() {
mode: "Tools",
home: appModeHomeHref("tools"),
search: appModeHomeHref("tools", { query: "medications", focus: true, run: true }),
detail: "`/applications` launcher and tool detail panels inside tools mode.",
detail: "Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`).",
},
]);
}
Expand Down
5 changes: 5 additions & 0 deletions scripts/production-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ async function main() {
}
}

const productionLike = process.env.NODE_ENV === "production" || process.env.VERCEL_ENV === "production";
if (productionLike && !envModule.env.RAG_QUERY_HASH_SECRET) {
result.failures.push("RAG_QUERY_HASH_SECRET is required in a production-like environment.");
}

if (placeholderLooksLikeExample(envModule.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? "")) {
result.warnings.push("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY looks like a placeholder.");
}
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/differentials/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
const rateLimit = await consumeSubjectApiRateLimit({
supabase,
subject: access.rateLimitSubject,
bucket: "registry",
bucket: "differentials",
allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
});
if (rateLimit.limited) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/differentials/presentations/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
const rateLimit = await consumeSubjectApiRateLimit({
supabase,
subject: access.rateLimitSubject,
bucket: "registry",
bucket: "differentials",
allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
});
if (rateLimit.limited) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/differentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export async function GET(request: Request) {
const rateLimit = await consumeSubjectApiRateLimit({
supabase,
subject: access.rateLimitSubject,
bucket: "registry",
bucket: "differentials",
allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
});
if (rateLimit.limited) {
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/documents/[id]/reindex/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
isAtomicReindexCandidate,
isCommittedGenerationMetadata,
} from "@/lib/reindex-pipeline";
import { invalidateRagCachesForDocumentMutation } from "@/lib/rag";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseJsonBodyOrDefault } from "@/lib/validation/body";
Expand Down Expand Up @@ -174,6 +175,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
images: committedImages,
summary: enrichment.summary.summary,
});
invalidateRagCachesForDocumentMutation(user.id);
return NextResponse.json({
mode,
enrichment,
Expand Down
25 changes: 19 additions & 6 deletions src/app/api/documents/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,12 +502,11 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
// job (status "pending") racing this DELETE let the worker upload a new
// generation of image objects after the storage paths were enumerated,
// orphaning them permanently.
const { data: activeJobs, error: activeJobsError } = await supabase
.from("ingestion_jobs")
.select("id,status")
.eq("document_id", id)
.in("status", ["pending", "processing"])
.limit(1);
async function loadActiveJobs() {
return supabase.from("ingestion_jobs").select("id,status").eq("document_id", id).in("status", ["pending", "processing"]).limit(1);
}

const { data: activeJobs, error: activeJobsError } = await loadActiveJobs();

if (activeJobsError) throw new Error(activeJobsError.message);
if ((activeJobs ?? []).length > 0) {
Expand Down Expand Up @@ -557,6 +556,20 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
throw new Error(ledgerWarning ? `${message}; ${ledgerWarning}` : message);
}

const { data: lateActiveJobs, error: lateActiveJobsError } = await loadActiveJobs();
if (lateActiveJobsError) throw new Error(lateActiveJobsError.message);
if ((lateActiveJobs ?? []).length > 0) {
const message = "Document gained pending or processing indexing work during delete. Stop or wait for the worker before deleting.";
const ledgerWarning = await updateStorageCleanupJob({
supabase,
cleanupJobId,
status: "failed",
storageRemoved: 0,
warnings: [message],
});
throw new PublicApiError(ledgerWarning ? `${message}; ${ledgerWarning}` : message, 409);
}

const { error: deleteError } = await supabase.from("documents").delete().eq("id", id).eq("owner_id", user.id);
if (deleteError) {
const ledgerWarning = await updateStorageCleanupJob({
Expand Down
5 changes: 4 additions & 1 deletion src/app/api/documents/[id]/signed-url/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,15 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
const supabase = createAdminClient();
const access = await publicAccessContext(_request, supabase);
const { data: document, error } = await withOwnerReadScope(
supabase.from("documents").select("storage_path,file_type").eq("id", id),
supabase.from("documents").select("storage_path,file_type,status").eq("id", id),
access.ownerId,
).maybeSingle();

if (error) throw new Error(error.message);
if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 });
if (document.status && document.status !== "indexed") {
return NextResponse.json({ error: "Document not found." }, { status: 404 });
}

const storage = supabase.storage.from(env.SUPABASE_DOCUMENT_BUCKET);
const signed = shouldDownload
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/medications/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
const rateLimit = await consumeSubjectApiRateLimit({
supabase,
subject: access.rateLimitSubject,
bucket: "registry",
bucket: "medications",
allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
});
if (rateLimit.limited) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/medications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export async function GET(request: Request) {
const rateLimit = await consumeSubjectApiRateLimit({
supabase,
subject: access.rateLimitSubject,
bucket: "registry",
bucket: "medications",
allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
});
if (rateLimit.limited) {
Expand Down
1 change: 1 addition & 0 deletions src/app/api/setup-status/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { env, isDemoMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard";
import { createAdminClient } from "@/lib/supabase/admin";
import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health";
Expand Down
5 changes: 5 additions & 0 deletions src/app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { writeAuditLog } from "@/lib/audit";
import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { assertSafeLocalProjectRequest, localProjectOriginErrorResponse, UnsafeLocalProjectOriginError } from "@/lib/local-project-guard";
import { probeSupabaseHealth } from "@/lib/supabase/health";
import { optionalFormText, parseFormDataFields } from "@/lib/validation/form-data";

Expand Down Expand Up @@ -82,6 +83,7 @@ export async function POST(request: Request) {
let insertedDocumentOwnerId: string | null = null;

try {
assertSafeLocalProjectRequest(request);
supabase = createAdminClient();
const adminSupabase = supabase;
const user = await requireAuthenticatedUser(request, adminSupabase);
Expand Down Expand Up @@ -298,6 +300,9 @@ export async function POST(request: Request) {
if (error instanceof AuthenticationError) {
return unauthorizedResponse();
}
if (error instanceof UnsafeLocalProjectOriginError) {
return localProjectOriginErrorResponse(error);
}

return jsonError(error);
}
Expand Down
11 changes: 0 additions & 11 deletions src/app/applications/layout.tsx

This file was deleted.

5 changes: 0 additions & 5 deletions src/app/applications/loading.tsx

This file was deleted.

12 changes: 0 additions & 12 deletions src/app/applications/page.tsx

This file was deleted.

18 changes: 10 additions & 8 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1101,14 +1101,14 @@ summary::-webkit-details-marker {

.answer-footer-search-action,
.answer-footer-search-send {
height: 2.05rem;
width: 2.05rem;
height: 2.75rem;
width: 2.75rem;
}

.answer-footer-search-action svg,
.answer-footer-search-send svg {
height: 1rem;
width: 1rem;
height: 1.1rem;
width: 1.1rem;
}
}

Expand Down Expand Up @@ -1172,14 +1172,16 @@ summary::-webkit-details-marker {
@media (max-width: 430px) {
.answer-footer-search-action,
.answer-footer-search-send {
height: 2.05rem !important;
width: 2.05rem !important;
height: 2.75rem !important;
width: 2.75rem !important;
min-height: 2.75rem;
min-width: 2.75rem;
}

.answer-footer-search-action svg,
.answer-footer-search-send svg {
height: 1rem;
width: 1rem;
height: 1.125rem;
width: 1.125rem;
}
}

Expand Down
43 changes: 13 additions & 30 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1067,26 +1067,8 @@ function SettingsHelpFooter({ onClick }: { onClick: () => void }) {
);
}

function ToolsHub({
query,
onQueryChange,
desktopComposerSlotId,
showDetailPanel,
}: {
query: string;
onQueryChange: (nextQuery: string) => void;
desktopComposerSlotId?: string;
showDetailPanel?: boolean;
}) {
return (
<ApplicationsLauncherWorkspace
variant="dashboard-tools"
query={query}
onQueryChange={onQueryChange}
desktopComposerSlotId={desktopComposerSlotId}
showDetailPanel={showDetailPanel}
/>
);
function ToolsHub({ query, desktopComposerSlotId }: { query: string; desktopComposerSlotId?: string }) {
return <ApplicationsLauncherWorkspace query={query} desktopComposerSlotId={desktopComposerSlotId} />;
}

type MobileSectionFabItem = {
Expand Down Expand Up @@ -1587,7 +1569,7 @@ export function ClinicalDashboard({
const activeModeSearch = appModeSearchConfig(searchMode);
const activeModeResultKind = appModeResultKind(searchMode);
const requestQueryMode = appModeQueryMode(searchMode, queryMode);
const requestedRun = searchParams.get("run") === "1";

// Record matches come from the owner-scoped registry API (mock fixtures in
// demo mode); ranking stays client-side so live-typing behaviour is
// unchanged and the registry is fetched once per active mode.
Expand Down Expand Up @@ -2473,7 +2455,7 @@ export function ClinicalDashboard({
urlDocumentSearchBootstrappedRef.current = true;
void executeSearch(searchText, mode, scopeFilters);
// URL search intentionally runs once when the selected mode can execute.
// eslint-disable-next-line react-hooks/exhaustive-deps

}, [canRunSearch, answerThreadBootstrapped]);

useEffect(() => {
Expand Down Expand Up @@ -3073,17 +3055,23 @@ export function ClinicalDashboard({
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }));
if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode);

const requestId = ++searchRequestSeqRef.current;

try {
const shortcutQueryMode = appModeQueryMode(targetMode, queryMode);
const payload = await runWithRetries(() =>
requestSourceLibrarySearch(trimmedSearchText, sourceLibraryMode, filtersOverride, shortcutQueryMode),
);
if (requestId !== searchRequestSeqRef.current) return;
applySearchResult(payload);
} catch (requestError) {
if (requestId !== searchRequestSeqRef.current) return;
setError(requestError instanceof Error ? requestError.message : "Document search failed");
} finally {
setLoading(false);
setAnswerProgress(null);
if (requestId === searchRequestSeqRef.current) {
setLoading(false);
setAnswerProgress(null);
}
}
}

Expand Down Expand Up @@ -3958,12 +3946,7 @@ export function ClinicalDashboard({
}}
/>
) : activeModeResultKind === "tools" ? (
<ToolsHub
query={query}
onQueryChange={setQuery}
desktopComposerSlotId={desktopHomeComposerSlotId}
showDetailPanel={!requestedRun}
/>
<ToolsHub query={query} desktopComposerSlotId={desktopHomeComposerSlotId} />
) : activeModeResultKind === "favourites" ? (
<FavouritesHub
query={query}
Expand Down
Loading