d3977a5dcf
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.
631 lines
19 KiB
TypeScript
631 lines
19 KiB
TypeScript
"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 };
|
||
}
|