Add database auto-migration, storage cleanup, and startup fixes

- Add start.sh that runs prisma db push before starting the server,
  creating tables automatically on first deploy
- Copy prisma CLI + engines into production image for runtime migrations
- Add configurable storage cleanup: source PDF retention (default 30d)
  and image retention (default 180d) with manual trigger in Settings
- Add /api/cleanup endpoint (GET for status, POST to run)
- Add retention settings to AppSettings schema
- Add ListObjectsV2 helper to minio.ts for bulk cleanup

Tested: local Docker build passes
Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-03-10 13:21:05 -05:00
parent 1c4a474ad7
commit 3b8d580ded
9 changed files with 289 additions and 5 deletions

View file

@ -32,6 +32,10 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/dotenv ./node_modules/dotenv
COPY --from=builder /app/start.sh ./start.sh
RUN mkdir -p /data/watch && chown -R nextjs:nodejs /data RUN mkdir -p /data/watch && chown -R nextjs:nodejs /data
@ -41,4 +45,4 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"] CMD ["sh", "start.sh"]

2
package-lock.json generated
View file

@ -7,6 +7,7 @@
"": { "": {
"name": "echos-ocr", "name": "echos-ocr",
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0",
@ -19,6 +20,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^17.3.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"minio": "^8.0.7", "minio": "^8.0.7",
"next": "16.1.6", "next": "16.1.6",

View file

@ -24,6 +24,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^17.3.1",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"minio": "^8.0.7", "minio": "^8.0.7",
"next": "16.1.6", "next": "16.1.6",

View file

@ -79,4 +79,7 @@ model AppSettings {
model String @default("llava:7b") model String @default("llava:7b")
watchDir String @default("") watchDir String @default("")
watching Boolean @default(false) watching Boolean @default(false)
sourceRetentionDays Int @default(30)
imageRetentionDays Int @default(180)
} }

View file

@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { deleteObject, listObjects } from "@/lib/minio";
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const dryRun = body.dryRun === true;
let settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
if (!settings) {
settings = await prisma.appSettings.create({
data: { id: "singleton" },
});
}
const now = new Date();
const sourcesCutoff = new Date(now.getTime() - settings.sourceRetentionDays * 86400000);
const imagesCutoff = new Date(now.getTime() - settings.imageRetentionDays * 86400000);
const results = {
sourcesDeleted: 0,
imagesDeleted: 0,
cardsUpdated: 0,
jobsPurged: 0,
dryRun,
};
// 1. Purge source PDFs older than sourceRetentionDays
const sourceKeys = await listObjects("sources/");
const jobsWithSources = await prisma.processingJob.findMany({
where: { createdAt: { lt: sourcesCutoff }, status: "complete" },
select: { id: true },
});
const oldJobIds = new Set(jobsWithSources.map((j) => j.id));
for (const key of sourceKeys) {
const jobId = key.split("/")[1];
if (jobId && oldJobIds.has(jobId)) {
if (!dryRun) await deleteObject(key);
results.sourcesDeleted++;
}
}
// 2. Purge card images older than imageRetentionDays
const oldCards = await prisma.responseCard.findMany({
where: { createdAt: { lt: imagesCutoff } },
select: { id: true, frontImagePath: true, backImagePath: true },
});
for (const card of oldCards) {
if (card.frontImagePath) {
if (!dryRun) await deleteObject(card.frontImagePath);
results.imagesDeleted++;
}
if (card.backImagePath) {
if (!dryRun) await deleteObject(card.backImagePath);
results.imagesDeleted++;
}
if (!dryRun) {
await prisma.responseCard.update({
where: { id: card.id },
data: { frontImagePath: null, backImagePath: null },
});
}
results.cardsUpdated++;
}
// 3. Purge completed processing jobs older than sourceRetentionDays
if (!dryRun) {
const deleted = await prisma.processingJob.deleteMany({
where: { createdAt: { lt: sourcesCutoff }, status: { in: ["complete", "error"] } },
});
results.jobsPurged = deleted.count;
} else {
results.jobsPurged = await prisma.processingJob.count({
where: { createdAt: { lt: sourcesCutoff }, status: { in: ["complete", "error"] } },
});
}
return NextResponse.json(results);
} catch (error) {
console.error("[cleanup POST]", error);
return NextResponse.json(
{ error: "Cleanup failed" },
{ status: 500 }
);
}
}
export async function GET() {
try {
let settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
if (!settings) {
settings = await prisma.appSettings.create({
data: { id: "singleton" },
});
}
const now = new Date();
const sourcesCutoff = new Date(now.getTime() - settings.sourceRetentionDays * 86400000);
const imagesCutoff = new Date(now.getTime() - settings.imageRetentionDays * 86400000);
const sourcesEligible = await prisma.processingJob.count({
where: { createdAt: { lt: sourcesCutoff }, status: "complete" },
});
const imagesEligible = await prisma.responseCard.count({
where: {
createdAt: { lt: imagesCutoff },
OR: [
{ frontImagePath: { not: null } },
{ backImagePath: { not: null } },
],
},
});
return NextResponse.json({
sourceRetentionDays: settings.sourceRetentionDays,
imageRetentionDays: settings.imageRetentionDays,
sourcesEligible,
imagesEligible,
});
} catch (error) {
console.error("[cleanup GET]", error);
return NextResponse.json(
{ error: "Failed to check cleanup status" },
{ status: 500 }
);
}
}

View file

@ -33,6 +33,8 @@ export async function PUT(request: NextRequest) {
if (body.model != null) data.model = String(body.model); if (body.model != null) data.model = String(body.model);
if (body.watchDir != null) data.watchDir = String(body.watchDir); if (body.watchDir != null) data.watchDir = String(body.watchDir);
if (body.watching != null) data.watching = Boolean(body.watching); if (body.watching != null) data.watching = Boolean(body.watching);
if (body.sourceRetentionDays != null) data.sourceRetentionDays = Math.max(1, parseInt(String(body.sourceRetentionDays)) || 30);
if (body.imageRetentionDays != null) data.imageRetentionDays = Math.max(1, parseInt(String(body.imageRetentionDays)) || 180);
const settings = await prisma.appSettings.upsert({ const settings = await prisma.appSettings.upsert({
where: { id: "singleton" }, where: { id: "singleton" },

View file

@ -2,7 +2,7 @@
import * as React from "react"; import * as React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2, Save, Wifi, WifiOff } from "lucide-react"; import { Loader2, Save, Wifi, WifiOff, Trash2 } from "lucide-react";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -16,6 +16,8 @@ type Settings = {
model: string; model: string;
watchDir: string; watchDir: string;
watching: boolean; watching: boolean;
sourceRetentionDays: number;
imageRetentionDays: number;
}; };
export default function SettingsPage() { export default function SettingsPage() {
@ -24,10 +26,23 @@ export default function SettingsPage() {
model: "", model: "",
watchDir: "", watchDir: "",
watching: false, watching: false,
sourceRetentionDays: 30,
imageRetentionDays: 180,
}); });
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [ollamaStatus, setOllamaStatus] = React.useState<"unknown" | "connected" | "error">("unknown"); const [ollamaStatus, setOllamaStatus] = React.useState<"unknown" | "connected" | "error">("unknown");
const [cleanupStatus, setCleanupStatus] = React.useState<{ sourcesEligible: number; imagesEligible: number } | null>(null);
const [cleaning, setCleaning] = React.useState(false);
const fetchCleanupStatus = React.useCallback(() => {
fetch("/api/cleanup")
.then((r) => r.json())
.then((data) => {
if (data.sourcesEligible !== undefined) setCleanupStatus(data);
})
.catch(() => {});
}, []);
React.useEffect(() => { React.useEffect(() => {
fetch("/api/settings") fetch("/api/settings")
@ -39,7 +54,8 @@ export default function SettingsPage() {
.catch(() => { .catch(() => {
setLoading(false); setLoading(false);
}); });
}, []); fetchCleanupStatus();
}, [fetchCleanupStatus]);
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
@ -94,6 +110,27 @@ export default function SettingsPage() {
} }
}; };
const runCleanup = async () => {
setCleaning(true);
try {
const res = await fetch("/api/cleanup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dryRun: false }),
});
if (!res.ok) throw new Error();
const data = await res.json();
toast.success(
`Cleanup complete: ${data.sourcesDeleted} source files, ${data.imagesDeleted} images, ${data.jobsPurged} jobs removed`
);
fetchCleanupStatus();
} catch {
toast.error("Cleanup failed");
} finally {
setCleaning(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
@ -198,8 +235,80 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Storage & Cleanup */}
<Card>
<CardHeader>
<CardTitle className="text-base">Storage & Cleanup</CardTitle>
<CardDescription>Auto-purge uploaded files and images to save storage</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Source PDF Retention (days)
</Label>
<Input
type="number"
min={1}
value={settings.sourceRetentionDays}
onChange={(e) =>
setSettings((s) => ({
...s,
sourceRetentionDays: parseInt(e.target.value) || 30,
}))
}
/>
<p className="mt-1 text-xs text-muted-foreground">
Original uploaded PDFs are deleted after this many days. Card data is kept.
</p>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Image Retention (days)
</Label>
<Input
type="number"
min={1}
value={settings.imageRetentionDays}
onChange={(e) =>
setSettings((s) => ({
...s,
imageRetentionDays: parseInt(e.target.value) || 180,
}))
}
/>
<p className="mt-1 text-xs text-muted-foreground">
Scanned card images are removed after this many days. Card data is kept.
</p>
</div>
<div className="rounded-lg border bg-muted/50 p-3">
<div className="flex items-center justify-between">
<div className="text-sm">
{cleanupStatus ? (
<>
<span className="font-medium">{cleanupStatus.sourcesEligible}</span> source files
{" and "}
<span className="font-medium">{cleanupStatus.imagesEligible}</span> card images eligible
</>
) : (
"Checking..."
)}
</div>
<Button
variant="destructive"
size="sm"
onClick={runCleanup}
disabled={cleaning || !cleanupStatus || (cleanupStatus.sourcesEligible === 0 && cleanupStatus.imagesEligible === 0)}
>
{cleaning ? <Loader2 className="mr-2 size-3 animate-spin" /> : <Trash2 className="mr-2 size-3" />}
Run Cleanup Now
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Monday.com Integration (placeholder) */} {/* Monday.com Integration (placeholder) */}
<Card className="lg:col-span-2"> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Monday.com Integration</CardTitle> <CardTitle className="text-base">Monday.com Integration</CardTitle>
<CardDescription>Push scanned card data to Monday.com boards (coming soon)</CardDescription> <CardDescription>Push scanned card data to Monday.com boards (coming soon)</CardDescription>

View file

@ -1,4 +1,4 @@
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || "192.168.68.105"; const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || "192.168.68.105";
@ -44,3 +44,24 @@ export async function deleteObject(key: string): Promise<void> {
new DeleteObjectCommand({ Bucket: BUCKET, Key: key }) new DeleteObjectCommand({ Bucket: BUCKET, Key: key })
); );
} }
export async function listObjects(prefix: string): Promise<string[]> {
const keys: string[] = [];
let continuationToken: string | undefined;
do {
const res = await s3.send(
new ListObjectsV2Command({
Bucket: BUCKET,
Prefix: prefix,
ContinuationToken: continuationToken,
})
);
for (const obj of res.Contents ?? []) {
if (obj.Key) keys.push(obj.Key);
}
continuationToken = res.NextContinuationToken;
} while (continuationToken);
return keys;
}

8
start.sh Executable file
View file

@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Running database migrations..."
npx prisma db push --skip-generate 2>&1 || echo "Warning: prisma db push failed, tables may already exist"
echo "Starting server..."
exec node server.js