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:
@@ -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
|
||||||
|
|||||||
@@ -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ı.") };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user