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
13 changes: 3 additions & 10 deletions src/app/api/documents/[id]/labels/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,6 @@ import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import type { DocumentLabel, DocumentLabelType } from "@/lib/types";
import { parseJsonBody } from "@/lib/validation/body";
import { parseRouteParams } from "@/lib/validation/params";

export const runtime = "nodejs";

Expand DownExpand Up@@ -41,9 +40,6 @@ const manualLabelUpdateSchema = manualLabelSchema.extend({
const manualLabelDeleteSchema = z.object({
labelId: z.string().uuid(),
});
const labelsRouteParamsSchema = z.object({
id: z.string().uuid(),
});

function parseManualLabel(input: z.infer<typeof manualLabelSchema>) {
const normalized = normalizeDocumentLabelForStorage({
Expand DownExpand Up@@ -88,8 +84,7 @@ async function selectLabels(supabase: ReturnType<typeof createAdminClient>, docu

export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id: rawId } = await params;
const { id } = parseRouteParams({ id: rawId }, labelsRouteParamsSchema, "Invalid document id.");
const { id } = await params;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore UUID validation for label route ids

When the path id is not a UUID, e.g. /api/documents/not-a-uuid/labels, this now passes the raw string into requireOwnedDocument, which filters documents.id even though supabase/schema.sql defines that column as uuid. Postgres/PostgREST rejects that as an input-syntax error, and this route's jsonError(error) default turns it into a 500 instead of the prior 400 validation response; PATCH and DELETE have the same raw-param path. Please validate the route id before any Supabase query.

Useful? React with 👍 / 👎.

if (isDemoMode()) {
return NextResponse.json({ error: "Demo documents cannot be curated." }, { status: 400 });
}
Expand DownExpand Up@@ -146,8 +141,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:

export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id: rawId } = await params;
const { id } = parseRouteParams({ id: rawId }, labelsRouteParamsSchema, "Invalid document id.");
const { id } = await params;
if (isDemoMode()) {
return NextResponse.json({ error: "Demo documents cannot be curated." }, { status: 400 });
}
Expand DownExpand Up@@ -212,8 +206,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id

export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id: rawId } = await params;
const { id } = parseRouteParams({ id: rawId }, labelsRouteParamsSchema, "Invalid document id.");
const { id } = await params;
if (isDemoMode()) {
return NextResponse.json({ error: "Demo documents cannot be curated." }, { status: 400 });
}
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/documents/[id]/reindex/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,6 +198,6 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ job }, { status: 201 });
} catch (error) {
if (error instanceof AuthenticationError) return unauthorizedResponse();
return jsonError(error);
return jsonError(error, 400);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve 5xx status for reindex failures

For a valid reindex request, failures while reading the document, updating its status, or inserting the ingestion job now pass ordinary Errors to jsonError(error, 400), making a backend/Supabase outage look like a client bad request. The reindex UI and operators need to distinguish retryable server failures from invalid input, so unexpected reindex errors should keep the default 500 response.

Useful? React with 👍 / 👎.

}
}
11 changes: 2 additions & 9 deletions src/app/api/documents/[id]/summarize/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { demoSummary, getDemoDocument } from "@/lib/demo-data";
import { isDemoMode } from "@/lib/env";
import { summarizeDocument } from "@/lib/rag";
import { jsonError } from "@/lib/http";
import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseRouteParams } from "@/lib/validation/params";

export const runtime = "nodejs";

const summarizeRouteParamsSchema = z.object({
id: z.string().uuid(),
});

export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id: rawId } = await params;
const { id } = parseRouteParams({ id: rawId }, summarizeRouteParamsSchema, "Invalid document id.");
const { id } = await params;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate summary ids before consuming rate limit

When the document id in the path is malformed, this now accepts the raw value and reaches consumeApiRateLimit before summarizeDocument eventually rejects the UUID filter. That means invalid URLs can consume the user's document_summarize quota, and a rate-limited user can get 429 instead of the validation error for a bad id. Validate the route param before demo/auth/rate-limit work as the previous schema boundary did.

Useful? React with 👍 / 👎.

if (isDemoMode()) {
if (!getDemoDocument(id)) {
return NextResponse.json({ error: "Demo document not found." }, { status: 404 });
Expand All@@ -39,6 +32,6 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
if (error instanceof Error && error.message === "Document not found.") {
return NextResponse.json({ error: "Document not found." }, { status: 404 });
}
return jsonError(error);
return jsonError(error, 400);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve 5xx status for summary backend failures

When the request is valid but summarizeDocument fails because Supabase or the model provider is unavailable, this catch now sends ordinary Errors through jsonError(error, 400), so clients see a non-retryable bad request rather than the server/backend failure it is. The route already special-cases authentication and missing documents, so unexpected summary-generation failures should keep the default 500 envelope.

Useful? React with 👍 / 👎.

}
}
31 changes: 5 additions & 26 deletions src/app/api/ingestion/batches/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isDemoMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseRequestQuery, queryInteger } from "@/lib/validation/query";

export const runtime = "nodejs";

const ACTIVE_BATCH_STATUSES = new Set(["queued", "processing"]);
const ACTIVE_INDEXING_POLL_MS = 5_000;
const ingestionBatchesQuerySchema = z.object({
limit: queryInteger({ fallback: 20, min: 1, max: 200 }),
offset: queryInteger({ fallback: 0, min: 0, max: 10_000 }),
});

type BatchRow = Record<string, unknown> & { status?: string | null };

Expand DownExpand Up@@ -46,34 +40,19 @@ function batchesResponse(batches: BatchRow[], extra: Record<string, unknown> = {

export async function GET(request: Request) {
try {
const { limit, offset } = parseRequestQuery(request, ingestionBatchesQuerySchema, "Invalid ingestion batches query.");
if (isDemoMode()) {
return batchesResponse([], {
demoMode: true,
pagination: { limit, offset, total: 0, nextOffset: offset, hasMore: false },
});
}
if (isDemoMode()) return batchesResponse([], { demoMode: true });

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);
const { data, error, count } = await supabase
const { data, error } = await supabase
.from("import_batches")
.select("*", { count: "exact" })
.select("*")
.eq("owner_id", user.id)
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);
.limit(20);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep paging available for ingestion batches

For workspaces with more than 20 import batches, /api/ingestion/batches?offset=20 now ignores the requested page and always returns only the newest 20 rows. The indexing monitor builds its active/failed batch view from this feed, so older failed batches can disappear from the API/UI once enough newer batches exist; restore limit/offset handling and pagination metadata instead of hard-capping the query.

Useful? React with 👍 / 👎.


if (error) throw new Error(error.message);
const batches = (data ?? []) as unknown as BatchRow[];
return batchesResponse(batches, {
pagination: {
limit,
offset,
total: count ?? batches.length,
nextOffset: offset + batches.length,
hasMore: count === null ? batches.length === limit : offset + batches.length < count,
},
});
return batchesResponse((data ?? []) as unknown as BatchRow[]);
} catch (error) {
if (error instanceof AuthenticationError) return unauthorizedResponse();
return jsonError(error);
Expand Down
11 changes: 2 additions & 9 deletions src/app/api/ingestion/jobs/[id]/retry/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,16 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { env, isDemoMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseRouteParams } from "@/lib/validation/params";

export const runtime = "nodejs";

const ingestionRetryRouteParamsSchema = z.object({
id: z.string().uuid(),
});

export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
if (isDemoMode()) return NextResponse.json({ error: "Retry is unavailable in demo mode." }, { status: 400 });

const { id: rawId } = await params;
const { id } = parseRouteParams({ id: rawId }, ingestionRetryRouteParamsSchema, "Invalid ingestion job id.");
const { id } = await params;
const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);

Expand DownExpand Up@@ -86,6 +79,6 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ job: data });
} catch (error) {
if (error instanceof AuthenticationError) return unauthorizedResponse();
return jsonError(error);
return jsonError(error, 400);
}
}
30 changes: 7 additions & 23 deletions src/app/api/ingestion/jobs/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { isDemoMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { optionalUuidQuery, parseRequestQuery, queryInteger } from "@/lib/validation/query";
import { optionalUuidQuery, parseRequestQuery } from "@/lib/validation/query";

export const runtime = "nodejs";

Expand All@@ -13,8 +13,6 @@ const ACTIVE_INDEXING_POLL_MS = 5_000;

const ingestionJobsQuerySchema = z.object({
batchId: optionalUuidQuery(),
limit: queryInteger({ fallback: 100, min: 1, max: 200 }),
offset: queryInteger({ fallback: 0, min: 0, max: 10_000 }),
});

type JobRow = Record<string, unknown> & { status?: string | null };
Expand DownExpand Up@@ -48,38 +46,24 @@ function jobsResponse(jobs: JobRow[], extra: Record<string, unknown> = {}) {

export async function GET(request: Request) {
try {
const { batchId, limit, offset } = parseRequestQuery(request, ingestionJobsQuerySchema, "Invalid ingestion jobs query.");
if (isDemoMode()) {
return jobsResponse([], {
demoMode: true,
pagination: { limit, offset, total: 0, nextOffset: offset, hasMore: false },
});
}
if (isDemoMode()) return jobsResponse([], { demoMode: true });

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);
const { batchId } = parseRequestQuery(request, ingestionJobsQuerySchema, "Invalid ingestion jobs query.");

let query = supabase
.from("ingestion_jobs")
.select("*, documents!inner(title,file_name,status,owner_id)", { count: "exact" })
.select("*, documents!inner(title,file_name,status,owner_id)")
.eq("documents.owner_id", user.id)
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);
.limit(100);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep paging available for ingestion jobs

For users with more than 100 ingestion jobs, /api/ingestion/jobs?offset=100 now ignores the requested page and always returns only the newest 100 rows. The dashboard's indexing monitor and retry controls are fed from this endpoint, so older failed jobs become unreachable from the API/UI once a busy workspace has enough newer jobs; restore the limit/offset handling and pagination metadata instead of hard-capping the feed.

Useful? React with 👍 / 👎.


if (batchId) query = query.eq("batch_id", batchId);

const { data, error, count } = await query;
const { data, error } = await query;
if (error) throw new Error(error.message);
const jobs = (data ?? []) as unknown as JobRow[];
return jobsResponse(jobs, {
pagination: {
limit,
offset,
total: count ?? jobs.length,
nextOffset: offset + jobs.length,
hasMore: count === null ? jobs.length === limit : offset + jobs.length < count,
},
});
return jobsResponse((data ?? []) as unknown as JobRow[]);
} catch (error) {
if (error instanceof AuthenticationError) return unauthorizedResponse();
return jsonError(error);
Expand Down
36 changes: 5 additions & 31 deletions src/app/api/jobs/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { demoJobs } from "@/lib/demo-data";
import { isDemoMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseRequestQuery, queryInteger } from "@/lib/validation/query";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const ACTIVE_JOB_STATUSES = new Set(["pending", "processing"]);
const ACTIVE_INDEXING_POLL_MS = 5_000;
const jobsQuerySchema = z.object({
limit: queryInteger({ fallback: 30, min: 1, max: 200 }),
offset: queryInteger({ fallback: 0, min: 0, max: 10_000 }),
});

type JobRow = Record<string, unknown> & { status?: string | null };

Expand DownExpand Up@@ -48,41 +42,21 @@ function jobsResponse(jobs: JobRow[], extra: Record<string, unknown> = {}) {

export async function GET(request: Request) {
try {
const { limit, offset } = parseRequestQuery(request, jobsQuerySchema, "Invalid jobs query.");
if (isDemoMode()) {
const jobs = demoJobs.slice(offset, offset + limit);
return jobsResponse(jobs, {
demoMode: true,
pagination: {
limit,
offset,
total: demoJobs.length,
nextOffset: offset + jobs.length,
hasMore: offset + jobs.length < demoJobs.length,
},
});
return jobsResponse(demoJobs, { demoMode: true });
}

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);
const { data, error, count } = await supabase
const { data, error } = await supabase
.from("ingestion_jobs")
.select("*, documents!inner(title,file_name,status)", { count: "exact" })
.select("*, documents!inner(title,file_name,status)")
.eq("documents.owner_id", user.id)
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);
.limit(30);

if (error) throw new Error(error.message);
const jobs = (data ?? []) as unknown as JobRow[];
return jobsResponse(jobs, {
pagination: {
limit,
offset,
total: count ?? jobs.length,
nextOffset: offset + jobs.length,
hasMore: count === null ? jobs.length === limit : offset + jobs.length < count,
},
});
return jobsResponse((data ?? []) as unknown as JobRow[]);
} catch (error) {
if (error instanceof AuthenticationError) {
return unauthorizedResponse();
Expand Down
10 changes: 8 additions & 2 deletions src/app/api/search/interaction/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { normalizedClinicalSearchTokens } from "@/lib/clinical-search";
import { isDemoMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { PublicApiError } from "@/lib/http";
import {
normalizedQueryTextForStorage,
queryDerivedTokensForStorage,
Expand DownExpand Up@@ -114,6 +114,12 @@ export async function POST(request: Request) {
if (error instanceof serverAuth.AuthenticationError) {
return serverAuth.unauthorizedResponse(error);
}
return jsonError(error);
if (error instanceof z.ZodError) {
return NextResponse.json({ ok: false }, { status: 400 });
}
if (error instanceof PublicApiError) {
return NextResponse.json({ ok: false }, { status: error.status });
}
return NextResponse.json({ ok: false }, { status: 500 });
}
}
2 changes: 1 addition & 1 deletion src/app/api/upload/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,6 @@ export async function POST(request: Request) {
return unauthorizedResponse();
}

return jsonError(error);
return jsonError(error, 400);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve 5xx status for upload persistence failures

When a valid upload reaches this catch because Supabase storage, document insert, or job insert fails, the thrown ordinary Error is passed to jsonError(error, 400), so the client sees a 400 client-input failure instead of a 5xx server persistence failure. That breaks status-based retries/alerts for real backend outages; validation failures already use PublicApiError, so unexpected errors should keep the default 500 behavior.

Useful? React with 👍 / 👎.

}
}
16 changes: 3 additions & 13 deletions tests/api-route-coverage.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,16 +21,15 @@ type BatchRow = {
status: string;
};

function createQueryMock<T>(result: { data: T; error: { message: string } | null; count?: number | null }) {
function createQueryMock<T>(result: { data: T; error: { message: string } | null }) {
const chain = {
select: null as unknown as ReturnType<typeof vi.fn>,
eq: null as unknown as ReturnType<typeof vi.fn>,
in: null as unknown as ReturnType<typeof vi.fn>,
order: null as unknown as ReturnType<typeof vi.fn>,
limit: null as unknown as ReturnType<typeof vi.fn>,
range: null as unknown as ReturnType<typeof vi.fn>,
then: (
resolve: (value: { data: T; error: { message: string } | null; count?: number | null }) => void,
resolve: (value: { data: T; error: { message: string } | null }) => void,
reject?: (reason?: unknown) => void,
) => Promise.resolve(result).then(resolve, reject),
} as {
Expand All@@ -39,9 +38,8 @@ function createQueryMock<T>(result: { data: T; error: { message: string } | null
in: ReturnType<typeof vi.fn>;
order: ReturnType<typeof vi.fn>;
limit: ReturnType<typeof vi.fn>;
range: ReturnType<typeof vi.fn>;
then: (
resolve: (value: { data: T; error: { message: string } | null; count?: number | null }) => void,
resolve: (value: { data: T; error: { message: string } | null }) => void,
reject?: (reason?: unknown) => void,
) => Promise<unknown>;
};
Expand All@@ -51,7 +49,6 @@ function createQueryMock<T>(result: { data: T; error: { message: string } | null
chain.in = vi.fn(() => chain);
chain.order = vi.fn(() => chain);
chain.limit = vi.fn(() => chain);
chain.range = vi.fn(() => chain);
return chain;
}

Expand DownExpand Up@@ -146,13 +143,6 @@ describe("/api/ingestion/jobs", () => {
hasActiveJobs: false,
pollAfterMs: null,
demoMode: true,
pagination: {
limit: 100,
offset: 0,
total: 0,
nextOffset: 0,
hasMore: false,
},
});
expect(response.headers.get("x-indexing-active")).toBe("false");
expect(createAdminClient).not.toHaveBeenCalled();
Expand Down
Loading
Loading