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
+353
View File
@@ -0,0 +1,353 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { logAudit } from "./audit";
import {
DATABASE_ID,
TABLES,
type Connection,
type TenantSettings,
} from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
import type {
ConnectionActionState,
ConnectionRequestState,
} from "./connection-types";
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 connectionPermissions(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(labTenantId, "owner")),
Permission.update(Role.team(labTenantId, "admin")),
Permission.delete(Role.team(clinicTenantId, "owner")),
Permission.delete(Role.team(labTenantId, "owner")),
];
}
export async function requestConnectionAction(
_prev: ConnectionRequestState,
formData: FormData,
): Promise<ConnectionRequestState> {
const rawCode = String(formData.get("memberNumber") ?? "").trim().toUpperCase();
if (!rawCode) {
return { ok: false, error: "Bağlantı kodu zorunlu." };
}
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Oturum bulunamadı." };
}
if (!ctx.kind) {
return { ok: false, error: "Hesap türünüz tanımlı değil. Ayarlar > Çalışma alanı." };
}
try {
const { tablesDB } = createAdminClient();
// Resolve counterpart by memberNumber
const counterpartRes = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("memberNumber", rawCode), Query.limit(1)],
});
const counterpart = counterpartRes.rows[0] as unknown as TenantSettings | undefined;
if (!counterpart) {
return { ok: false, error: "Bu kod ile eşleşen bir hesap bulunamadı." };
}
if (counterpart.tenantId === ctx.tenantId) {
return { ok: false, error: "Kendi kodunuzla bağlantı kuramazsınız." };
}
if (counterpart.kind === ctx.kind) {
const same = ctx.kind === "lab" ? "laboratuvar" : "klinik";
return {
ok: false,
error: `Bu kod bir ${same} hesabına ait. Bağlantı sadece klinik ↔ laboratuvar arasında olur.`,
};
}
const clinicTenantId = ctx.kind === "clinic" ? ctx.tenantId : counterpart.tenantId;
const labTenantId = ctx.kind === "lab" ? ctx.tenantId : counterpart.tenantId;
// Already connected or pending?
const existingRes = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.connections,
queries: [
Query.equal("clinicTenantId", clinicTenantId),
Query.equal("labTenantId", labTenantId),
Query.limit(1),
],
});
const existing = existingRes.rows[0] as unknown as Connection | undefined;
if (existing) {
if (existing.status === "approved") {
return { ok: false, error: "Bu hesapla zaten bağlantınız var." };
}
if (existing.status === "pending") {
return { ok: false, error: "Bekleyen bir talep zaten var." };
}
// status === 'rejected' → re-open: reset to pending, requesterBy = current user
await tablesDB.updateRow(DATABASE_ID, TABLES.connections, existing.$id, {
status: "pending",
requestedBy: ctx.user.id,
requestedAt: new Date().toISOString(),
approvedAt: null,
rejectedAt: null,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "connection",
entityId: existing.$id,
changes: { status: "pending", reopened: true },
});
} else {
const created = await tablesDB.createRow(
DATABASE_ID,
TABLES.connections,
ID.unique(),
{
clinicTenantId,
labTenantId,
status: "pending",
requestedBy: ctx.user.id,
requestedAt: new Date().toISOString(),
},
connectionPermissions(clinicTenantId, labTenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "connection",
entityId: created.$id,
changes: { clinicTenantId, labTenantId, status: "pending" },
});
}
} catch (e) {
return { ok: false, error: appwriteError(e, "Bağlantı talebi gönderilemedi.") };
}
revalidatePath("/connections");
return { ok: true };
}
async function loadConnectionForMutation(
connectionId: string,
expectedTenant: string,
): Promise<Connection | null> {
try {
const { tablesDB } = createAdminClient();
const row = await tablesDB.getRow(DATABASE_ID, TABLES.connections, connectionId);
const conn = row as unknown as Connection;
if (
conn.clinicTenantId !== expectedTenant &&
conn.labTenantId !== expectedTenant
) {
return null;
}
return conn;
} catch {
return null;
}
}
export async function approveConnectionAction(
_prev: ConnectionActionState,
formData: FormData,
): Promise<ConnectionActionState> {
const connectionId = String(formData.get("connectionId") ?? "").trim();
if (!connectionId) return { ok: false, error: "Bağlantı bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Bu işlem için yetkiniz yok." };
}
const conn = await loadConnectionForMutation(connectionId, ctx.tenantId);
if (!conn) return { ok: false, error: "Bağlantı bulunamadı." };
if (conn.status !== "pending") {
return { ok: false, error: "Bu talep zaten yanıtlandı." };
}
// Requester cannot self-approve
const requesterTenant =
conn.requestedBy && conn.clinicTenantId === ctx.tenantId
? conn.clinicTenantId
: conn.labTenantId;
// Better: simply require that current tenant is NOT the side that initiated.
// We don't have requesterTenant explicit on row, but requestedBy.userId belongs
// to one side. As a guard, approver must be on the other side from the user
// who created it. Best signal we have: requestedBy is the originator userId.
// If approver is same user as requester → block.
if (conn.requestedBy === ctx.user.id) {
return { ok: false, error: "Kendi talebinizi siz onaylayamazsınız." };
}
void requesterTenant;
try {
const { tablesDB } = createAdminClient();
await tablesDB.updateRow(DATABASE_ID, TABLES.connections, connectionId, {
status: "approved",
approvedAt: new Date().toISOString(),
rejectedAt: null,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "connection",
entityId: connectionId,
changes: { status: "approved" },
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Onaylanamadı.") };
}
revalidatePath("/connections");
return { ok: true };
}
export async function rejectConnectionAction(
_prev: ConnectionActionState,
formData: FormData,
): Promise<ConnectionActionState> {
const connectionId = String(formData.get("connectionId") ?? "").trim();
if (!connectionId) return { ok: false, error: "Bağlantı bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Bu işlem için yetkiniz yok." };
}
const conn = await loadConnectionForMutation(connectionId, ctx.tenantId);
if (!conn) return { ok: false, error: "Bağlantı bulunamadı." };
if (conn.status !== "pending") {
return { ok: false, error: "Bu talep zaten yanıtlandı." };
}
if (conn.requestedBy === ctx.user.id) {
return { ok: false, error: "Kendi talebinizi reddedemezsiniz." };
}
try {
const { tablesDB } = createAdminClient();
await tablesDB.updateRow(DATABASE_ID, TABLES.connections, connectionId, {
status: "rejected",
rejectedAt: new Date().toISOString(),
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "connection",
entityId: connectionId,
changes: { status: "rejected" },
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Reddedilemedi.") };
}
revalidatePath("/connections");
return { ok: true };
}
export async function cancelConnectionAction(
_prev: ConnectionActionState,
formData: FormData,
): Promise<ConnectionActionState> {
// Used when the requester wants to withdraw their own pending request.
const connectionId = String(formData.get("connectionId") ?? "").trim();
if (!connectionId) return { ok: false, error: "Bağlantı bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Oturum bulunamadı." };
}
const conn = await loadConnectionForMutation(connectionId, ctx.tenantId);
if (!conn) return { ok: false, error: "Bağlantı bulunamadı." };
if (conn.status !== "pending") {
return { ok: false, error: "Bu talep iptal edilemez." };
}
if (conn.requestedBy !== ctx.user.id) {
return { ok: false, error: "Sadece talebi gönderen iptal edebilir." };
}
try {
const { tablesDB } = createAdminClient();
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "connection",
entityId: connectionId,
changes: { status: "pending", reason: "cancelled" },
});
} catch (e) {
return { ok: false, error: appwriteError(e, "İptal edilemedi.") };
}
revalidatePath("/connections");
return { ok: true };
}
export async function deleteConnectionAction(
_prev: ConnectionActionState,
formData: FormData,
): Promise<ConnectionActionState> {
const connectionId = String(formData.get("connectionId") ?? "").trim();
if (!connectionId) return { ok: false, error: "Bağlantı bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Bu işlem için yetkiniz yok." };
}
const conn = await loadConnectionForMutation(connectionId, ctx.tenantId);
if (!conn) return { ok: false, error: "Bağlantı bulunamadı." };
try {
const { tablesDB } = createAdminClient();
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "connection",
entityId: connectionId,
changes: { previousStatus: conn.status },
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Silinemedi.") };
}
revalidatePath("/connections");
return { ok: true };
}
+109
View File
@@ -0,0 +1,109 @@
import "server-only";
import { Query } from "node-appwrite";
import {
DATABASE_ID,
TABLES,
type Connection,
type TenantKind,
type TenantSettings,
} from "./schema";
import { createAdminClient } from "./server";
export type CounterpartTenant = {
tenantId: string;
companyName: string;
memberNumber: string;
kind: TenantKind;
};
export type ConnectionWithCounterpart = Connection & {
counterpart: CounterpartTenant | null;
};
function counterpartTenantId(conn: Connection, selfTenantId: string): string {
return conn.clinicTenantId === selfTenantId ? conn.labTenantId : conn.clinicTenantId;
}
async function fetchCounterparts(tenantIds: string[]): Promise<Map<string, CounterpartTenant>> {
if (tenantIds.length === 0) return new Map();
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", tenantIds), Query.limit(100)],
});
const map = new Map<string, CounterpartTenant>();
for (const row of result.rows as unknown as TenantSettings[]) {
map.set(row.tenantId, {
tenantId: row.tenantId,
companyName: row.companyName,
memberNumber: row.memberNumber,
kind: row.kind,
});
}
return map;
}
async function enrich(
rows: Connection[],
selfTenantId: string,
): Promise<ConnectionWithCounterpart[]> {
const counterpartIds = Array.from(
new Set(rows.map((r) => counterpartTenantId(r, selfTenantId))),
);
const map = await fetchCounterparts(counterpartIds);
return rows.map((r) => ({
...r,
counterpart: map.get(counterpartTenantId(r, selfTenantId)) ?? null,
}));
}
async function listConnectionsByStatus(
tenantId: string,
status: Connection["status"],
): Promise<Connection[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.connections,
queries: [
Query.or([
Query.equal("clinicTenantId", tenantId),
Query.equal("labTenantId", tenantId),
]),
Query.equal("status", status),
Query.orderDesc("$createdAt"),
Query.limit(100),
],
});
return result.rows as unknown as Connection[];
}
export async function listApprovedConnections(
tenantId: string,
): Promise<ConnectionWithCounterpart[]> {
const rows = await listConnectionsByStatus(tenantId, "approved");
return enrich(rows, tenantId);
}
export async function listPendingInbound(
tenantId: string,
selfUserId: string,
): Promise<ConnectionWithCounterpart[]> {
// Pending requests sent TO this tenant (this tenant must approve/reject).
const rows = await listConnectionsByStatus(tenantId, "pending");
const inbound = rows.filter((r) => r.requestedBy !== selfUserId);
return enrich(inbound, tenantId);
}
export async function listPendingOutbound(
tenantId: string,
selfUserId: string,
): Promise<ConnectionWithCounterpart[]> {
// Pending requests this tenant sent — counterpart will approve/reject.
const rows = await listConnectionsByStatus(tenantId, "pending");
const outbound = rows.filter((r) => r.requestedBy === selfUserId);
return enrich(outbound, tenantId);
}
+13
View File
@@ -0,0 +1,13 @@
export type ConnectionRequestState = {
ok: boolean;
error?: string;
};
export const initialConnectionRequestState: ConnectionRequestState = { ok: false };
export type ConnectionActionState = {
ok: boolean;
error?: string;
};
export const initialConnectionActionState: ConnectionActionState = { ok: false };
+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ı.") };
}
}
+102
View File
@@ -0,0 +1,102 @@
import "server-only";
import { Query } from "node-appwrite";
import {
DATABASE_ID,
TABLES,
type Job,
type TenantKind,
type TenantSettings,
} from "./schema";
import { createAdminClient } from "./server";
export type JobCounterpart = {
tenantId: string;
companyName: string;
kind: TenantKind;
};
export type JobWithCounterpart = Job & {
counterpart: JobCounterpart | null;
};
async function fetchTenants(tenantIds: string[]): Promise<Map<string, JobCounterpart>> {
if (tenantIds.length === 0) return new Map();
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", tenantIds), Query.limit(200)],
});
const map = new Map<string, JobCounterpart>();
for (const row of result.rows as unknown as TenantSettings[]) {
map.set(row.tenantId, {
tenantId: row.tenantId,
companyName: row.companyName,
kind: row.kind,
});
}
return map;
}
function enrichJob(j: Job, counterpartId: string, map: Map<string, JobCounterpart>): JobWithCounterpart {
return { ...j, counterpart: map.get(counterpartId) ?? null };
}
/** Inbound for a lab tenant — jobs the lab has received. */
export async function listInboundJobs(labTenantId: string): Promise<JobWithCounterpart[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal("labTenantId", labTenantId),
Query.orderDesc("$createdAt"),
Query.limit(200),
],
});
const jobs = result.rows as unknown as Job[];
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.clinicTenantId))));
return jobs.map((j) => enrichJob(j, j.clinicTenantId, map));
}
/** Outbound for a clinic tenant — jobs the clinic has sent. */
export async function listOutboundJobs(clinicTenantId: string): Promise<JobWithCounterpart[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal("clinicTenantId", clinicTenantId),
Query.orderDesc("$createdAt"),
Query.limit(200),
],
});
const jobs = result.rows as unknown as Job[];
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.labTenantId))));
return jobs.map((j) => enrichJob(j, j.labTenantId, map));
}
/** For clinic's "Yeni İş Yayınla" form: list approved labs they can send to. */
export async function listApprovedLabsForClinic(
clinicTenantId: string,
): Promise<JobCounterpart[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.connections,
queries: [
Query.equal("clinicTenantId", clinicTenantId),
Query.equal("status", "approved"),
Query.limit(100),
],
});
const labIds = (result.rows as unknown as { labTenantId: string }[]).map(
(r) => r.labTenantId,
);
const map = await fetchTenants(labIds);
return labIds
.map((id) => map.get(id))
.filter((v): v is JobCounterpart => Boolean(v));
}
+48
View File
@@ -0,0 +1,48 @@
import type { JobStatus, JobStep, ProstheticType } from "./schema";
export type JobFormState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
jobId?: string;
};
export const initialJobFormState: JobFormState = { ok: false };
export type JobActionState = {
ok: boolean;
error?: string;
};
export const initialJobActionState: JobActionState = { ok: false };
export const JOB_STATUS_LABELS: Record<JobStatus, string> = {
pending: "Bekliyor",
in_progress: "İşlemde",
sent: "Gönderildi",
delivered: "Teslim alındı",
cancelled: "İptal",
};
export const JOB_STEP_LABELS: Record<JobStep, string> = {
olcu: "Ölçü",
alt_yapi_prova: "Alt Yapı Prova",
ust_yapi_prova: "Üst Yapı Prova",
cila_bitim: "Cila / Bitim",
};
export const JOB_STEP_ORDER: JobStep[] = [
"olcu",
"alt_yapi_prova",
"ust_yapi_prova",
"cila_bitim",
];
export const PROSTHETIC_TYPE_LABELS: Record<ProstheticType, string> = {
metal_porselen: "Metal Porselen",
zirkonyum: "Zirkonyum",
implant_ustu_zirkonyum: "İmplant Üstü Zirkonyum",
gecici: "Geçici",
e_max: "E-Max",
diger: "Diğer",
};
+238
View File
@@ -0,0 +1,238 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import { DATABASE_ID, TABLES, type Prosthetic } from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
import type { ProstheticActionState, ProstheticFormState } from "./prosthetic-types";
import { prostheticSchema } from "@/lib/validation/prosthetic";
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 {
name: String(formData.get("name") ?? "").trim(),
type: String(formData.get("type") ?? "").trim(),
unitPrice: String(formData.get("unitPrice") ?? "0"),
currency: String(formData.get("currency") ?? "").trim(),
};
}
function prostheticPermissions(tenantId: string): string[] {
return [
Permission.read(Role.team(tenantId)),
Permission.update(Role.team(tenantId, "owner")),
Permission.update(Role.team(tenantId, "admin")),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
];
}
export async function createProstheticAction(
_prev: ProstheticFormState,
formData: FormData,
): Promise<ProstheticFormState> {
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin", "member"]);
requireTenantKind(ctx, ["lab"]);
} catch {
return { ok: false, error: "Bu işlem yalnızca laboratuvar hesaplarında yapılabilir." };
}
const parsed = prostheticSchema.safeParse(pickFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const created = await tablesDB.createRow(
DATABASE_ID,
TABLES.prosthetics,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
name: parsed.data.name,
type: parsed.data.type,
unitPrice: parsed.data.unitPrice,
currency: parsed.data.currency,
archived: false,
},
prostheticPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "prosthetic",
entityId: created.$id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Ürün eklenemedi.") };
}
revalidatePath("/products");
return { ok: true };
}
export async function updateProstheticAction(
_prev: ProstheticFormState,
formData: FormData,
): Promise<ProstheticFormState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Ürün bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin", "member"]);
requireTenantKind(ctx, ["lab"]);
} catch {
return { ok: false, error: "Bu işlem yalnızca laboratuvar hesaplarında yapılabilir." };
}
const parsed = prostheticSchema.safeParse(pickFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.prosthetics,
id,
)) as unknown as Prosthetic;
if (row.tenantId !== ctx.tenantId) {
return { ok: false, error: "Bu ürünü düzenleme yetkiniz yok." };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.prosthetics, id, {
name: parsed.data.name,
type: parsed.data.type,
unitPrice: parsed.data.unitPrice,
currency: parsed.data.currency,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "prosthetic",
entityId: id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Güncellenemedi.") };
}
revalidatePath("/products");
return { ok: true };
}
export async function archiveProstheticAction(
_prev: ProstheticActionState,
formData: FormData,
): Promise<ProstheticActionState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Ürün bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
requireTenantKind(ctx, ["lab"]);
} catch {
return { ok: false, error: "Bu işlem için yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.prosthetics,
id,
)) as unknown as Prosthetic;
if (row.tenantId !== ctx.tenantId) {
return { ok: false, error: "Bu ürünü düzenleme yetkiniz yok." };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.prosthetics, id, {
archived: !row.archived,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "prosthetic",
entityId: id,
changes: { archived: !row.archived },
});
} catch (e) {
return { ok: false, error: appwriteError(e, "İşlem başarısız.") };
}
revalidatePath("/products");
return { ok: true };
}
export async function deleteProstheticAction(
_prev: ProstheticActionState,
formData: FormData,
): Promise<ProstheticActionState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Ürün bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
requireTenantKind(ctx, ["lab"]);
} catch {
return { ok: false, error: "Bu işlem için yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.prosthetics,
id,
)) as unknown as Prosthetic;
if (row.tenantId !== ctx.tenantId) {
return { ok: false, error: "Bu ürünü silme yetkiniz yok." };
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.prosthetics, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "prosthetic",
entityId: id,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Silinemedi.") };
}
revalidatePath("/products");
return { ok: true };
}
+35
View File
@@ -0,0 +1,35 @@
import "server-only";
import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type Prosthetic } from "./schema";
import { createAdminClient } from "./server";
export async function listProsthetics(tenantId: string): Promise<Prosthetic[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.prosthetics,
queries: [
Query.equal("tenantId", tenantId),
Query.orderAsc("name"),
Query.limit(200),
],
});
return result.rows as unknown as Prosthetic[];
}
export async function listActiveProsthetics(tenantId: string): Promise<Prosthetic[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.prosthetics,
queries: [
Query.equal("tenantId", tenantId),
Query.notEqual("archived", true),
Query.orderAsc("name"),
Query.limit(200),
],
});
return result.rows as unknown as Prosthetic[];
}
+23
View File
@@ -0,0 +1,23 @@
export type ProstheticFormState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialProstheticFormState: ProstheticFormState = { ok: false };
export type ProstheticActionState = {
ok: boolean;
error?: string;
};
export const initialProstheticActionState: ProstheticActionState = { ok: false };
export const PROSTHETIC_TYPE_OPTIONS = [
{ value: "metal_porselen", label: "Metal Porselen" },
{ value: "zirkonyum", label: "Zirkonyum" },
{ value: "implant_ustu_zirkonyum", label: "İmplant Üstü Zirkonyum" },
{ value: "gecici", label: "Geçici" },
{ value: "e_max", label: "E-Max" },
{ value: "diger", label: "Diğer" },
] as const;