Skip to content
Merged
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
8 changes: 6 additions & 2 deletions src/app/api/documents/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { NextResponse } from "next/server";
import type { Json } from "@/lib/supabase/database.types";
import { z } from "zod";
import { rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { getDemoDocumentPayload } from "@/lib/demo-data";
import { env, isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
import { invalidateRagCachesForDocumentMutation } from "@/lib/rag";
import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access";
import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access";
import { writeAuditLog } from "@/lib/audit";
import { parseJsonBody } from "@/lib/validation/body";
import { parseRouteParams } from "@/lib/validation/params";
Expand Down Expand Up @@ -281,7 +282,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:

const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id.");
const supabase = createAdminClient();
const access = await publicAccessContext(request, supabase);
const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase);
if (rateLimit.limited) {
return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit);
}
const { data: document, error } = await withOwnerReadScope(
supabase.from("documents").select("*").eq("id", id),
access.ownerId,
Expand Down
8 changes: 6 additions & 2 deletions src/app/api/documents/[id]/search/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { demoChunks, getDemoDocument } from "@/lib/demo-data";
import { isDemoMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access";
import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access";
import { parseRouteParams } from "@/lib/validation/params";
import { parseRequestQuery, queryInteger } from "@/lib/validation/query";

Expand Down Expand Up @@ -182,7 +183,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:

const { id } = parseRouteParams({ id: rawId }, documentSearchParamsSchema, "Invalid document id.");
const supabase = createAdminClient();
const access = await publicAccessContext(request, supabase);
const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase);
if (rateLimit.limited) {
return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit);
}
const { data: document, error: documentError } = await withOwnerReadScope(
supabase.from("documents").select("id,metadata").eq("id", id),
access.ownerId,
Expand Down
8 changes: 6 additions & 2 deletions src/app/api/documents/[id]/signed-url/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { getDemoDocument } from "@/lib/demo-data";
import { env } from "@/lib/env";
import { isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access";
import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access";

export const runtime = "nodejs";

Expand All @@ -32,7 +33,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid document id.");

const supabase = createAdminClient();
const access = await publicAccessContext(_request, supabase);
const { access, rateLimit } = await enforceDocumentReadRateLimit(_request, supabase);
if (rateLimit.limited) {
return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit);
}
const { data: document, error } = await withOwnerReadScope(
supabase.from("documents").select("storage_path,file_type").eq("id", id),
access.ownerId,
Expand Down
8 changes: 6 additions & 2 deletions src/app/api/documents/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { demoDocuments } from "@/lib/demo-data";
import { isDemoMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access";
import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access";
import { parseRequestQuery, queryBoolean, queryInteger } from "@/lib/validation/query";

export const runtime = "nodejs";
Expand Down Expand Up @@ -131,7 +132,10 @@ export async function GET(request: Request) {
} = parseRequestQuery(request, documentListQuerySchema, "Invalid document list query.");

const supabase = createAdminClient();
const access = await publicAccessContext(request, supabase);
const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase);
if (rateLimit.limited) {
return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit);
}
let query = withOwnerReadScope(
supabase.from("documents").select(DOCUMENT_LIST_COLUMNS, { count: "exact" }),
access.ownerId,
Expand Down
8 changes: 6 additions & 2 deletions src/app/api/images/[id]/signed-url/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { getDemoImage } from "@/lib/demo-data";
import { env } from "@/lib/env";
import { isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access";
import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access";

export const runtime = "nodejs";

Expand All @@ -32,7 +33,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid image id.");

const supabase = createAdminClient();
const access = await publicAccessContext(_request, supabase);
const { access, rateLimit } = await enforceDocumentReadRateLimit(_request, supabase);
if (rateLimit.limited) {
return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit);
}
const { data: image, error } = await supabase
.from("document_images")
.select("document_id,storage_path,mime_type,caption,metadata")
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { env } from "@/lib/env";
import { assertAllowedFile, assertFileContentSignature, jsonError, PublicApiError } from "@/lib/http";
import {
allowRateLimitInMemoryFallbackOnUnavailable,
consumeSubjectApiRateLimit,
rateLimitJsonResponse,
} from "@/lib/api-rate-limit";
import { logger } from "@/lib/logger";
import { writeAuditLog } from "@/lib/audit";
import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming";
Expand Down Expand Up @@ -85,6 +90,20 @@ export async function POST(request: Request) {
supabase = createAdminClient();
const adminSupabase = supabase;
const user = await requireAuthenticatedUser(request, adminSupabase);

const rateLimit = await consumeSubjectApiRateLimit({
supabase: adminSupabase,
subject: { kind: "owner", ownerId: user.id },
bucket: "document_upload",
allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse(
"Upload is temporarily rate limited because too many requests were received. Retry shortly.",
rateLimit,
);
}

const formData = await request.formData().catch((cause) => {
throw new PublicApiError("Invalid upload form data.", 400, {
code: "invalid_form_data",
Expand Down
13 changes: 7 additions & 6 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"use client";
"use client";

import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
Expand Down Expand Up @@ -1355,8 +1355,8 @@ function SettingsClinicalContextStrip() {
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 truncate">
Private<span className="hidden min-[360px]:inline"> workspace</span>{" "}
<span className="px-1 text-[color:var(--text-soft)]">·</span> WA{" "}
<span className="px-1 text-[color:var(--text-soft)]">·</span> No PHI
<span className="px-1 text-[color:var(--text-soft)]">┬╖</span> WA{" "}
<span className="px-1 text-[color:var(--text-soft)]">┬╖</span> No PHI
</span>
</div>
);
Expand Down Expand Up @@ -3252,7 +3252,7 @@ export function ClinicalDashboard({
throw new Error("Search did not return usable results.");
}

// M10: discard a stale response a newer search owns the UI state.
// M10: discard a stale response ΓÇö a newer search owns the UI state.
if (requestId === searchRequestSeqRef.current) {
applySearchResult(successfulPayload, trimmedQuery);
if (successfulPayload.kind === "answer") {
Expand Down Expand Up @@ -3320,10 +3320,10 @@ export function ClinicalDashboard({
if (!autoRunSearch || !trimmedQuery || !canAutoRunMode || loading) return;
if (searchMode === "answer" && !answerThreadBootstrapped) return;
// Once an answer is on screen, composer edits are follow-up drafts and must
// only run on explicit submit not on every query keystroke while run=1
// only run on explicit submit ΓÇö not on every query keystroke while run=1
// keeps autoRunSearch enabled from the URL.
if (searchMode === "answer" && answer) return;
// After reload, the URL query matches the restored latest turn do not
// After reload, the URL query matches the restored latest turn ΓÇö do not
// archive it again into a duplicate prior turn.
if (searchMode === "answer" && latestAnswerQuery?.trim() === trimmedQuery) {
autoRunSearchSignatureRef.current = `${searchMode}:${trimmedQuery}`;
Expand Down Expand Up @@ -4489,6 +4489,7 @@ export function ClinicalDashboard({
followUpSuggestions={answerFollowUpSuggestions}
onPickFollowUpSuggestion={handlePickFollowUpSuggestion}
followUpSuggestionsDisabled={loading}
scrollContainerRef={mainRef}
/>
</>
) : null
Expand Down
27 changes: 24 additions & 3 deletions src/components/clinical-dashboard/answer-result-surface.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"use client";
"use client";

import Link from "next/link";
import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
import { ClipboardCheck, ExternalLink, Layers, ShieldAlert } from "lucide-react";

import { type AnswerFeedbackType } from "@/lib/answer-feedback";
import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions";
import { useCollapseWhenContentBelow } from "@/components/clinical-dashboard/use-collapse-when-content-below";
import { NaturalLanguageAnswer, UserQuestionBubble } from "@/components/clinical-dashboard/answer-content";
import {
AnswerSupportSummaryCard,
Expand Down Expand Up @@ -61,6 +62,7 @@ export function StagedAnswerResultSurface({
followUpSuggestions,
onPickFollowUpSuggestion,
followUpSuggestionsDisabled = false,
scrollContainerRef,
}: {
answer: RagAnswer;
query: string;
Expand All @@ -86,6 +88,7 @@ export function StagedAnswerResultSurface({
followUpSuggestions?: string[];
onPickFollowUpSuggestion?: (suggestion: string) => void;
followUpSuggestionsDisabled?: boolean;
scrollContainerRef?: RefObject<HTMLElement | null>;
}) {
const noteCount = clinicalNotesCount(answer);
const showClinicalNotes =
Expand Down Expand Up @@ -116,6 +119,8 @@ export function StagedAnswerResultSurface({
const clinicalNotesTriggerRef = useRef<HTMLButtonElement>(null);
const evidenceTriggerRef = useRef<HTMLButtonElement>(null);
const safetyTriggerRef = useRef<HTMLButtonElement>(null);
const supportActionRowRef = useRef<HTMLDivElement>(null);
const belowContentSentinelRef = useRef<HTMLDivElement>(null);
const copyQuotesTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
Expand Down Expand Up @@ -180,8 +185,15 @@ export function StagedAnswerResultSurface({
weakEvidence,
});
const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel);
const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support";
const evidenceTrustLabel = inlineEvidenceSummary.split(" ┬╖ ")[0] || "Review support";
const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer);
const showSupportActionRow = showClinicalNotes || showEvidenceDrawer;
const collapseActionRow = useCollapseWhenContentBelow({
containerRef: scrollContainerRef,
anchorRef: supportActionRowRef,
belowSentinelRef: belowContentSentinelRef,
disabled: !showSupportActionRow || !scrollContainerRef,
});
const showLayoutAside = Boolean(centralTable);

return (
Expand Down Expand Up @@ -222,6 +234,8 @@ export function StagedAnswerResultSurface({
clinicalTriggerRef={clinicalNotesTriggerRef}
evidenceTriggerRef={evidenceTriggerRef}
safetyTriggerRef={safetyTriggerRef}
actionRowRef={supportActionRowRef}
collapseActionRow={collapseActionRow}
safetyFindingsCount={safetyFindings.length}
onOpenClinicalNotes={openClinicalNotes}
onOpenEvidence={() => openEvidence(null)}
Expand All @@ -236,6 +250,12 @@ export function StagedAnswerResultSurface({
disabled={followUpSuggestionsDisabled}
/>
) : null}
<div
ref={belowContentSentinelRef}
data-testid="answer-support-below-sentinel"
className="h-0 w-0"
aria-hidden="true"
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sentinel skips mobile table content

Medium Severity

The below-content sentinel is positioned before the InlineTableCard, which causes useCollapseWhenContentBelow to misjudge available scrollable content. As a result, the Clinical notes/Evidence support row remains expanded even when a large table is still off-screen, particularly on mobile, potentially overlapping with fixed UI elements.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c7f2d68. Configure here.

</div>

{centralTable ? (
Expand Down Expand Up @@ -309,8 +329,9 @@ export function StagedAnswerResultSurface({
<Layers className="h-3.5 w-3.5" />
</span>
}
contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-2xl"
contentClassName="max-h-[88dvh] bg-[color:var(--surface-raised)] sm:max-h-[min(88dvh,44rem)] sm:max-w-3xl"
bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3"
desktopBackdropClassName="sm:bg-black/50"
returnFocusRef={evidenceTriggerRef}
portal
>
Expand Down
Loading