perf+fix: file download proxy + drop awaits on audit/notifications/finance sync

Two problems reported by the user:

1. File downloads broken on the lab side.
   The link in JobFilesPanel pointed straight at Appwrite's
   /storage/.../view URL. Storage permissions are scoped to the job's two
   teams, but the browser only has a session cookie for our app domain,
   not for db.kovaksoft.com — so the cross-origin request hit Appwrite
   as a guest and 401'd.

   New /api/jobs/[jobId]/files/[fileId]/download route. requireTenant()
   first, then verify the caller's tenant is one of (clinicTenantId,
   labTenantId) on the parent job, then storage.getFileDownload via the
   admin SDK and stream the buffer back with Content-Disposition:
   attachment so the browser saves it under the original filename.
   listJobFiles now hands out that relative URL instead of the Appwrite
   one — same anchor in the panel, just routed through us.

2. Saves and edits feel slow whenever a notification is involved.
   Every mutation was awaiting logAudit, createNotification and
   syncFinanceForJob in sequence. None of these need to block the user
   response — audit is best-effort logging, notifications are async UX,
   and the finance sync is idempotent and re-runs on the next mutation
   anyway. Switched all 46 call sites across the action modules to
   void-fire-and-forget (matching the pattern we already used in
   clinic-pricing-actions). Net effect: each mutation drops ~3 sequential
   Appwrite roundtrips before the server action returns.
This commit is contained in:
kovakmedya
2026-05-22 01:05:25 +03:00
parent 97a6031992
commit 12631cf9c5
13 changed files with 112 additions and 46 deletions
@@ -0,0 +1,66 @@
import { NextResponse } from "next/server";
import { BUCKETS, DATABASE_ID, TABLES, type Job, type JobFile } from "@/lib/appwrite/schema";
import { createAdminClient } from "@/lib/appwrite/server";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
/**
* Server-side download proxy. The Appwrite bucket files are scoped to the
* job's two teams (clinic + lab) and the lab's frontend domain doesn't carry
* an Appwrite session cookie, so a direct browser → Appwrite link 401s. We
* authenticate the caller via the lab session, verify they actually have
* access to the job, then stream the file out with a forced attachment
* disposition.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ jobId: string; fileId: string }> },
) {
const { jobId, fileId } = await params;
let ctx;
try {
ctx = await requireTenant();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { tablesDB, storage } = createAdminClient();
let job: Job;
let file: JobFile;
try {
const [j, f] = await Promise.all([
tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId) as Promise<unknown>,
tablesDB.getRow(DATABASE_ID, TABLES.jobFiles, fileId) as Promise<unknown>,
]);
job = j as Job;
file = f as JobFile;
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (file.jobId !== jobId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (ctx.tenantId !== job.clinicTenantId && ctx.tenantId !== job.labTenantId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const buf = (await storage.getFileDownload(BUCKETS.jobFiles, file.fileId)) as
| ArrayBuffer
| Buffer;
const body =
buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf);
// Quote the filename so spaces / non-ASCII don't break the header.
const safeName = file.name.replace(/["\\]/g, "_");
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(file.name)}`,
"Cache-Control": "private, no-store",
},
});
}