feat(customers): full CRUD module — pattern for all other modules
Establishes the multi-tenant module pattern. Subsequent modules (services,
software, calendar, tasks, finance, invoices) will copy this structure.
Validation:
- lib/validation/customers.ts: Zod schema with Turkish messages, optional
fields normalized to undefined.
Server actions (lib/appwrite/customer-actions.ts):
- createCustomerAction, updateCustomerAction, deleteCustomerAction
- All call requireTenant() guard, write team-scoped row permissions
(read+update by team, delete by owner|admin), and emit audit log.
- Update/delete cross-check tenantId on the existing row before mutating
(defense in depth even though row-level perms already enforce it).
- Field-level errors flattened from Zod for inline form display.
Server-side queries (lib/appwrite/customer-queries.ts):
- listCustomers(tenantId), getCustomer(tenantId, id) — admin SDK with
Query.equal('tenantId',...) tenant scope.
UI:
- /customers page (server component): pulls active tenant context, lists
customers, hands off to CustomersClient.
- CustomersClient: TanStack Table with global filter (name/email/phone/
taxId), column sorting on name + createdAt, pagination (20/page),
status badges, row actions (Edit/Delete dropdown), empty-state CTA.
- CustomerFormSheet: shadcn Sheet-based add/edit form with all fields,
toast feedback (sonner), inline field errors. Reused for create + update
by switching the action.
- DeleteCustomerDialog: confirmation modal with destructive button.
Infrastructure:
- Added sonner Toaster to root layout (richColors, closeButton).
- Updated metadata to 'İşletmem KovakCRM' and html lang='tr'.
- Renamed theme storage key to isletmem-ui-theme.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
"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 Customer } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { CustomerActionState } from "./customer-types";
|
||||
import { customerSchema } from "@/lib/validation/customers";
|
||||
|
||||
function appwriteError(e: unknown): string {
|
||||
if (e instanceof AppwriteException) {
|
||||
return e.message || "Beklenmeyen bir hata oluştu.";
|
||||
}
|
||||
return "Bağlantı hatası. Tekrar deneyin.";
|
||||
}
|
||||
|
||||
function pickFormFields(formData: FormData) {
|
||||
return {
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
email: String(formData.get("email") ?? "").trim(),
|
||||
phone: String(formData.get("phone") ?? "").trim(),
|
||||
taxId: String(formData.get("taxId") ?? "").trim(),
|
||||
address: String(formData.get("address") ?? "").trim(),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
status: (formData.get("status") as "active" | "passive" | null) ?? "active",
|
||||
};
|
||||
}
|
||||
|
||||
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 teamRowPermissions(tenantId: string) {
|
||||
return [
|
||||
Permission.read(Role.team(tenantId)),
|
||||
Permission.update(Role.team(tenantId)),
|
||||
Permission.delete(Role.team(tenantId, "owner")),
|
||||
Permission.delete(Role.team(tenantId, "admin")),
|
||||
];
|
||||
}
|
||||
|
||||
export async function createCustomerAction(
|
||||
_prev: CustomerActionState,
|
||||
formData: FormData,
|
||||
): Promise<CustomerActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = customerSchema.safeParse(pickFormFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.customers,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
...parsed.data,
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "customer",
|
||||
entityId: row.$id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/customers");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateCustomerAction(
|
||||
_prev: CustomerActionState,
|
||||
formData: FormData,
|
||||
): Promise<CustomerActionState> {
|
||||
const id = String(formData.get("id") ?? "");
|
||||
if (!id) return { ok: false, error: "ID eksik." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = customerSchema.safeParse(pickFormFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const existing = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.customers,
|
||||
id,
|
||||
)) as unknown as Customer;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.customers, id, parsed.data);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "customer",
|
||||
entityId: id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/customers");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteCustomerAction(formData: FormData): Promise<CustomerActionState> {
|
||||
const id = String(formData.get("id") ?? "");
|
||||
if (!id) return { ok: false, error: "ID eksik." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const existing = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.customers,
|
||||
id,
|
||||
)) as unknown as Customer;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.customers, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "customer",
|
||||
entityId: id,
|
||||
changes: { name: existing.name },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/customers");
|
||||
return { ok: true };
|
||||
}
|
||||
Reference in New Issue
Block a user