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:
parent
1c4a474ad7
commit
3b8d580ded
9 changed files with 289 additions and 5 deletions
|
|
@ -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
2
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
134
src/app/api/cleanup/route.ts
Normal file
134
src/app/api/cleanup/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
8
start.sh
Executable 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
|
||||||
Loading…
Reference in a new issue