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
+4
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { logAudit } from "./audit";
import { syncFinanceForJob } from "./finance-sync";
import { archiveJobFiles } from "./job-file-archive";
import { createNotification } from "./notification-helpers";
import { calculateJobPriceForProsthetic } from "./pricing";
import {
@@ -564,6 +565,9 @@ export async function markDeliveredAction(
jobId,
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) {
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;
size: number;
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 {