Add survey side recovery from source PDFs
Card-level reprocessing only re-OCRs existing images. When the old page count bug caused survey pages to never be extracted, those images don't exist to reprocess. New /api/cards/recover-survey endpoint: - Finds all cards missing frontImagePath (survey side) - Locates their source PDFs in MinIO via the ProcessingJob - Re-extracts the correct survey page for each card - Stores the image and runs OCR to fill in survey data - Processes sequentially with rate-limit delays Settings page shows amber banner when missing backs are detected with a one-click "Recover Missing Backs" button. Made-with: Cursor
This commit is contained in:
parent
393114e9be
commit
27bdf9d415
2 changed files with 194 additions and 0 deletions
145
src/app/api/cards/recover-survey/route.ts
Normal file
145
src/app/api/cards/recover-survey/route.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getBuffer, uploadBuffer } from "@/lib/minio";
|
||||
import { pdfToImages, imageToBase64 } from "@/lib/pdf";
|
||||
import { ocrImage } from "@/lib/ai-ocr";
|
||||
|
||||
const DELAY_BETWEEN_OCR_MS = 3_000;
|
||||
|
||||
export async function GET() {
|
||||
const missing = await prisma.responseCard.count({
|
||||
where: { frontImagePath: null, backImagePath: { not: null } },
|
||||
});
|
||||
return NextResponse.json({ missingCount: missing });
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const cards = await prisma.responseCard.findMany({
|
||||
where: { frontImagePath: null, backImagePath: { not: null } },
|
||||
select: { id: true, sourceFile: true, name: true },
|
||||
});
|
||||
|
||||
if (cards.length === 0) {
|
||||
return NextResponse.json({ ok: true, recovered: 0, message: "No cards with missing survey sides" });
|
||||
}
|
||||
|
||||
const sourceFiles = [...new Set(cards.map((c) => c.sourceFile).filter(Boolean))] as string[];
|
||||
console.log(`[recover-survey] ${cards.length} card(s) missing survey, from ${sourceFiles.length} source file(s)`);
|
||||
|
||||
(async () => {
|
||||
let recovered = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const sourceFile of sourceFiles) {
|
||||
try {
|
||||
const jobIdMatch = sourceFile.match(/^sources\/([^/]+)\//);
|
||||
if (!jobIdMatch) {
|
||||
console.warn(`[recover-survey] Cannot parse job ID from sourceFile: ${sourceFile}`);
|
||||
continue;
|
||||
}
|
||||
const jobId = jobIdMatch[1];
|
||||
|
||||
const job = await prisma.processingJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) {
|
||||
console.warn(`[recover-survey] Job ${jobId} not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedCardIds = (job.cardIds as string[] | null) ?? [];
|
||||
const affectedCards = cards.filter((c) => c.sourceFile === sourceFile);
|
||||
const isPdf = job.fileName.toLowerCase().endsWith(".pdf");
|
||||
|
||||
if (!isPdf) {
|
||||
console.log(`[recover-survey] Skipping non-PDF source: ${sourceFile}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let pdfBuffer: Buffer;
|
||||
try {
|
||||
pdfBuffer = await getBuffer(sourceFile);
|
||||
} catch {
|
||||
console.warn(`[recover-survey] Source PDF not found in storage: ${sourceFile}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[recover-survey] Re-extracting pages from ${sourceFile}`);
|
||||
const pageImages = await pdfToImages(pdfBuffer);
|
||||
console.log(`[recover-survey] Got ${pageImages.length} pages from ${job.fileName}`);
|
||||
|
||||
for (const card of affectedCards) {
|
||||
const cardIndex = orderedCardIds.indexOf(card.id);
|
||||
if (cardIndex === -1) {
|
||||
console.warn(`[recover-survey] Card ${card.id} not found in job ${jobId} cardIds`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const surveyPageIndex = cardIndex * 2 + 1;
|
||||
if (surveyPageIndex >= pageImages.length) {
|
||||
console.log(`[recover-survey] Card "${card.name}" (${card.id}): no survey page at index ${surveyPageIndex} (only ${pageImages.length} pages)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const surveyImage = pageImages[surveyPageIndex];
|
||||
const imgKey = `images/${card.id}/survey.jpg`;
|
||||
|
||||
try {
|
||||
await uploadBuffer(imgKey, surveyImage.buffer, "image/jpeg");
|
||||
await prisma.responseCard.update({
|
||||
where: { id: card.id },
|
||||
data: { frontImagePath: imgKey },
|
||||
});
|
||||
|
||||
const base64 = await imageToBase64(surveyImage.buffer);
|
||||
const result = await ocrImage(base64, "survey");
|
||||
|
||||
await prisma.responseCard.update({
|
||||
where: { id: card.id },
|
||||
data: {
|
||||
messageTopics: result.data.messageTopics ?? [],
|
||||
messageTopicsOther: asString(result.data.messageTopicsOther),
|
||||
nextStep: result.data.nextStep ?? [],
|
||||
attendanceDuration: asString(result.data.attendanceDuration),
|
||||
campusPreference: result.data.campusPreference ?? [],
|
||||
campusPreferenceOther: asString(result.data.campusPreferenceOther),
|
||||
howHeard: result.data.howHeard ?? [],
|
||||
howHeardOther: asString(result.data.howHeardOther),
|
||||
serviceAttended: asString(result.data.serviceAttended),
|
||||
},
|
||||
});
|
||||
|
||||
recovered++;
|
||||
console.log(`[recover-survey] Recovered survey for "${card.name}" (${card.id})`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error(`[recover-survey] Failed OCR for card ${card.id}:`, err);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, DELAY_BETWEEN_OCR_MS));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[recover-survey] Error processing source ${sourceFile}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[recover-survey] Done: ${recovered} recovered, ${failed} failed`);
|
||||
})().catch((err) => {
|
||||
console.error("[recover-survey] Background task failed:", err);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
queued: cards.length,
|
||||
sourceFiles: sourceFiles.length,
|
||||
message: `Recovery started for ${cards.length} card(s) from ${sourceFiles.length} PDF(s)`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[recover-survey POST]", error);
|
||||
return NextResponse.json({ error: "Recovery failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function asString(v: unknown): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
return String(v);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
LayoutGrid,
|
||||
Globe,
|
||||
Check,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Header } from "@/components/layout/header";
|
||||
|
|
@ -191,6 +192,8 @@ export default function SettingsPage() {
|
|||
const [subscribing, setSubscribing] = React.useState(false);
|
||||
const [pushingAll, setPushingAll] = React.useState(false);
|
||||
const [webhookTestStatus, setWebhookTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle");
|
||||
const [recoverCount, setRecoverCount] = React.useState<number | null>(null);
|
||||
const [recovering, setRecovering] = React.useState(false);
|
||||
const [notifPrefs, setNotifPrefs] = React.useState<NotificationPrefs>(DEFAULT_NOTIFICATION_PREFS);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -226,6 +229,10 @@ export default function SettingsPage() {
|
|||
setLoading(false);
|
||||
});
|
||||
fetchCleanupStatus();
|
||||
fetch("/api/cards/recover-survey")
|
||||
.then((r) => r.json())
|
||||
.then((data) => { if (data.missingCount !== undefined) setRecoverCount(data.missingCount); })
|
||||
.catch(() => {});
|
||||
}, [fetchCleanupStatus]);
|
||||
|
||||
const handleSave = async () => {
|
||||
|
|
@ -467,6 +474,24 @@ export default function SettingsPage() {
|
|||
});
|
||||
};
|
||||
|
||||
const recoverSurveys = async () => {
|
||||
setRecovering(true);
|
||||
try {
|
||||
const res = await fetch("/api/cards/recover-survey", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success(data.message || `Recovery started for ${data.queued} card(s)`);
|
||||
setRecoverCount(0);
|
||||
} else {
|
||||
toast.error(data.error || "Recovery failed");
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to start recovery");
|
||||
} finally {
|
||||
setRecovering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runCleanup = async () => {
|
||||
setCleaning(true);
|
||||
try {
|
||||
|
|
@ -722,6 +747,30 @@ export default function SettingsPage() {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recoverCount !== null && recoverCount > 0 && (
|
||||
<div className="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-amber-700 dark:text-amber-300">{recoverCount}</span>{" "}
|
||||
card(s) are missing their survey (back) side.
|
||||
<span className="block mt-0.5 text-xs text-muted-foreground">
|
||||
Re-extracts missing pages from the original source PDFs and runs OCR.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 rounded-xl border-amber-500/30 text-amber-700 hover:bg-amber-500/10 dark:text-amber-300"
|
||||
onClick={recoverSurveys}
|
||||
disabled={recovering}
|
||||
>
|
||||
{recovering ? <Loader2 className="mr-2 size-3 animate-spin" /> : <RefreshCw className="mr-2 size-3" />}
|
||||
Recover Missing Backs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue