Files
lab/src/lib/appwrite/job-actions.ts
T
kovakmedya d3977a5dcf 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.
2026-05-22 15:58:58 +03:00

631 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, string> {
const out: Record<string, string> = {};
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<JobFormState> {
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<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;
}
}
async function appendJobHistory(args: {
job: Job;
step: JobStep;
completedBy: string;
note?: string;
}): Promise<void> {
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<JobActionState> {
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<JobActionState> {
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<JobActionState> {
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<JobActionState> {
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<JobActionState> {
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 };
}