feat(jobs): purge file binaries when a job is delivered, keep metadata

Active scan + image traffic was going to bloat Storage fast — every
delivered case has tens of MB of STL hanging around forever. Now closing
a case via 'Teslim Aldım' fires a background archive sweep that deletes
the binary from the bucket but keeps the job_files row, so audit
('kim, ne, ne zaman yükledi') is preserved.

  - DB: job_files.archivedAt datetime (nullable).
  - archiveJobFiles(jobId) (lib/appwrite/job-file-archive.ts):
    lists rows, storage.deleteFile each, stamps archivedAt on the row.
    All in try/catch so partial Storage failures don't roll back the
    'delivered' transition.
  - markDeliveredAction fires it as 'void archiveJobFiles(jobId)' — same
    fire-and-forget pattern as audit/notifications/finance sync.

UI / API
  - Job detail file row dims to 60% opacity, shows 'Arşivlendi
    {tarih}' inline, and disables both the download dialog trigger and
    the STL viewer button.
  - /api/jobs/[jobId]/files/[fileId]/download returns 410 Gone with a
    Turkish message when archivedAt is set — direct-URL hot links can't
    fish the file back either.
This commit is contained in:
kovakmedya
2026-05-22 15:58:58 +03:00
parent 9e78d506ae
commit d3977a5dcf
5 changed files with 79 additions and 3 deletions
@@ -254,7 +254,8 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [downloadOpen, setDownloadOpen] = useState(false); const [downloadOpen, setDownloadOpen] = useState(false);
const [viewerOpen, setViewerOpen] = useState(false); const [viewerOpen, setViewerOpen] = useState(false);
const isViewable = VIEWABLE_RE.test(file.name); const isArchived = Boolean(file.archivedAt);
const isViewable = !isArchived && VIEWABLE_RE.test(file.name);
useEffect(() => { useEffect(() => {
if (state.ok) { if (state.ok) {
@@ -281,12 +282,20 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
} }
return ( return (
<li className="flex items-center gap-3 px-3 py-2"> <li className={`flex items-center gap-3 px-3 py-2 ${isArchived ? "opacity-60" : ""}`}>
<span className="text-muted-foreground">{kindIcon(file.kind)}</span> <span className="text-muted-foreground">{kindIcon(file.kind)}</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{file.name}</p> <p className="truncate text-sm font-medium">{file.name}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind} · {formatSize(file.size)} {JOB_FILE_KIND_LABELS[file.kind] ?? file.kind} · {formatSize(file.size)}
{isArchived && (
<>
{" · "}
<span className="text-amber-600 dark:text-amber-400">
Arşivlendi {new Date(file.archivedAt!).toLocaleDateString("tr-TR")}
</span>
</>
)}
</p> </p>
</div> </div>
<Badge variant="outline" className="hidden sm:inline-flex"> <Badge variant="outline" className="hidden sm:inline-flex">
@@ -315,7 +324,13 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
</Dialog> </Dialog>
)} )}
<Dialog open={downloadOpen} onOpenChange={setDownloadOpen}> <Dialog open={downloadOpen} onOpenChange={setDownloadOpen}>
<Button size="sm" variant="outline" onClick={() => setDownloadOpen(true)}> <Button
size="sm"
variant="outline"
onClick={() => setDownloadOpen(true)}
disabled={isArchived}
title={isArchived ? "Bu dosya arşivlendi; indirilebilir kopyası yok." : undefined}
>
<Download className="size-4" /> <Download className="size-4" />
</Button> </Button>
<DialogContent> <DialogContent>
@@ -46,6 +46,12 @@ export async function GET(
if (ctx.tenantId !== job.clinicTenantId && ctx.tenantId !== job.labTenantId) { if (ctx.tenantId !== job.clinicTenantId && ctx.tenantId !== job.labTenantId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
if (file.archivedAt) {
return NextResponse.json(
{ error: "Dosya arşivlendi, indirilebilir kopya yok." },
{ status: 410 },
);
}
const buf = (await storage.getFileDownload(BUCKETS.jobFiles, file.fileId)) as const buf = (await storage.getFileDownload(BUCKETS.jobFiles, file.fileId)) as
| ArrayBuffer | ArrayBuffer
+4
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { logAudit } from "./audit"; import { logAudit } from "./audit";
import { syncFinanceForJob } from "./finance-sync"; import { syncFinanceForJob } from "./finance-sync";
import { archiveJobFiles } from "./job-file-archive";
import { createNotification } from "./notification-helpers"; import { createNotification } from "./notification-helpers";
import { calculateJobPriceForProsthetic } from "./pricing"; import { calculateJobPriceForProsthetic } from "./pricing";
import { import {
@@ -564,6 +565,9 @@ export async function markDeliveredAction(
jobId, jobId,
message: `Hasta ${job.patientCode} işi teslim alındı.`, message: `Hasta ${job.patientCode} işi teslim alındı.`,
}); });
// Free up Storage now that the case is closed. Metadata rows stay for
// the audit trail; only the binaries go.
void archiveJobFiles(jobId);
} catch (e) { } catch (e) {
return { ok: false, error: appwriteError(e, "Teslim alınamadı.") }; return { ok: false, error: appwriteError(e, "Teslim alınamadı.") };
} }
+48
View File
@@ -0,0 +1,48 @@
import "server-only";
import { Query } from "node-appwrite";
import { BUCKETS, DATABASE_ID, TABLES, type JobFile } from "./schema";
import { createAdminClient } from "./server";
/**
* Purge the binary scan/image/document objects backing a finished job from
* Appwrite Storage and stamp archivedAt on the corresponding rows. The row
* itself stays — the lab and clinic still need the audit trail (which file
* was uploaded, by whom, when) long after delivery.
*
* Best-effort: a single Storage error must not block the calling action.
* The function never throws.
*/
export async function archiveJobFiles(jobId: string): Promise<void> {
const { tablesDB, storage } = createAdminClient();
try {
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobFiles,
queries: [Query.equal("jobId", jobId), Query.limit(500)],
});
const rows = result.rows as unknown as JobFile[];
const now = new Date().toISOString();
await Promise.all(
rows.map(async (r) => {
if (r.archivedAt) return;
try {
await storage.deleteFile(BUCKETS.jobFiles, r.fileId);
} catch {
// file already gone, or storage unreachable — still flip archivedAt
// so the UI doesn't keep teasing a download button.
}
try {
await tablesDB.updateRow(DATABASE_ID, TABLES.jobFiles, r.$id, {
archivedAt: now,
});
} catch {
// row update failed; leave it for the next call to retry.
}
}),
);
} catch {
// List itself failed — nothing to do.
}
}
+3
View File
@@ -137,6 +137,9 @@ export interface JobFile extends Row {
name: string; name: string;
size: number; size: number;
mimeType?: string; mimeType?: string;
/** Set when the binary is purged from object storage after a job closes.
* The row stays for audit; downloads/previews are disabled past this point. */
archivedAt?: string;
} }
export interface JobStatusHistory extends Row { export interface JobStatusHistory extends Row {