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 [downloadOpen, setDownloadOpen] = 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(() => {
if (state.ok) {
@@ -281,12 +282,20 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
}
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>
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{file.name}</p>
<p className="text-muted-foreground text-xs">
{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>
</div>
<Badge variant="outline" className="hidden sm:inline-flex">
@@ -315,7 +324,13 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
</Dialog>
)}
<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" />
</Button>
<DialogContent>