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: 10 additions & 3 deletions src/app/api/documents/[id]/labels/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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 Down Expand Up @@ -40,6 +41,9 @@ 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 Down Expand Up @@ -84,7 +88,8 @@ async function selectLabels(supabase: ReturnType<typeof createAdminClient>, docu

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

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

export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const { id: rawId } = await params;
const { id } = parseRouteParams({ id: rawId }, labelsRouteParamsSchema, "Invalid document id.");
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 number Diff line number Diff line change
Expand Up @@ -203,6 +203,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, 400);
return jsonError(error);
}
}
11 changes: 9 additions & 2 deletions src/app/api/documents/[id]/summarize/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
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 } = await params;
const { id: rawId } = await params;
const { id } = parseRouteParams({ id: rawId }, summarizeRouteParamsSchema, "Invalid document id.");
if (isDemoMode()) {
if (!getDemoDocument(id)) {
return NextResponse.json({ error: "Demo document not found." }, { status: 404 });
Expand All @@ -32,6 +39,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, 400);
return jsonError(error);
}
}
31 changes: 26 additions & 5 deletions src/app/api/ingestion/batches/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
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 Down Expand Up @@ -40,19 +46,34 @@ function batchesResponse(batches: BatchRow[], extra: Record<string, unknown> = {

export async function GET(request: Request) {
try {
if (isDemoMode()) return batchesResponse([], { demoMode: true });
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 },
});
}

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

if (error) throw new Error(error.message);
return batchesResponse((data ?? []) as unknown as BatchRow[]);
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,
},
});
} catch (error) {
if (error instanceof AuthenticationError) return unauthorizedResponse();
return jsonError(error);
Expand Down
11 changes: 9 additions & 2 deletions src/app/api/ingestion/jobs/[id]/retry/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
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 } = await params;
const { id: rawId } = await params;
const { id } = parseRouteParams({ id: rawId }, ingestionRetryRouteParamsSchema, "Invalid ingestion job id.");
const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);

Expand Down Expand Up @@ -79,6 +86,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, 400);
return jsonError(error);
}
}
30 changes: 23 additions & 7 deletions src/app/api/ingestion/jobs/route.ts
Original file line number Diff line number Diff 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 } from "@/lib/validation/query";
import { optionalUuidQuery, parseRequestQuery, queryInteger } from "@/lib/validation/query";

export const runtime = "nodejs";

Expand All @@ -13,6 +13,8 @@ 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 Down Expand Up @@ -46,24 +48,38 @@ function jobsResponse(jobs: JobRow[], extra: Record<string, unknown> = {}) {

export async function GET(request: Request) {
try {
if (isDemoMode()) return jobsResponse([], { demoMode: true });
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 },
});
}

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)")
.select("*, documents!inner(title,file_name,status,owner_id)", { count: "exact" })
.eq("documents.owner_id", user.id)
.order("created_at", { ascending: false })
.limit(100);
.range(offset, offset + limit - 1);

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

const { data, error } = await query;
const { data, error, count } = await query;
if (error) throw new Error(error.message);
return jobsResponse((data ?? []) as unknown as JobRow[]);
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,
},
});
} catch (error) {
if (error instanceof AuthenticationError) return unauthorizedResponse();
return jsonError(error);
Expand Down
36 changes: 31 additions & 5 deletions src/app/api/jobs/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
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 Down Expand Up @@ -42,21 +48,41 @@ 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()) {
return jobsResponse(demoJobs, { demoMode: true });
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,
},
});
}

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

if (error) throw new Error(error.message);
return jobsResponse((data ?? []) as unknown as JobRow[]);
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,
},
});
} catch (error) {
if (error instanceof AuthenticationError) {
return unauthorizedResponse();
Expand Down
10 changes: 2 additions & 8 deletions src/app/api/search/interaction/route.ts
Original file line number Diff line number Diff 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 { PublicApiError } from "@/lib/http";
import { jsonError } from "@/lib/http";
import {
normalizedQueryTextForStorage,
queryDerivedTokensForStorage,
Expand Down Expand Up @@ -114,12 +114,6 @@ export async function POST(request: Request) {
if (error instanceof serverAuth.AuthenticationError) {
return serverAuth.unauthorizedResponse(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 });
return jsonError(error);
}
}
16 changes: 13 additions & 3 deletions tests/api-route-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@ type BatchRow = {
status: string;
};

function createQueryMock<T>(result: { data: T; error: { message: string } | null }) {
function createQueryMock<T>(result: { data: T; error: { message: string } | null; count?: number | 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 }) => void,
resolve: (value: { data: T; error: { message: string } | null; count?: number | null }) => void,
reject?: (reason?: unknown) => void,
) => Promise.resolve(result).then(resolve, reject),
} as {
Expand All @@ -38,8 +39,9 @@ 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 }) => void,
resolve: (value: { data: T; error: { message: string } | null; count?: number | null }) => void,
reject?: (reason?: unknown) => void,
) => Promise<unknown>;
};
Expand All @@ -49,6 +51,7 @@ 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 Down Expand Up @@ -143,6 +146,13 @@ 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