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,620 @@
|
||||
"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 Invoice,
|
||||
type InvoiceItem,
|
||||
type TenantSettings,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { InvoiceActionState } from "./invoice-types";
|
||||
import { invoiceItemSchema, invoiceSchema } from "@/lib/validation/invoices";
|
||||
|
||||
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 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")),
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function nextInvoiceNumber(
|
||||
tenantId: string,
|
||||
): Promise<{ number: string; settingsId: string | null }> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tenantSettings,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
|
||||
});
|
||||
const settings = result.rows[0] as unknown as TenantSettings | undefined;
|
||||
const prefix = settings?.invoicePrefix || "INV";
|
||||
const counter = (settings?.invoiceCounter ?? 0) + 1;
|
||||
const year = new Date().getFullYear();
|
||||
const number = `${prefix}-${year}-${String(counter).padStart(4, "0")}`;
|
||||
|
||||
if (settings) {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, settings.$id, {
|
||||
invoiceCounter: counter,
|
||||
});
|
||||
return { number, settingsId: settings.$id };
|
||||
}
|
||||
return { number, settingsId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflect invoice payment status into finance_entries.
|
||||
* - status === "paid": ensure exactly one income entry exists for this invoice.
|
||||
* - otherwise: remove any income entries linked to this invoice (auto-generated).
|
||||
*
|
||||
* Best-effort. Failures here must not break the invoice mutation; we log the
|
||||
* error to audit but do not throw.
|
||||
*/
|
||||
async function syncPaymentEntry(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
invoice: Invoice,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const linked = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.financeEntries,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.equal("invoiceId", invoice.$id),
|
||||
Query.equal("type", "income"),
|
||||
Query.limit(10),
|
||||
],
|
||||
});
|
||||
|
||||
if (invoice.status === "paid") {
|
||||
if (linked.rows.length === 0) {
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId,
|
||||
createdBy: userId,
|
||||
type: "income",
|
||||
amount: Number((invoice.total ?? 0).toFixed(2)),
|
||||
date: new Date().toISOString(),
|
||||
description: `Fatura ${invoice.number} tahsilatı`,
|
||||
customerId: invoice.customerId,
|
||||
invoiceId: invoice.$id,
|
||||
scope: "company",
|
||||
},
|
||||
[
|
||||
Permission.read(Role.team(tenantId)),
|
||||
Permission.update(Role.team(tenantId)),
|
||||
Permission.delete(Role.team(tenantId, "owner")),
|
||||
Permission.delete(Role.team(tenantId, "admin")),
|
||||
],
|
||||
);
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId,
|
||||
action: "create",
|
||||
entityType: "finance_entry",
|
||||
entityId: row.$id,
|
||||
changes: { auto: "invoice_paid", invoiceId: invoice.$id, amount: invoice.total },
|
||||
});
|
||||
} else {
|
||||
// Keep the first; resync amount in case the invoice total changed
|
||||
const first = linked.rows[0];
|
||||
const desiredAmount = Number((invoice.total ?? 0).toFixed(2));
|
||||
if (
|
||||
(first as unknown as { amount: number }).amount !== desiredAmount
|
||||
) {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, first.$id, {
|
||||
amount: desiredAmount,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const r of linked.rows) {
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, r.$id);
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId,
|
||||
action: "delete",
|
||||
entityType: "finance_entry",
|
||||
entityId: r.$id,
|
||||
changes: { auto: "invoice_unpaid", invoiceId: invoice.$id },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort; don't block the invoice update
|
||||
}
|
||||
}
|
||||
|
||||
async function recomputeTotals(
|
||||
tenantId: string,
|
||||
invoiceId: string,
|
||||
userId?: string,
|
||||
): Promise<{ subtotal: number; vatTotal: number; total: number }> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.invoiceItems,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.equal("invoiceId", invoiceId),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
let subtotal = 0;
|
||||
let vatTotal = 0;
|
||||
for (const r of result.rows as unknown as InvoiceItem[]) {
|
||||
const lineNet = (r.quantity ?? 0) * (r.unitPrice ?? 0);
|
||||
const lineVat = lineNet * ((r.vatRate ?? 0) / 100);
|
||||
subtotal += lineNet;
|
||||
vatTotal += lineVat;
|
||||
}
|
||||
const total = subtotal + vatTotal;
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.invoices, invoiceId, {
|
||||
subtotal: Number(subtotal.toFixed(2)),
|
||||
vatTotal: Number(vatTotal.toFixed(2)),
|
||||
total: Number(total.toFixed(2)),
|
||||
});
|
||||
|
||||
// If invoice is paid, keep the linked income entry's amount in sync.
|
||||
if (userId) {
|
||||
try {
|
||||
const refreshed = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.invoices,
|
||||
invoiceId,
|
||||
)) as unknown as Invoice;
|
||||
if (refreshed.status === "paid") {
|
||||
await syncPaymentEntry(tenantId, userId, refreshed);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
return { subtotal, vatTotal, total };
|
||||
}
|
||||
|
||||
// -------------------- Invoice header --------------------
|
||||
|
||||
function pickInvoiceFields(formData: FormData) {
|
||||
return {
|
||||
customerId: String(formData.get("customerId") ?? ""),
|
||||
issueDate: String(formData.get("issueDate") ?? ""),
|
||||
dueDate: String(formData.get("dueDate") ?? ""),
|
||||
status:
|
||||
(formData.get("status") as "draft" | "sent" | "paid" | "overdue" | "cancelled" | null) ??
|
||||
"draft",
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createInvoiceAction(
|
||||
_prev: InvoiceActionState,
|
||||
formData: FormData,
|
||||
): Promise<InvoiceActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = invoiceSchema.safeParse(pickInvoiceFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
let invoiceId = "";
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const { number } = await nextInvoiceNumber(ctx.tenantId);
|
||||
|
||||
const data = {
|
||||
...parsed.data,
|
||||
issueDate: toIso(parsed.data.issueDate),
|
||||
dueDate: toIso(parsed.data.dueDate),
|
||||
number,
|
||||
subtotal: 0,
|
||||
vatTotal: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.invoices,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
...data,
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
invoiceId = row.$id;
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "invoice",
|
||||
entityId: row.$id,
|
||||
changes: { number, customerId: parsed.data.customerId },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/invoices");
|
||||
return { ok: true, invoiceId };
|
||||
}
|
||||
|
||||
export async function updateInvoiceAction(
|
||||
_prev: InvoiceActionState,
|
||||
formData: FormData,
|
||||
): Promise<InvoiceActionState> {
|
||||
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 = invoiceSchema.safeParse(pickInvoiceFields(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.invoices,
|
||||
id,
|
||||
)) as unknown as Invoice;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const data = {
|
||||
...parsed.data,
|
||||
issueDate: toIso(parsed.data.issueDate),
|
||||
dueDate: toIso(parsed.data.dueDate),
|
||||
};
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.invoices, id, data);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "invoice",
|
||||
entityId: id,
|
||||
changes: data,
|
||||
});
|
||||
|
||||
// Sync payment ↔ finance entry on every save (cheap; idempotent).
|
||||
const refreshed = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.invoices,
|
||||
id,
|
||||
)) as unknown as Invoice;
|
||||
await syncPaymentEntry(ctx.tenantId, ctx.user.id, refreshed);
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/invoices");
|
||||
revalidatePath(`/invoices/${id}`);
|
||||
revalidatePath("/finance");
|
||||
return { ok: true, invoiceId: id };
|
||||
}
|
||||
|
||||
export async function deleteInvoiceAction(
|
||||
formData: FormData,
|
||||
): Promise<InvoiceActionState> {
|
||||
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.invoices,
|
||||
id,
|
||||
)) as unknown as Invoice;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
// Delete items first
|
||||
const items = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.invoiceItems,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.equal("invoiceId", id),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
for (const it of items.rows) {
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.invoiceItems, it.$id);
|
||||
}
|
||||
|
||||
// Cascade-delete any auto-generated income entries linked to this invoice
|
||||
const linkedEntries = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.financeEntries,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.equal("invoiceId", id),
|
||||
Query.limit(50),
|
||||
],
|
||||
});
|
||||
for (const r of linkedEntries.rows) {
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, r.$id);
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.invoices, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "invoice",
|
||||
entityId: id,
|
||||
changes: {
|
||||
number: existing.number,
|
||||
items: items.rows.length,
|
||||
linkedFinanceEntries: linkedEntries.rows.length,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/invoices");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// -------------------- Invoice items --------------------
|
||||
|
||||
function pickItemFields(formData: FormData) {
|
||||
return {
|
||||
description: String(formData.get("description") ?? "").trim(),
|
||||
quantity: String(formData.get("quantity") ?? "1"),
|
||||
unitPrice: String(formData.get("unitPrice") ?? "0"),
|
||||
vatRate: String(formData.get("vatRate") ?? "20"),
|
||||
};
|
||||
}
|
||||
|
||||
function lineTotal(qty: number, unitPrice: number, vatRate: number): number {
|
||||
const net = qty * unitPrice;
|
||||
const vat = net * (vatRate / 100);
|
||||
return Number((net + vat).toFixed(2));
|
||||
}
|
||||
|
||||
export async function addInvoiceItemAction(
|
||||
_prev: InvoiceActionState,
|
||||
formData: FormData,
|
||||
): Promise<InvoiceActionState> {
|
||||
const invoiceId = String(formData.get("invoiceId") ?? "");
|
||||
if (!invoiceId) return { ok: false, error: "Fatura ID eksik." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = invoiceItemSchema.safeParse(pickItemFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Kalem geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const invoice = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.invoices,
|
||||
invoiceId,
|
||||
)) as unknown as Invoice;
|
||||
if (invoice.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const total = lineTotal(parsed.data.quantity, parsed.data.unitPrice, parsed.data.vatRate ?? 0);
|
||||
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.invoiceItems,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
invoiceId,
|
||||
description: parsed.data.description,
|
||||
quantity: parsed.data.quantity,
|
||||
unitPrice: parsed.data.unitPrice,
|
||||
vatRate: parsed.data.vatRate ?? 0,
|
||||
lineTotal: total,
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await recomputeTotals(ctx.tenantId, invoiceId, ctx.user.id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "invoice_item",
|
||||
entityId: row.$id,
|
||||
changes: { invoiceId, ...parsed.data, lineTotal: total },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath(`/invoices/${invoiceId}`);
|
||||
return { ok: true, invoiceId };
|
||||
}
|
||||
|
||||
export async function updateInvoiceItemAction(
|
||||
_prev: InvoiceActionState,
|
||||
formData: FormData,
|
||||
): Promise<InvoiceActionState> {
|
||||
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 = invoiceItemSchema.safeParse(pickItemFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Kalem geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const existing = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.invoiceItems,
|
||||
id,
|
||||
)) as unknown as InvoiceItem;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const total = lineTotal(parsed.data.quantity, parsed.data.unitPrice, parsed.data.vatRate ?? 0);
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.invoiceItems, id, {
|
||||
description: parsed.data.description,
|
||||
quantity: parsed.data.quantity,
|
||||
unitPrice: parsed.data.unitPrice,
|
||||
vatRate: parsed.data.vatRate ?? 0,
|
||||
lineTotal: total,
|
||||
});
|
||||
|
||||
await recomputeTotals(ctx.tenantId, existing.invoiceId, ctx.user.id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "invoice_item",
|
||||
entityId: id,
|
||||
changes: { ...parsed.data, lineTotal: total },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath(`/invoices/${(await getItemInvoiceId(id)) ?? ""}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function getItemInvoiceId(itemId: string): Promise<string | null> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.invoiceItems,
|
||||
itemId,
|
||||
)) as unknown as InvoiceItem;
|
||||
return row.invoiceId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteInvoiceItemAction(
|
||||
formData: FormData,
|
||||
): Promise<InvoiceActionState> {
|
||||
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.invoiceItems,
|
||||
id,
|
||||
)) as unknown as InvoiceItem;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.invoiceItems, id);
|
||||
await recomputeTotals(ctx.tenantId, existing.invoiceId, ctx.user.id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "invoice_item",
|
||||
entityId: id,
|
||||
changes: { invoiceId: existing.invoiceId },
|
||||
});
|
||||
|
||||
revalidatePath(`/invoices/${existing.invoiceId}`);
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
Reference in New Issue
Block a user