diff --git a/src/app/api/jobs/[jobId]/files/[fileId]/download/route.ts b/src/app/api/jobs/[jobId]/files/[fileId]/download/route.ts new file mode 100644 index 0000000..e2ad199 --- /dev/null +++ b/src/app/api/jobs/[jobId]/files/[fileId]/download/route.ts @@ -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, + tablesDB.getRow(DATABASE_ID, TABLES.jobFiles, fileId) as Promise, + ]); + 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", + }, + }); +} diff --git a/src/app/api/jobs/[jobId]/files/route.ts b/src/app/api/jobs/[jobId]/files/route.ts index 3ecaa39..312869e 100644 --- a/src/app/api/jobs/[jobId]/files/route.ts +++ b/src/app/api/jobs/[jobId]/files/route.ts @@ -144,7 +144,7 @@ export async function POST( createdRowIds.push(row.$id); } - await logAudit({ + void logAudit({ tenantId: tenantCtx.tenantId, userId: tenantCtx.user.id, action: "create", diff --git a/src/lib/appwrite/connection-actions.ts b/src/lib/appwrite/connection-actions.ts index d978589..b3c4c2e 100644 --- a/src/lib/appwrite/connection-actions.ts +++ b/src/lib/appwrite/connection-actions.ts @@ -110,7 +110,7 @@ export async function requestConnectionAction( approvedAt: null, rejectedAt: null, }); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", @@ -132,7 +132,7 @@ export async function requestConnectionAction( }, connectionPermissions(clinicTenantId, labTenantId), ); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "create", @@ -141,7 +141,7 @@ export async function requestConnectionAction( changes: { clinicTenantId, labTenantId, status: "pending" }, }); const counterpartId = counterpart.tenantId; - await createNotification({ + void createNotification({ tenantId: counterpartId, connectionId: created.$id, message: `${ctx.settings?.companyName ?? "Bir hesap"} bağlantı talebi gönderdi.`, @@ -217,7 +217,7 @@ export async function approveConnectionAction( approvedAt: new Date().toISOString(), rejectedAt: null, }); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", @@ -227,7 +227,7 @@ export async function approveConnectionAction( }); const requesterTenant = conn.clinicTenantId === ctx.tenantId ? conn.labTenantId : conn.clinicTenantId; - await createNotification({ + void createNotification({ tenantId: requesterTenant, connectionId, message: `${ctx.settings?.companyName ?? "Karşı taraf"} bağlantı talebinizi onayladı.`, @@ -270,7 +270,7 @@ export async function rejectConnectionAction( status: "rejected", rejectedAt: new Date().toISOString(), }); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", @@ -313,7 +313,7 @@ export async function cancelConnectionAction( try { const { tablesDB } = createAdminClient(); await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "delete", @@ -350,7 +350,7 @@ export async function deleteConnectionAction( try { const { tablesDB } = createAdminClient(); await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "delete", diff --git a/src/lib/appwrite/finance-actions.ts b/src/lib/appwrite/finance-actions.ts index 56dcd1d..f417c82 100644 --- a/src/lib/appwrite/finance-actions.ts +++ b/src/lib/appwrite/finance-actions.ts @@ -55,7 +55,7 @@ export async function markFinancePaidAction( await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, { status: "paid", }); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", @@ -95,7 +95,7 @@ export async function reopenFinanceAction( await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, { status: "pending", }); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", diff --git a/src/lib/appwrite/job-actions.ts b/src/lib/appwrite/job-actions.ts index 369f462..64cf535 100644 --- a/src/lib/appwrite/job-actions.ts +++ b/src/lib/appwrite/job-actions.ts @@ -201,7 +201,7 @@ export async function createJobAction( }, jobPermissions(ctx.tenantId, parsed.data.labTenantId), ); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "create", @@ -209,7 +209,7 @@ export async function createJobAction( entityId: created.$id, changes: { labTenantId: parsed.data.labTenantId, patientCode }, }); - await createNotification({ + void createNotification({ tenantId: parsed.data.labTenantId, jobId: created.$id, message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${patientCode}).`, @@ -302,7 +302,7 @@ export async function acceptJobAction( currentStep: "olcu", }); await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id }); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", @@ -310,7 +310,7 @@ export async function acceptJobAction( entityId: jobId, changes: { status: "in_progress", currentStep: "olcu" }, }); - await createNotification({ + void createNotification({ tenantId: job.clinicTenantId, jobId, message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı.`, @@ -361,7 +361,7 @@ export async function advanceStepAction( await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { status: "sent", }); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", @@ -380,7 +380,7 @@ export async function advanceStepAction( completedBy: ctx.user.id, note, }); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", @@ -401,8 +401,8 @@ export async function advanceStepAction( completedBy: ctx.user.id, note, }); - await syncFinanceForJob({ ...job, status: "sent" }); - await createNotification({ + void syncFinanceForJob({ ...job, status: "sent" }); + void createNotification({ tenantId: job.clinicTenantId, jobId, message: `Hasta ${job.patientCode} işi gönderildi. Teslim alındığında onaylayın.`, @@ -445,7 +445,7 @@ export async function markDeliveredAction( await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { status: "delivered", }); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", @@ -453,8 +453,8 @@ export async function markDeliveredAction( entityId: jobId, changes: { status: "delivered" }, }); - await syncFinanceForJob({ ...job, status: "delivered" }); - await createNotification({ + void syncFinanceForJob({ ...job, status: "delivered" }); + void createNotification({ tenantId: job.labTenantId, jobId, message: `Hasta ${job.patientCode} işi teslim alındı.`, @@ -502,7 +502,7 @@ export async function cancelJobAction( await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { status: "cancelled", }); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", diff --git a/src/lib/appwrite/job-file-actions.ts b/src/lib/appwrite/job-file-actions.ts index 2eafcbf..e023ecb 100644 --- a/src/lib/appwrite/job-file-actions.ts +++ b/src/lib/appwrite/job-file-actions.ts @@ -138,7 +138,7 @@ export async function uploadJobFilesAction( createdRowIds.push(row.$id); } - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "create", @@ -206,7 +206,7 @@ export async function deleteJobFileAction( // File may already be gone; row is the source of truth. } - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "delete", diff --git a/src/lib/appwrite/job-file-queries.ts b/src/lib/appwrite/job-file-queries.ts index 5884000..e94eee7 100644 --- a/src/lib/appwrite/job-file-queries.ts +++ b/src/lib/appwrite/job-file-queries.ts @@ -2,12 +2,12 @@ import "server-only"; import { Query } from "node-appwrite"; -import { BUCKETS, DATABASE_ID, TABLES, type JobFile } from "./schema"; +import { DATABASE_ID, TABLES, type JobFile } from "./schema"; import { createAdminClient } from "./server"; import { toPlain } from "./serialize"; -import { getFileViewUrl } from "./storage"; export type JobFileWithUrl = JobFile & { + /** Server-side download proxy. Browser → our app → admin SDK → bucket. */ url: string; }; @@ -26,7 +26,7 @@ export async function listJobFiles(jobId: string): Promise { return toPlain( rows.map((r) => ({ ...r, - url: getFileViewUrl(BUCKETS.jobFiles, r.fileId), + url: `/api/jobs/${jobId}/files/${r.$id}/download`, })), ); } diff --git a/src/lib/appwrite/logo-actions.ts b/src/lib/appwrite/logo-actions.ts index 5c96a7f..c65100e 100644 --- a/src/lib/appwrite/logo-actions.ts +++ b/src/lib/appwrite/logo-actions.ts @@ -87,7 +87,7 @@ export async function uploadLogoAction( } } - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", @@ -151,7 +151,7 @@ export async function removeLogoAction(): Promise { /* file already gone, fine */ } - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "delete", diff --git a/src/lib/appwrite/patient-actions.ts b/src/lib/appwrite/patient-actions.ts index d576ab6..8d86dc2 100644 --- a/src/lib/appwrite/patient-actions.ts +++ b/src/lib/appwrite/patient-actions.ts @@ -137,7 +137,7 @@ export async function createPatientAction( }, patientPermissions(ctx.tenantId), ); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "create", @@ -191,7 +191,7 @@ export async function updatePatientAction( lastName: parsed.data.lastName, notes: parsed.data.notes, }); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", @@ -236,7 +236,7 @@ export async function archivePatientAction( await tablesDB.updateRow(DATABASE_ID, TABLES.patients, id, { archived: !row.archived, }); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", diff --git a/src/lib/appwrite/profile-actions.ts b/src/lib/appwrite/profile-actions.ts index f4d33d4..e4ab637 100644 --- a/src/lib/appwrite/profile-actions.ts +++ b/src/lib/appwrite/profile-actions.ts @@ -28,7 +28,7 @@ async function audit(action: "update", entityType: string, changes: Record { await admin.teams.deleteMembership(ctx.tenantId, me.$id); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "delete", @@ -334,7 +334,7 @@ export async function updateMemberRoleAction( const { teams } = createAdminClient(); await teams.updateMembership(ctx.tenantId, membershipId, [role]); - await logAudit({ + void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", @@ -456,7 +456,7 @@ export async function acceptInviteAction(code: string): Promise