openreader/src/app/api/documents/[id]/parsed/route.ts
Richard R b6be71e3b1 refactor(types,api): centralize PDF and TTS type exports to compute-core/types
Move and re-export all PDF parsing and TTS-related types from
@openreader/compute-core/types, eliminating local type duplication.
Update imports throughout the codebase to use the new centralized type
module. Remove obsolete worker-contract file and update tsconfig paths
and compute-core exports for the new types entry point. Refactor API
routes and job logic to use the new type imports and shared parse-state
healing utility. This streamlines type management and improves
consistency between compute and app layers.
2026-05-22 01:51:51 -06:00

233 lines
7.7 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import {
getParsedDocumentBlob,
getParsedDocumentBlobByKey,
isMissingBlobError,
isValidDocumentId,
} from '@/lib/server/documents/blobstore';
import { enqueueParsePdfJob } from '@/lib/server/jobs/parsePdfJob';
import {
normalizeParseStatus,
parseDocumentParseState,
stringifyDocumentParseState,
} from '@/lib/server/documents/parse-state';
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import type { ParsedPdfDocument } from '@/types/parsed-pdf';
export const dynamic = 'force-dynamic';
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
function hasAnyParsedBlocks(doc: ParsedPdfDocument | null): boolean {
if (!doc || !Array.isArray(doc.pages)) return false;
return doc.pages.some((page) => Array.isArray(page.blocks) && page.blocks.length > 0);
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const authCtxOrRes = await requireAuthContext(req);
if (authCtxOrRes instanceof Response) return authCtxOrRes;
const params = await ctx.params;
const id = (params.id || '').trim().toLowerCase();
const retryFailed = req.nextUrl.searchParams.get('retry') === '1';
if (!isValidDocumentId(id)) {
return NextResponse.json({ error: 'Invalid document id' }, { status: 400 });
}
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
const rows = (await db
.select({
id: documents.id,
userId: documents.userId,
parseState: documents.parseState,
parsedJsonKey: documents.parsedJsonKey,
})
.from(documents)
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{
id: string;
userId: string;
parseState: string | null;
parsedJsonKey: string | null;
}>;
const row = rows.find((candidate) => candidate.userId === storageUserId) ?? rows[0];
if (!row) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
let state = parseDocumentParseState(row.parseState);
state = await healStaleDocumentParseState({
documentId: id,
userId: row.userId,
state,
});
const effectiveStatus = normalizeParseStatus(state.status);
if (effectiveStatus === 'failed' && retryFailed) {
await db
.update(documents)
.set({
parseState: stringifyDocumentParseState({
status: 'pending',
progress: null,
updatedAt: Date.now(),
}),
})
.where(and(eq(documents.id, id), eq(documents.userId, row.userId)));
enqueueParsePdfJob({
documentId: id,
userId: row.userId,
namespace: testNamespace,
});
return NextResponse.json({ parseStatus: 'pending', parseProgress: null }, { status: 202 });
}
if (effectiveStatus !== 'ready') {
if (effectiveStatus === 'pending') {
enqueueParsePdfJob({
documentId: id,
userId: row.userId,
namespace: testNamespace,
});
}
return NextResponse.json({
parseStatus: effectiveStatus,
parseProgress: state.progress ?? null,
}, { status: 202 });
}
try {
const json = row.parsedJsonKey?.trim()
? await getParsedDocumentBlobByKey(row.parsedJsonKey)
: await getParsedDocumentBlob(id, testNamespace);
let parsedDoc: ParsedPdfDocument | null = null;
try {
parsedDoc = JSON.parse(Buffer.from(json).toString('utf8')) as ParsedPdfDocument;
} catch {
parsedDoc = null;
}
if (!hasAnyParsedBlocks(parsedDoc)) {
console.warn('[documents/parsed] parsed doc has no blocks', {
documentId: id,
userId: row.userId,
parsedJsonKey: row.parsedJsonKey,
});
}
return new NextResponse(new Uint8Array(json), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
} catch (error) {
if (isMissingBlobError(error)) {
return NextResponse.json({ parseStatus: 'failed', error: 'Parsed document not found' }, { status: 404 });
}
throw error;
}
} catch (error) {
console.error('Error reading parsed PDF:', error);
return NextResponse.json({ error: 'Failed to read parsed PDF' }, { status: 500 });
}
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const authCtxOrRes = await requireAuthContext(req);
if (authCtxOrRes instanceof Response) return authCtxOrRes;
const params = await ctx.params;
const id = (params.id || '').trim().toLowerCase();
if (!isValidDocumentId(id)) {
return NextResponse.json({ error: 'Invalid document id' }, { status: 400 });
}
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
const rows = (await db
.select({
id: documents.id,
userId: documents.userId,
parseState: documents.parseState,
parsedJsonKey: documents.parsedJsonKey,
})
.from(documents)
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{
id: string;
userId: string;
parseState: string | null;
parsedJsonKey: string | null;
}>;
const row = rows.find((candidate) => candidate.userId === storageUserId) ?? rows[0];
if (!row) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
let state = parseDocumentParseState(row.parseState);
state = await healStaleDocumentParseState({
documentId: id,
userId: row.userId,
state,
});
const effectiveStatus = normalizeParseStatus(state.status);
if (effectiveStatus !== 'running') {
await db
.update(documents)
.set({
parseState: stringifyDocumentParseState({
status: 'pending',
progress: null,
updatedAt: Date.now(),
}),
})
.where(and(eq(documents.id, id), eq(documents.userId, row.userId)));
}
enqueueParsePdfJob({
documentId: id,
userId: row.userId,
namespace: testNamespace,
forceToken: randomUUID(),
});
return NextResponse.json(
{
parseStatus: effectiveStatus === 'running' ? 'running' : 'pending',
parseProgress: effectiveStatus === 'running' ? (state.progress ?? null) : null,
},
{ status: 202 },
);
} catch (error) {
console.error('Error forcing parsed PDF refresh:', error);
return NextResponse.json({ error: 'Failed to force parsed PDF refresh' }, { status: 500 });
}
}