"use server"; import { revalidatePath } from "next/cache"; import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite"; 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 { DATABASE_ID, TABLES, type Connection, type Job, type JobStep, type Patient, type Prosthetic, } from "./schema"; import { createAdminClient } from "./server"; import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard"; import { JOB_STEP_ORDER } from "./job-types"; import type { JobActionState, JobFormState } from "./job-types"; import { createJobSchema } from "@/lib/validation/job"; 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 flattenErrors(err: z.ZodError): Record { const out: Record = {}; for (const issue of err.issues) { const key = issue.path.join("."); if (key && !out[key]) out[key] = issue.message; } return out; } function pickFields(formData: FormData) { return { labTenantId: String(formData.get("labTenantId") ?? "").trim(), patientId: String(formData.get("patientId") ?? "").trim(), patientCode: String(formData.get("patientCode") ?? "").trim(), prostheticId: String(formData.get("prostheticId") ?? "").trim(), teeth: formData.getAll("teeth").map((v) => String(v).trim()).filter(Boolean), color: String(formData.get("color") ?? "").trim(), description: String(formData.get("description") ?? "").trim(), dueDate: String(formData.get("dueDate") ?? "").trim(), }; } function jobPermissions(clinicTenantId: string, labTenantId: string): string[] { return [ Permission.read(Role.team(clinicTenantId)), Permission.read(Role.team(labTenantId)), Permission.update(Role.team(clinicTenantId, "owner")), Permission.update(Role.team(clinicTenantId, "admin")), Permission.update(Role.team(clinicTenantId, "member")), Permission.update(Role.team(labTenantId, "owner")), Permission.update(Role.team(labTenantId, "admin")), Permission.update(Role.team(labTenantId, "member")), Permission.delete(Role.team(clinicTenantId, "owner")), Permission.delete(Role.team(clinicTenantId, "admin")), ]; } export async function createJobAction( _prev: JobFormState, formData: FormData, ): Promise { let ctx; try { ctx = await requireTenant(); requireRole(ctx, ["owner", "admin", "member"]); requireTenantKind(ctx, ["clinic"]); } catch { return { ok: false, error: "İş yayınlama yalnızca klinik hesapları için." }; } const parsed = createJobSchema.safeParse(pickFields(formData)); if (!parsed.success) { return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error), }; } const { tablesDB } = createAdminClient(); // If a patientId is supplied, verify it belongs to this clinic and inherit // its patientCode so the lab side always sees a stable identifier. let patientCode = parsed.data.patientCode; if (parsed.data.patientId) { try { const patientRow = (await tablesDB.getRow( DATABASE_ID, TABLES.patients, parsed.data.patientId, )) as unknown as Patient; if (patientRow.clinicTenantId !== ctx.tenantId) { return { ok: false, error: "Bu hasta size ait değil.", fieldErrors: { patientId: "Yetki yok." }, }; } patientCode = patientRow.patientCode; } catch { return { ok: false, error: "Hasta bulunamadı.", fieldErrors: { patientId: "Hasta kaydı yok." }, }; } } // Verify the chosen lab is an approved connection of this clinic const connRes = await tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.connections, queries: [ Query.equal("clinicTenantId", ctx.tenantId), Query.equal("labTenantId", parsed.data.labTenantId), Query.equal("status", "approved"), Query.limit(1), ], }); const conn = connRes.rows[0] as unknown as Connection | undefined; if (!conn) { return { ok: false, error: "Seçilen laboratuvarla onaylanmış bir bağlantınız yok.", fieldErrors: { labTenantId: "Onaylı bağlantı bulunamadı." }, }; } // Resolve the chosen catalog product. It must belong to the selected lab // and still be active — anything else is rejected with a clear error. let prosthetic: Prosthetic; try { const row = await tablesDB.getRow( DATABASE_ID, TABLES.prosthetics, parsed.data.prostheticId, ); prosthetic = row as unknown as Prosthetic; } catch { return { ok: false, error: "Seçilen ürün bulunamadı.", fieldErrors: { prostheticId: "Ürün bulunamadı." }, }; } if (prosthetic.tenantId !== parsed.data.labTenantId) { return { ok: false, error: "Seçilen ürün bu laboratuvara ait değil.", fieldErrors: { prostheticId: "Ürün bu lab'a ait değil." }, }; } if (prosthetic.archived) { return { ok: false, error: "Seçilen ürün arşivlenmiş.", fieldErrors: { prostheticId: "Bu ürün artık aktif değil." }, }; } try { // Server-side price calculation — clinic never sets the price. const quote = await calculateJobPriceForProsthetic({ prosthetic, clinicTenantId: ctx.tenantId, teethCount: parsed.data.teeth.length, }); const created = await tablesDB.createRow( DATABASE_ID, TABLES.jobs, ID.unique(), { clinicTenantId: ctx.tenantId, labTenantId: parsed.data.labTenantId, createdBy: ctx.user.id, patientId: parsed.data.patientId, patientCode, prostheticType: prosthetic.type, prostheticId: prosthetic.$id, memberCount: parsed.data.teeth.length, teeth: parsed.data.teeth, color: parsed.data.color, description: parsed.data.description, price: quote?.amount, currency: quote?.currency, dueDate: parsed.data.dueDate, status: "pending", }, jobPermissions(ctx.tenantId, parsed.data.labTenantId), ); void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "create", entityType: "job", entityId: created.$id, changes: { labTenantId: parsed.data.labTenantId, patientCode }, }); void createNotification({ tenantId: parsed.data.labTenantId, jobId: created.$id, message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${patientCode}).`, }); revalidatePath("/jobs/outbound"); revalidatePath("/dashboard"); return { ok: true, jobId: created.$id }; } catch (e) { return { ok: false, error: appwriteError(e, "İş oluşturulamadı.") }; } } function jobHistoryPermissions(clinicTenantId: string, labTenantId: string): string[] { return [ Permission.read(Role.team(clinicTenantId)), Permission.read(Role.team(labTenantId)), ]; } async function loadJobForTenant(jobId: string, tenantId: string): Promise { 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; } } async function appendJobHistory(args: { job: Job; step: JobStep; completedBy: string; note?: string; }): Promise { const { tablesDB } = createAdminClient(); try { await tablesDB.createRow( DATABASE_ID, TABLES.jobStatusHistory, ID.unique(), { jobId: args.job.$id, clinicTenantId: args.job.clinicTenantId, labTenantId: args.job.labTenantId, step: args.step, completedBy: args.completedBy, completedAt: new Date().toISOString(), note: args.note, }, jobHistoryPermissions(args.job.clinicTenantId, args.job.labTenantId), ); } catch { // history failures must never block the main mutation } } export async function acceptJobAction( _prev: JobActionState, formData: FormData, ): Promise { 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"]); requireTenantKind(ctx, ["lab"]); } catch { return { ok: false, error: "Sadece laboratuvar bu işi kabul edebilir." }; } const job = await loadJobForTenant(jobId, ctx.tenantId); if (!job || job.labTenantId !== ctx.tenantId) { return { ok: false, error: "İş bulunamadı." }; } if (job.status !== "pending") { return { ok: false, error: "Bu iş zaten işleme alınmış." }; } try { const { tablesDB } = createAdminClient(); // Accepting the job = lab took the impression, started substructure work. // Step jumps straight to alt_yapi_prova; location flips to at_lab. await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { status: "in_progress", currentStep: "alt_yapi_prova", location: "at_lab", }); await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id }); void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", entityType: "job", entityId: jobId, changes: { status: "in_progress", currentStep: "alt_yapi_prova", location: "at_lab", }, }); void createNotification({ tenantId: job.clinicTenantId, jobId, message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı, alt yapı üretiminde.`, }); } catch (e) { return { ok: false, error: appwriteError(e, "Kabul edilemedi.") }; } revalidatePath(`/jobs/${jobId}`); revalidatePath("/jobs/inbound"); revalidatePath("/jobs/outbound"); return { ok: true }; } /** * Lab hands the work back to the clinic for the next physical step * (prova or final delivery). The current step stays the same — only the * location flips at_lab → at_clinic. If the lab is finishing the last * production step (cila_bitim), that's the final delivery and the job * status becomes "sent". */ export async function handToClinicAction( _prev: JobActionState, formData: FormData, ): Promise { const jobId = String(formData.get("jobId") ?? "").trim(); if (!jobId) return { ok: false, error: "İş bulunamadı." }; const note = String(formData.get("note") ?? "").trim() || undefined; let ctx; try { ctx = await requireTenant(); requireRole(ctx, ["owner", "admin", "member"]); requireTenantKind(ctx, ["lab"]); } catch { return { ok: false, error: "Sadece laboratuvar kliniğe gönderebilir." }; } const job = await loadJobForTenant(jobId, ctx.tenantId); if (!job || job.labTenantId !== ctx.tenantId) { return { ok: false, error: "İş bulunamadı." }; } if (job.status !== "in_progress") { return { ok: false, error: "Sadece işlemdeki işler kliniğe gönderilebilir." }; } if (job.location !== "at_lab") { return { ok: false, error: "İş zaten kliniğe gönderilmiş." }; } if (!job.currentStep) { return { ok: false, error: "Mevcut aşama bilinmiyor." }; } const isFinalStep = job.currentStep === "cila_bitim"; try { const { tablesDB } = createAdminClient(); if (isFinalStep) { // Final delivery — production is done, status moves to sent. await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { status: "sent", location: "at_clinic", }); await appendJobHistory({ job, step: "cila_bitim", completedBy: ctx.user.id, note, }); void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", entityType: "job", entityId: jobId, changes: { status: "sent", location: "at_clinic" }, }); void syncFinanceForJob({ ...job, status: "sent" }); void createNotification({ tenantId: job.clinicTenantId, jobId, message: `Hasta ${job.patientCode} cila/bitim tamamlandı, nihai teslime gönderildi.`, }); } else { // Prova için klinike geçici teslim — step aynı, location değişti. await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { location: "at_clinic", }); await appendJobHistory({ job, step: job.currentStep, completedBy: ctx.user.id, note, }); void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", entityType: "job", entityId: jobId, changes: { location: "at_clinic", handedOffStep: job.currentStep }, }); const stepLabel = job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı"; void createNotification({ tenantId: job.clinicTenantId, jobId, message: `Hasta ${job.patientCode} ${stepLabel} provasına hazır, kliniğe gönderildi.`, }); } } catch (e) { return { ok: false, error: appwriteError(e, "Gönderilemedi.") }; } revalidatePath(`/jobs/${jobId}`); revalidatePath("/jobs/inbound"); revalidatePath("/jobs/outbound"); revalidatePath("/finance"); return { ok: true }; } /** * Clinic confirms the prova was successful. Step advances to the next * production stage and location flips back at_clinic → at_lab so the * lab can pick the work back up. */ export async function approveAtClinicAction( _prev: JobActionState, formData: FormData, ): Promise { const jobId = String(formData.get("jobId") ?? "").trim(); if (!jobId) return { ok: false, error: "İş bulunamadı." }; const note = String(formData.get("note") ?? "").trim() || undefined; let ctx; try { ctx = await requireTenant(); requireRole(ctx, ["owner", "admin", "member"]); requireTenantKind(ctx, ["clinic"]); } catch { return { ok: false, error: "Sadece klinik provayı onaylayabilir." }; } const job = await loadJobForTenant(jobId, ctx.tenantId); if (!job || job.clinicTenantId !== ctx.tenantId) { return { ok: false, error: "İş bulunamadı." }; } if (job.status !== "in_progress") { return { ok: false, error: "Yalnızca işlemdeki provalar onaylanabilir." }; } if (job.location !== "at_clinic") { return { ok: false, error: "İş şu an klinikte değil." }; } if (!job.currentStep) { return { ok: false, error: "Mevcut aşama bilinmiyor." }; } const currentIdx = JOB_STEP_ORDER.indexOf(job.currentStep); const nextStep = JOB_STEP_ORDER[currentIdx + 1]; if (!nextStep) { return { ok: false, error: "Bu aşamadan ileri gidilemez." }; } try { const { tablesDB } = createAdminClient(); await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { currentStep: nextStep, location: "at_lab", }); await appendJobHistory({ job, step: job.currentStep, completedBy: ctx.user.id, note, }); void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", entityType: "job", entityId: jobId, changes: { currentStep: nextStep, location: "at_lab", completedStep: job.currentStep, }, }); const stepLabel = job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı"; void createNotification({ tenantId: job.labTenantId, jobId, message: `Hasta ${job.patientCode} ${stepLabel} provası onaylandı, lab tarafına geri döndü.`, }); } catch (e) { return { ok: false, error: appwriteError(e, "Onaylanamadı.") }; } revalidatePath(`/jobs/${jobId}`); revalidatePath("/jobs/inbound"); revalidatePath("/jobs/outbound"); return { ok: true }; } export async function markDeliveredAction( _prev: JobActionState, formData: FormData, ): Promise { 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"]); requireTenantKind(ctx, ["clinic"]); } catch { return { ok: false, error: "Teslim almayı yalnızca klinik yapabilir." }; } const job = await loadJobForTenant(jobId, ctx.tenantId); if (!job || job.clinicTenantId !== ctx.tenantId) { return { ok: false, error: "İş bulunamadı." }; } if (job.status !== "sent") { return { ok: false, error: "Sadece gönderilmiş işler teslim alınabilir." }; } try { const { tablesDB } = createAdminClient(); await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { status: "delivered", }); void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", entityType: "job", entityId: jobId, changes: { status: "delivered" }, }); void syncFinanceForJob({ ...job, status: "delivered" }); void createNotification({ tenantId: job.labTenantId, 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ı.") }; } revalidatePath(`/jobs/${jobId}`); revalidatePath("/jobs/outbound"); revalidatePath("/jobs/inbound"); revalidatePath("/finance"); return { ok: true }; } export async function cancelJobAction( _prev: JobActionState, formData: FormData, ): Promise { const jobId = String(formData.get("jobId") ?? "").trim(); if (!jobId) return { ok: false, error: "İş bulunamadı." }; let ctx; try { ctx = await requireTenant(); requireRole(ctx, ["owner", "admin"]); } catch { return { ok: false, error: "Bu işlem için yetkiniz yok." }; } const job = await loadJobForTenant(jobId, ctx.tenantId); if (!job) return { ok: false, error: "İş bulunamadı." }; if (job.status !== "pending") { return { ok: false, error: "Yalnızca bekleyen işler iptal edilebilir." }; } if (ctx.kind === "clinic" && job.clinicTenantId !== ctx.tenantId) { return { ok: false, error: "Yetkiniz yok." }; } if (ctx.kind === "lab" && job.labTenantId !== ctx.tenantId) { return { ok: false, error: "Yetkiniz yok." }; } try { const { tablesDB } = createAdminClient(); await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { status: "cancelled", }); void logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", entityType: "job", entityId: jobId, changes: { status: "cancelled" }, }); } catch (e) { return { ok: false, error: appwriteError(e, "İptal edilemedi.") }; } revalidatePath(`/jobs/${jobId}`); revalidatePath("/jobs/inbound"); revalidatePath("/jobs/outbound"); return { ok: true }; }