12631cf9c5
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.
224 lines
6.7 KiB
TypeScript
224 lines
6.7 KiB
TypeScript
"use server";
|
||
|
||
import { revalidatePath } from "next/cache";
|
||
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
|
||
import { InputFile } from "node-appwrite/file";
|
||
|
||
import { logAudit } from "./audit";
|
||
import {
|
||
BUCKETS,
|
||
DATABASE_ID,
|
||
TABLES,
|
||
type Job,
|
||
type JobFile,
|
||
type JobFileKind,
|
||
} from "./schema";
|
||
import { createAdminClient } from "./server";
|
||
import { requireRole, requireTenant } from "./tenant-guard";
|
||
import type {
|
||
JobFileActionState,
|
||
JobFileUploadState,
|
||
} from "./job-file-types";
|
||
|
||
const MAX_FILE_BYTES = 200 * 1024 * 1024; // 200MB — bucket limit
|
||
|
||
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||
if (e instanceof AppwriteException) return e.message || fallback;
|
||
return process.env.NODE_ENV !== "production" && e instanceof Error
|
||
? `${fallback} (${e.message})`
|
||
: fallback;
|
||
}
|
||
|
||
function classifyFile(mimeType: string | undefined, name: string): JobFileKind {
|
||
const lower = (mimeType || name).toLowerCase();
|
||
if (/\.(stl|obj|ply|3mf|dcm)$/i.test(name)) return "scan";
|
||
if (lower.startsWith("image/") || /\.(png|jpe?g|webp|tiff?|heic|heif|bmp)$/i.test(name)) {
|
||
return "image";
|
||
}
|
||
return "document";
|
||
}
|
||
|
||
function filePermissions(clinicTenantId: string, labTenantId: string): string[] {
|
||
return [
|
||
Permission.read(Role.team(clinicTenantId)),
|
||
Permission.read(Role.team(labTenantId)),
|
||
Permission.delete(Role.team(clinicTenantId, "owner")),
|
||
Permission.delete(Role.team(clinicTenantId, "admin")),
|
||
Permission.delete(Role.team(labTenantId, "owner")),
|
||
Permission.delete(Role.team(labTenantId, "admin")),
|
||
];
|
||
}
|
||
|
||
async function loadJobForTenant(jobId: string, tenantId: string): Promise<Job | null> {
|
||
try {
|
||
const { tablesDB } = createAdminClient();
|
||
const row = await tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId);
|
||
const job = row as unknown as Job;
|
||
if (job.clinicTenantId !== tenantId && job.labTenantId !== tenantId) {
|
||
return null;
|
||
}
|
||
return job;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function uploadJobFilesAction(
|
||
_prev: JobFileUploadState,
|
||
formData: FormData,
|
||
): Promise<JobFileUploadState> {
|
||
const jobId = String(formData.get("jobId") ?? "").trim();
|
||
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||
|
||
let ctx;
|
||
try {
|
||
ctx = await requireTenant();
|
||
requireRole(ctx, ["owner", "admin", "member"]);
|
||
} catch {
|
||
return { ok: false, error: "Yüklemek için yetkiniz yok." };
|
||
}
|
||
|
||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||
if (!job) return { ok: false, error: "İş bulunamadı." };
|
||
|
||
const files = formData.getAll("files").filter((v): v is File => v instanceof File && v.size > 0);
|
||
console.log("[uploadJobFilesAction] jobId=%s files=%d total=%dB", jobId, files.length, files.reduce((s, f) => s + f.size, 0));
|
||
if (files.length === 0) {
|
||
return { ok: false, error: "Dosya seçin." };
|
||
}
|
||
|
||
for (const f of files) {
|
||
if (f.size > MAX_FILE_BYTES) {
|
||
return { ok: false, error: `${f.name} 200MB sınırını aşıyor.` };
|
||
}
|
||
}
|
||
|
||
const { storage, tablesDB } = createAdminClient();
|
||
const uploadedFileIds: string[] = [];
|
||
const createdRowIds: string[] = [];
|
||
|
||
try {
|
||
for (const f of files) {
|
||
const fileId = ID.unique();
|
||
const buffer = Buffer.from(await f.arrayBuffer());
|
||
const inputFile = InputFile.fromBuffer(buffer, f.name);
|
||
await storage.createFile({
|
||
bucketId: BUCKETS.jobFiles,
|
||
fileId,
|
||
file: inputFile,
|
||
permissions: filePermissions(job.clinicTenantId, job.labTenantId),
|
||
});
|
||
uploadedFileIds.push(fileId);
|
||
|
||
const kind = classifyFile(f.type, f.name);
|
||
const row = await tablesDB.createRow(
|
||
DATABASE_ID,
|
||
TABLES.jobFiles,
|
||
ID.unique(),
|
||
{
|
||
jobId: job.$id,
|
||
clinicTenantId: job.clinicTenantId,
|
||
labTenantId: job.labTenantId,
|
||
uploadedBy: ctx.user.id,
|
||
kind,
|
||
fileId,
|
||
name: f.name.slice(0, 255),
|
||
size: f.size,
|
||
mimeType: f.type ? f.type.slice(0, 100) : undefined,
|
||
},
|
||
[
|
||
Permission.read(Role.team(job.clinicTenantId)),
|
||
Permission.read(Role.team(job.labTenantId)),
|
||
Permission.delete(Role.team(job.clinicTenantId, "owner")),
|
||
Permission.delete(Role.team(job.clinicTenantId, "admin")),
|
||
Permission.delete(Role.team(job.labTenantId, "owner")),
|
||
Permission.delete(Role.team(job.labTenantId, "admin")),
|
||
],
|
||
);
|
||
createdRowIds.push(row.$id);
|
||
}
|
||
|
||
void logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "create",
|
||
entityType: "job_files",
|
||
entityId: jobId,
|
||
changes: { count: files.length },
|
||
});
|
||
} catch (e) {
|
||
console.error("[uploadJobFilesAction] failed", e);
|
||
// Rollback: best-effort cleanup of partially uploaded files and rows.
|
||
for (const id of createdRowIds) {
|
||
try {
|
||
await tablesDB.deleteRow(DATABASE_ID, TABLES.jobFiles, id);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
for (const id of uploadedFileIds) {
|
||
try {
|
||
await storage.deleteFile({ bucketId: BUCKETS.jobFiles, fileId: id });
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
return { ok: false, error: appwriteError(e, "Dosya yüklenemedi.") };
|
||
}
|
||
|
||
revalidatePath(`/jobs/${jobId}`);
|
||
return { ok: true, uploaded: files.length };
|
||
}
|
||
|
||
export async function deleteJobFileAction(
|
||
_prev: JobFileActionState,
|
||
formData: FormData,
|
||
): Promise<JobFileActionState> {
|
||
const rowId = String(formData.get("rowId") ?? "").trim();
|
||
if (!rowId) return { ok: false, error: "Dosya bulunamadı." };
|
||
|
||
let ctx;
|
||
try {
|
||
ctx = await requireTenant();
|
||
requireRole(ctx, ["owner", "admin"]);
|
||
} catch {
|
||
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||
}
|
||
|
||
try {
|
||
const { storage, tablesDB } = createAdminClient();
|
||
const row = (await tablesDB.getRow(
|
||
DATABASE_ID,
|
||
TABLES.jobFiles,
|
||
rowId,
|
||
)) as unknown as JobFile;
|
||
if (
|
||
row.clinicTenantId !== ctx.tenantId &&
|
||
row.labTenantId !== ctx.tenantId
|
||
) {
|
||
return { ok: false, error: "Yetkiniz yok." };
|
||
}
|
||
|
||
await tablesDB.deleteRow(DATABASE_ID, TABLES.jobFiles, rowId);
|
||
try {
|
||
await storage.deleteFile({ bucketId: BUCKETS.jobFiles, fileId: row.fileId });
|
||
} catch {
|
||
// File may already be gone; row is the source of truth.
|
||
}
|
||
|
||
void logAudit({
|
||
tenantId: ctx.tenantId,
|
||
userId: ctx.user.id,
|
||
action: "delete",
|
||
entityType: "job_file",
|
||
entityId: rowId,
|
||
changes: { fileId: row.fileId, name: row.name },
|
||
});
|
||
|
||
revalidatePath(`/jobs/${row.jobId}`);
|
||
return { ok: true };
|
||
} catch (e) {
|
||
return { ok: false, error: appwriteError(e, "Silinemedi.") };
|
||
}
|
||
}
|