Files
lab/src/lib/appwrite/job-actions.ts
T
kovakmedya 97a6031992 feat(jobs/new): clinic picks a lab catalog product, not a raw type
What the clinic sees has changed from a fixed 6-item Protez Türü dropdown
to the actual products that lab has published in its catalog. Picking 'Premium
Zirkonyum (1500 TRY / diş)' is now an option; picking the generic
'Zirkonyum' bucket is not.

Wiring
  - DB: jobs.prostheticId string column (optional — legacy jobs stay valid).
    The denormalised prostheticType is still written, sourced server-side
    from the chosen catalog row, so reports/aggregates keep working.
  - validation/job.ts: prostheticId required, prostheticType removed from
    the form payload (computed instead).
  - job-actions.ts: looks up the catalog row, verifies tenantId match +
    not archived, then pulls .type and .unitPrice from it to drive
    calculateJobPriceForProsthetic.

Pricing helper
  - New calculateJobPriceForProsthetic(prosthetic, clinicTenantId,
    teethCount). Reuses the same clinic_pricing cascade
    (custom price wins, otherwise discountPercent off catalog) but skips
    the type-based catalog lookup entirely — it already has the row.
  - /api/pricing/quote rewritten to accept { prostheticId, teethCount },
    still gated on an approved connection between clinic and the lab that
    owns the product.

UI
  - jobs/new page server-loads each connected lab's active prosthetics
    once and hands them to NewJobForm as prostheticsByLab.
  - NewJobForm: 'Ürün *' Select replaces 'Protez Türü *'. The list filters
    by the currently selected lab; switching labs clears the chosen
    product. Each option shows name + 'unitPrice / diş' on the right.
    Once selected, a small caption surfaces the underlying category
    label ('Kategori: Zirkonyum') so the clinic still understands what
    bucket the product falls into.
  - PriceQuoteCard's hasInputs gating moved off prostheticType to
    prostheticId.
2026-05-22 01:01:35 +03:00

522 lines
16 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 { 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),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "job",
entityId: created.$id,
changes: { labTenantId: parsed.data.labTenantId, patientCode },
});
await 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();
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
status: "in_progress",
currentStep: "olcu",
});
await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "job",
entityId: jobId,
changes: { status: "in_progress", currentStep: "olcu" },
});
await createNotification({
tenantId: job.clinicTenantId,
jobId,
message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı.`,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Kabul edilemedi.") };
}
revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
return { ok: true };
}
export async function advanceStepAction(
_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 aşama ilerletebilir." };
}
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: "Yalnızca işleme alınmış işler ilerletilebilir." };
}
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
if (currentIdx < 0) return { ok: false, error: "Mevcut aşama bilinmiyor." };
const nextIdx = currentIdx + 1;
const isFinalStepComplete = currentIdx === JOB_STEP_ORDER.length - 1;
try {
const { tablesDB } = createAdminClient();
if (isFinalStepComplete) {
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
status: "sent",
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "job",
entityId: jobId,
changes: { status: "sent" },
});
} else {
const nextStep = JOB_STEP_ORDER[nextIdx];
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
currentStep: nextStep,
});
await appendJobHistory({
job,
step: job.currentStep!,
completedBy: ctx.user.id,
note,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "job",
entityId: jobId,
changes: { currentStep: nextStep, completedStep: job.currentStep },
});
}
} catch (e) {
return { ok: false, error: appwriteError(e, "İlerletilemedi.") };
}
if (isFinalStepComplete) {
// Record completion of the last step too, then mark sent.
await appendJobHistory({
job,
step: job.currentStep!,
completedBy: ctx.user.id,
note,
});
await syncFinanceForJob({ ...job, status: "sent" });
await createNotification({
tenantId: job.clinicTenantId,
jobId,
message: `Hasta ${job.patientCode} işi gönderildi. Teslim alındığında onaylayın.`,
});
}
revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
revalidatePath("/finance");
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",
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "job",
entityId: jobId,
changes: { status: "delivered" },
});
await syncFinanceForJob({ ...job, status: "delivered" });
await createNotification({
tenantId: job.labTenantId,
jobId,
message: `Hasta ${job.patientCode} işi teslim alındı.`,
});
} 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",
});
await 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 };
}