init: kovakemlak-crm project scaffold
- Next.js 16 + Appwrite multi-tenant emlak CRM - Database: kovakemlak-db (properties, customers, customer_searches, property_matches, presentations, investors, activities, tenant_settings) - Same stack as isletmem-kovakcrm (shadcn/ui template base) - Modules: portföy, müşteri takibi, arama kriterleri, otomatik eşleştirme, sunum linki, yatırımcı portalı
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import {
|
||||
isPlanLimitError,
|
||||
planLimitMessage,
|
||||
requirePlanCapacity,
|
||||
} from "./plan-limits";
|
||||
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
|
||||
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { FinanceActionState } from "./finance-types";
|
||||
import { financeEntrySchema } from "@/lib/validation/finance";
|
||||
|
||||
function appwriteError(e: unknown): string {
|
||||
if (e instanceof AppwriteException) return e.message || "Beklenmeyen hata.";
|
||||
return "Bağlantı hatası. Tekrar deneyin.";
|
||||
}
|
||||
|
||||
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 pickFormFields(formData: FormData) {
|
||||
return {
|
||||
type: formData.get("type") as "income" | "expense" | "debt" | "receivable",
|
||||
amount: String(formData.get("amount") ?? "0"),
|
||||
date: String(formData.get("date") ?? ""),
|
||||
description: String(formData.get("description") ?? "").trim(),
|
||||
customerId: String(formData.get("customerId") ?? ""),
|
||||
invoiceId: String(formData.get("invoiceId") ?? ""),
|
||||
paymentMethod: formData.get("paymentMethod") as
|
||||
| "cash"
|
||||
| "transfer"
|
||||
| "card"
|
||||
| "check"
|
||||
| "other"
|
||||
| null,
|
||||
bankAccountId: String(formData.get("bankAccountId") ?? ""),
|
||||
scope: (formData.get("scope") as "company" | "personal" | null) ?? "company",
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(v: string): string {
|
||||
if (!v) return v;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
|
||||
return v;
|
||||
}
|
||||
|
||||
export async function createFinanceEntryAction(
|
||||
_prev: FinanceActionState,
|
||||
formData: FormData,
|
||||
): Promise<FinanceActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = financeEntrySchema.safeParse(pickFormFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePlanCapacity(ctx, "financeEntries");
|
||||
} catch (e) {
|
||||
if (isPlanLimitError(e)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: planLimitMessage(e.resource, e.limit),
|
||||
code: "PLAN_LIMIT_EXCEEDED",
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const data = { ...parsed.data, date: toIso(parsed.data.date) };
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
...data,
|
||||
},
|
||||
scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "finance_entry",
|
||||
entityId: row.$id,
|
||||
changes: data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateFinanceEntryAction(
|
||||
_prev: FinanceActionState,
|
||||
formData: FormData,
|
||||
): Promise<FinanceActionState> {
|
||||
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 = financeEntrySchema.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.financeEntries,
|
||||
id,
|
||||
)) as unknown as FinanceEntry;
|
||||
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const data = { ...parsed.data, date: toIso(parsed.data.date) };
|
||||
await tablesDB.updateRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
id,
|
||||
data,
|
||||
scopedRowPermissions(ctx.tenantId, existing.createdBy, parsed.data.scope),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "finance_entry",
|
||||
entityId: id,
|
||||
changes: data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteFinanceEntryAction(
|
||||
formData: FormData,
|
||||
): Promise<FinanceActionState> {
|
||||
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.financeEntries,
|
||||
id,
|
||||
)) as unknown as FinanceEntry;
|
||||
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "finance_entry",
|
||||
entityId: id,
|
||||
changes: { type: existing.type, amount: existing.amount },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
Reference in New Issue
Block a user