feat(modules): connections, products, jobs (list/form/detail-placeholder)

Connections (clinic ↔ lab)
  - request via member number, approve/reject (counterparty), cancel pending,
    delete approved
  - permission rows opened to both teams; audit log for every mutation
  - /connections page: own code card, request form, pending inbound/outbound
    tables, approved connections table with delete confirm

Products (lab catalog)
  - createProstheticAction + update + archive/restore + delete (lab-only)
  - zod validation, dev-mode error surfacing
  - /products page: catalog table + add form + edit dialog. Hidden from
    clinic accounts via requireTenantKind.

Jobs (work orders)
  - createJobAction (clinic-only) — checks approved connection before write,
    permissions opened to both clinic and lab teams
  - listInboundJobs (lab perspective), listOutboundJobs (clinic perspective),
    listApprovedLabsForClinic for the new-job form
  - /jobs/inbound + /jobs/outbound tables with role-aware copy
  - /jobs/new full form (lab select, patient code, prosthetic type, member
    count, color, due date, price/currency, description)
  - /jobs/[jobId] placeholder detail page with stepper visualisation;
    status/step updates and file upload come next session

All new mutations follow the memory rules: schema-checked row payloads,
admin client behind requireTenant + requireRole/requireTenantKind, audit
log calls best-effort, no empty-string Radix Select values.
This commit is contained in:
kovakmedya
2026-05-21 19:59:23 +03:00
parent 7fb8288f79
commit 76e02754b8
26 changed files with 2765 additions and 42 deletions
+142
View File
@@ -0,0 +1,142 @@
"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 {
DATABASE_ID,
TABLES,
type Connection,
} from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
import type { 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(),
patientCode: String(formData.get("patientCode") ?? "").trim(),
prostheticType: String(formData.get("prostheticType") ?? "").trim(),
memberCount: String(formData.get("memberCount") ?? ""),
color: String(formData.get("color") ?? "").trim(),
description: String(formData.get("description") ?? "").trim(),
price: String(formData.get("price") ?? "").trim(),
currency: String(formData.get("currency") ?? "").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();
// 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ı." },
};
}
try {
const created = await tablesDB.createRow(
DATABASE_ID,
TABLES.jobs,
ID.unique(),
{
clinicTenantId: ctx.tenantId,
labTenantId: parsed.data.labTenantId,
createdBy: ctx.user.id,
patientCode: parsed.data.patientCode,
prostheticType: parsed.data.prostheticType,
memberCount: parsed.data.memberCount,
color: parsed.data.color,
description: parsed.data.description,
price: parsed.data.price,
currency: parsed.data.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: parsed.data.patientCode },
});
revalidatePath("/jobs/outbound");
revalidatePath("/dashboard");
return { ok: true, jobId: created.$id };
} catch (e) {
return { ok: false, error: appwriteError(e, "İş oluşturulamadı.") };
}
}