feat: emlak CRM iskelet kurulumu

- schema.ts tamamen yeniden yazıldı (properties, customers, customer_searches, property_matches, presentations, investors, activities, tenant_settings)
- Sidebar emlak modüllerine güncellendi (İlanlar, Müşteriler, Yatırımcılar, Sunumlar, Aktiviteler)
- Eski CRM lib dosyaları temizlendi (finance, invoice, lead, task, software, vs.)
- Yeni modül dizinleri oluşturuldu (stub pages)
- command-search emlak navigasyonuna güncellendi
- site-header temizlendi
- Typecheck: 0 hata (chart.tsx template hariç)
This commit is contained in:
egecankomur
2026-05-05 11:43:29 +03:00
parent 37679e83e6
commit 2f17c342ca
172 changed files with 422 additions and 23862 deletions
+4 -2
View File
@@ -4,7 +4,9 @@ import { headers } from "next/headers";
import { ID, Permission, Role } from "node-appwrite";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES, type AuditAction } from "./schema";
import { DATABASE_ID, TABLES } from "./schema";
type AuditAction = string;
export async function logAudit(args: {
tenantId: string;
@@ -23,7 +25,7 @@ export async function logAudit(args: {
const { tablesDB } = createAdminClient();
await tablesDB.createRow(
DATABASE_ID,
TABLES.auditLogs,
"audit_logs",
ID.unique(),
{
tenantId: args.tenantId,
-243
View File
@@ -1,243 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Query } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import { DATABASE_ID, TABLES, type BankAccount } from "./schema";
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { BankAccountActionState } from "./bank-account-types";
import { bankAccountSchema } from "@/lib/validation/bank-accounts";
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 {
bankName: String(formData.get("bankName") ?? "").trim(),
accountName: String(formData.get("accountName") ?? "").trim(),
iban: String(formData.get("iban") ?? "").trim(),
openingBalance: String(formData.get("openingBalance") ?? "0"),
notes: String(formData.get("notes") ?? "").trim(),
scope: (formData.get("scope") as "company" | "personal" | null) ?? "company",
};
}
export async function createBankAccountAction(
_prev: BankAccountActionState,
formData: FormData,
): Promise<BankAccountActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = bankAccountSchema.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.bankAccounts,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
...parsed.data,
},
scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "bank_account",
entityId: row.$id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/banks");
return { ok: true };
}
export async function updateBankAccountAction(
_prev: BankAccountActionState,
formData: FormData,
): Promise<BankAccountActionState> {
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 = bankAccountSchema.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.bankAccounts,
id,
)) as unknown as BankAccount;
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.updateRow(
DATABASE_ID,
TABLES.bankAccounts,
id,
parsed.data,
scopedRowPermissions(ctx.tenantId, existing.createdBy, parsed.data.scope),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "bank_account",
entityId: id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/banks");
return { ok: true };
}
export async function archiveBankAccountAction(
formData: FormData,
): Promise<BankAccountActionState> {
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.bankAccounts,
id,
)) as unknown as BankAccount;
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
const newArchivedState = !existing.archived;
await tablesDB.updateRow(DATABASE_ID, TABLES.bankAccounts, id, {
archived: newArchivedState,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "bank_account",
entityId: id,
changes: { archived: newArchivedState },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/banks");
return { ok: true };
}
export async function deleteBankAccountAction(
formData: FormData,
): Promise<BankAccountActionState> {
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.bankAccounts,
id,
)) as unknown as BankAccount;
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
// Block delete if any finance_entry still references this account.
const linked = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("bankAccountId", id),
Query.limit(1),
],
});
if (linked.rows.length > 0) {
return {
ok: false,
error:
"Bu hesaba bağlı finans hareketleri var. Önce hesabı arşivleyin veya hareketleri başka bir hesaba taşıyın.",
};
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.bankAccounts, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "bank_account",
entityId: id,
changes: { bankName: existing.bankName, accountName: existing.accountName },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/banks");
return { ok: true };
}
-99
View File
@@ -1,99 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { canAccessRow } from "./scope-permissions";
import { createAdminClient } from "./server";
import {
DATABASE_ID,
TABLES,
type BankAccount,
type FinanceEntry,
} from "./schema";
/**
* Returns bank accounts the current user is allowed to see:
* - all `company` scope rows
* - personal-scope rows where createdBy === currentUserId
*/
export async function listBankAccounts(
tenantId: string,
currentUserId?: string,
): Promise<BankAccount[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.bankAccounts,
queries: [
Query.equal("tenantId", tenantId),
Query.orderAsc("bankName"),
Query.limit(200),
],
});
const rows = result.rows as unknown as BankAccount[];
if (!currentUserId) return rows;
return rows.filter((r) => canAccessRow(r, currentUserId));
} catch {
return [];
}
}
/**
* Computes a current balance for each visible account.
*/
export async function getBankAccountBalances(
tenantId: string,
currentUserId?: string,
): Promise<Map<string, number>> {
const balances = new Map<string, number>();
try {
const accounts = await listBankAccounts(tenantId, currentUserId);
const visibleIds = new Set(accounts.map((a) => a.$id));
for (const a of accounts) balances.set(a.$id, a.openingBalance ?? 0);
const { tablesDB } = createAdminClient();
const entries = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [
Query.equal("tenantId", tenantId),
Query.isNotNull("bankAccountId"),
Query.limit(5000),
],
});
for (const e of entries.rows as unknown as FinanceEntry[]) {
if (!e.bankAccountId || !visibleIds.has(e.bankAccountId)) continue;
const cur = balances.get(e.bankAccountId);
if (cur === undefined) continue;
if (e.type === "income") balances.set(e.bankAccountId, cur + e.amount);
else if (e.type === "expense") balances.set(e.bankAccountId, cur - e.amount);
}
} catch {
/* ignore */
}
return balances;
}
export async function listEntriesForAccount(
tenantId: string,
bankAccountId: string,
limit = 25,
): Promise<FinanceEntry[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("bankAccountId", bankAccountId),
Query.orderDesc("date"),
Query.limit(limit),
],
});
return result.rows as unknown as FinanceEntry[];
} catch {
return [];
}
}
-7
View File
@@ -1,7 +0,0 @@
export type BankAccountActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialBankAccountState: BankAccountActionState = { ok: false };
-209
View File
@@ -1,209 +0,0 @@
"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 CalendarEvent } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { CalendarActionState } from "./calendar-types";
import { calendarEventSchema } from "@/lib/validation/calendar";
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 pickFormFields(formData: FormData) {
return {
title: String(formData.get("title") ?? "").trim(),
description: String(formData.get("description") ?? "").trim(),
start: String(formData.get("start") ?? ""),
end: String(formData.get("end") ?? ""),
allDay: formData.get("allDay") ?? false,
customerId: String(formData.get("customerId") ?? ""),
color: String(formData.get("color") ?? ""),
};
}
function toIso(v: string, allDay: boolean | undefined): string {
if (!v) return v;
// datetime-local => "YYYY-MM-DDTHH:mm"; date => "YYYY-MM-DD"
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
return `${v}T00:00:00.000+00:00`;
}
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(v)) {
return new Date(v).toISOString();
}
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)) {
return new Date(v).toISOString();
}
// fallback for already-iso
return v;
}
export async function createCalendarEventAction(
_prev: CalendarActionState,
formData: FormData,
): Promise<CalendarActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = calendarEventSchema.safeParse(pickFormFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const data = {
...parsed.data,
start: toIso(parsed.data.start, parsed.data.allDay),
end: toIso(parsed.data.end, parsed.data.allDay),
};
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.calendarEvents,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
...data,
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "calendar_event",
entityId: row.$id,
changes: data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/calendar");
return { ok: true };
}
export async function updateCalendarEventAction(
_prev: CalendarActionState,
formData: FormData,
): Promise<CalendarActionState> {
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 = calendarEventSchema.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.calendarEvents,
id,
)) as unknown as CalendarEvent;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const data = {
...parsed.data,
start: toIso(parsed.data.start, parsed.data.allDay),
end: toIso(parsed.data.end, parsed.data.allDay),
};
await tablesDB.updateRow(DATABASE_ID, TABLES.calendarEvents, id, data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "calendar_event",
entityId: id,
changes: data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/calendar");
return { ok: true };
}
export async function deleteCalendarEventAction(
formData: FormData,
): Promise<CalendarActionState> {
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.calendarEvents,
id,
)) as unknown as CalendarEvent;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.calendarEvents, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "calendar_event",
entityId: id,
changes: { title: existing.title },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/calendar");
return { ok: true };
}
-28
View File
@@ -1,28 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES, type CalendarEvent } from "./schema";
export async function listCalendarEvents(
tenantId: string,
rangeStart?: string,
rangeEnd?: string,
): Promise<CalendarEvent[]> {
try {
const { tablesDB } = createAdminClient();
const queries = [Query.equal("tenantId", tenantId), Query.limit(1000)];
if (rangeStart) queries.push(Query.greaterThanEqual("start", rangeStart));
if (rangeEnd) queries.push(Query.lessThanEqual("start", rangeEnd));
queries.push(Query.orderAsc("start"));
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.calendarEvents,
queries,
});
return result.rows as unknown as CalendarEvent[];
} catch {
return [];
}
}
-7
View File
@@ -1,7 +0,0 @@
export type CalendarActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialCalendarState: CalendarActionState = { ok: false };
-504
View File
@@ -1,504 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Query } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import {
DATABASE_ID,
TABLES,
type CreditCard,
type CreditCardStatement,
} from "./schema";
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { CreditCardActionState } from "./credit-card-types";
import { creditCardSchema, statementSchema } from "@/lib/validation/credit-cards";
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 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;
}
// ---------------- Cards ----------------
function pickCardFields(formData: FormData) {
return {
bankName: String(formData.get("bankName") ?? "").trim(),
cardName: String(formData.get("cardName") ?? "").trim(),
last4: String(formData.get("last4") ?? "").trim(),
creditLimit: String(formData.get("creditLimit") ?? "0"),
statementDay: String(formData.get("statementDay") ?? "1"),
dueDay: String(formData.get("dueDay") ?? "10"),
interestRate: String(formData.get("interestRate") ?? "4.25"),
bankAccountId: String(formData.get("bankAccountId") ?? ""),
notes: String(formData.get("notes") ?? "").trim(),
scope: (formData.get("scope") as "company" | "personal" | null) ?? "company",
};
}
export async function createCreditCardAction(
_prev: CreditCardActionState,
formData: FormData,
): Promise<CreditCardActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = creditCardSchema.safeParse(pickCardFields(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.creditCards,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
...parsed.data,
},
scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "credit_card",
entityId: row.$id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
return { ok: true };
}
export async function updateCreditCardAction(
_prev: CreditCardActionState,
formData: FormData,
): Promise<CreditCardActionState> {
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 = creditCardSchema.safeParse(pickCardFields(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.creditCards,
id,
)) as unknown as CreditCard;
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.updateRow(
DATABASE_ID,
TABLES.creditCards,
id,
parsed.data,
scopedRowPermissions(ctx.tenantId, existing.createdBy, parsed.data.scope),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "credit_card",
entityId: id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
return { ok: true };
}
export async function archiveCreditCardAction(
formData: FormData,
): Promise<CreditCardActionState> {
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.creditCards,
id,
)) as unknown as CreditCard;
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
const newArchivedState = !existing.archived;
await tablesDB.updateRow(DATABASE_ID, TABLES.creditCards, id, {
archived: newArchivedState,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "credit_card",
entityId: id,
changes: { archived: newArchivedState },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
return { ok: true };
}
export async function deleteCreditCardAction(
formData: FormData,
): Promise<CreditCardActionState> {
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.creditCards,
id,
)) as unknown as CreditCard;
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
// Cascade delete statements + their finance entries
const statements = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.creditCardStatements,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("cardId", id),
Query.limit(500),
],
});
for (const s of statements.rows as unknown as CreditCardStatement[]) {
if (s.financeEntryId) {
try {
await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, s.financeEntryId);
} catch {
/* ignore */
}
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.creditCardStatements, s.$id);
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.creditCards, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "credit_card",
entityId: id,
changes: { bankName: existing.bankName, cardName: existing.cardName, statements: statements.rows.length },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
return { ok: true };
}
// ---------------- Statements ----------------
function pickStatementFields(formData: FormData) {
return {
cardId: String(formData.get("cardId") ?? ""),
period: String(formData.get("period") ?? "").trim(),
statementDate: String(formData.get("statementDate") ?? ""),
dueDate: String(formData.get("dueDate") ?? ""),
totalDebt: String(formData.get("totalDebt") ?? "0"),
minimumPayment: String(formData.get("minimumPayment") ?? "0"),
notes: String(formData.get("notes") ?? "").trim(),
};
}
function computeStatus(
totalDebt: number,
paidAmount: number,
dueDate: string,
): "pending" | "partial" | "paid" | "overdue" {
if (paidAmount >= totalDebt) return "paid";
const past = new Date(dueDate).getTime() < Date.now();
if (paidAmount > 0) return past ? "overdue" : "partial";
return past ? "overdue" : "pending";
}
export async function createStatementAction(
_prev: CreditCardActionState,
formData: FormData,
): Promise<CreditCardActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = statementSchema.safeParse(pickStatementFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const card = (await tablesDB.getRow(
DATABASE_ID,
TABLES.creditCards,
parsed.data.cardId,
)) as unknown as CreditCard;
if (card.tenantId !== ctx.tenantId || !canAccessRow(card, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
const status = computeStatus(parsed.data.totalDebt, 0, parsed.data.dueDate);
// Statements inherit the card's scope.
const cardScope = card.scope ?? "company";
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.creditCardStatements,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
cardId: parsed.data.cardId,
period: parsed.data.period,
statementDate: toIso(parsed.data.statementDate),
dueDate: toIso(parsed.data.dueDate),
totalDebt: parsed.data.totalDebt,
minimumPayment: parsed.data.minimumPayment,
paidAmount: 0,
status,
notes: parsed.data.notes,
},
scopedRowPermissions(ctx.tenantId, ctx.user.id, cardScope),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "credit_card_statement",
entityId: row.$id,
changes: { cardId: parsed.data.cardId, period: parsed.data.period, totalDebt: parsed.data.totalDebt },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
return { ok: true };
}
export async function payStatementAction(formData: FormData): Promise<CreditCardActionState> {
const id = String(formData.get("id") ?? "");
const amountStr = String(formData.get("amount") ?? "");
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.creditCardStatements,
id,
)) as unknown as CreditCardStatement;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const remaining = (existing.totalDebt ?? 0) - (existing.paidAmount ?? 0);
if (remaining <= 0) {
return { ok: false, error: "Bu ekstrenin bakiyesi kalmamış." };
}
const amount = amountStr
? Number(amountStr.replace(",", "."))
: remaining;
if (!Number.isFinite(amount) || amount <= 0) {
return { ok: false, error: "Tutar geçersiz.", fieldErrors: { amount: "Geçersiz" } };
}
const payAmount = Math.min(amount, remaining);
const card = (await tablesDB.getRow(
DATABASE_ID,
TABLES.creditCards,
existing.cardId,
)) as unknown as CreditCard;
if (!canAccessRow(card, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
const cardScope = card.scope ?? "company";
const fe = await tablesDB.createRow(
DATABASE_ID,
TABLES.financeEntries,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
type: "expense",
amount: payAmount,
date: new Date().toISOString(),
description: `${card.bankName} ${card.cardName} ${existing.period} ekstre ödemesi`,
bankAccountId: card.bankAccountId,
scope: cardScope,
},
scopedRowPermissions(ctx.tenantId, ctx.user.id, cardScope),
);
const newPaid = (existing.paidAmount ?? 0) + payAmount;
const newStatus = computeStatus(existing.totalDebt ?? 0, newPaid, existing.dueDate);
await tablesDB.updateRow(DATABASE_ID, TABLES.creditCardStatements, id, {
paidAmount: Number(newPaid.toFixed(2)),
status: newStatus,
financeEntryId: newStatus === "paid" ? fe.$id : existing.financeEntryId ?? fe.$id,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "credit_card_statement",
entityId: id,
changes: { paidAmount: newPaid, status: newStatus, payAmount },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
revalidatePath("/finance");
return { ok: true };
}
export async function deleteStatementAction(
formData: FormData,
): Promise<CreditCardActionState> {
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.creditCardStatements,
id,
)) as unknown as CreditCardStatement;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
// Statement inherits its parent card's scope.
const parent = (await tablesDB.getRow(
DATABASE_ID,
TABLES.creditCards,
existing.cardId,
)) as unknown as CreditCard;
if (!canAccessRow(parent, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
if (existing.financeEntryId) {
try {
await tablesDB.deleteRow(
DATABASE_ID,
TABLES.financeEntries,
existing.financeEntryId,
);
} catch {
/* ignore */
}
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.creditCardStatements, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "credit_card_statement",
entityId: id,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
revalidatePath("/finance");
return { ok: true };
}
-74
View File
@@ -1,74 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { canAccessRow } from "./scope-permissions";
import { createAdminClient } from "./server";
import {
DATABASE_ID,
TABLES,
type CreditCard,
type CreditCardStatement,
} from "./schema";
export async function listCreditCards(
tenantId: string,
currentUserId?: string,
): Promise<CreditCard[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.creditCards,
queries: [
Query.equal("tenantId", tenantId),
Query.orderAsc("bankName"),
Query.limit(200),
],
});
const rows = result.rows as unknown as CreditCard[];
if (!currentUserId) return rows;
return rows.filter((r) => canAccessRow(r, currentUserId));
} catch {
return [];
}
}
/**
* Lists statements whose parent card is visible to the user.
*/
export async function listStatements(
tenantId: string,
currentUserId?: string,
): Promise<CreditCardStatement[]> {
try {
const { tablesDB } = createAdminClient();
const [allStmt, allCards] = await Promise.all([
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.creditCardStatements,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("statementDate"),
Query.limit(500),
],
}),
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.creditCards,
queries: [Query.equal("tenantId", tenantId), Query.limit(200)],
}),
]);
if (!currentUserId) return allStmt.rows as unknown as CreditCardStatement[];
const visibleCardIds = new Set(
(allCards.rows as unknown as CreditCard[])
.filter((c) => canAccessRow(c, currentUserId))
.map((c) => c.$id),
);
return (allStmt.rows as unknown as CreditCardStatement[]).filter((s) =>
visibleCardIds.has(s.cardId),
);
} catch {
return [];
}
}
-7
View File
@@ -1,7 +0,0 @@
export type CreditCardActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialCreditCardState: CreditCardActionState = { ok: false };
-203
View File
@@ -1,203 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import {
isPlanLimitError,
planLimitMessage,
requirePlanCapacity,
} from "./plan-limits";
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 {
await requirePlanCapacity(ctx, "customers");
} 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 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 };
}
-42
View File
@@ -1,42 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES, type Customer } from "./schema";
export async function listCustomers(tenantId: string): Promise<Customer[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.customers,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("$createdAt"),
Query.limit(500),
],
});
return result.rows as unknown as Customer[];
} catch {
return [];
}
}
export async function getCustomer(
tenantId: string,
id: string,
): Promise<Customer | null> {
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.customers,
id,
)) as unknown as Customer;
if (row.tenantId !== tenantId) return null;
return row;
} catch {
return null;
}
}
-8
View File
@@ -1,8 +0,0 @@
export type CustomerActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
code?: "PLAN_LIMIT_EXCEEDED";
};
export const initialCustomerState: CustomerActionState = { ok: false };
-236
View File
@@ -1,236 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import {
DATABASE_ID,
TABLES,
type Customer,
type FinanceEntry,
type Invoice,
type Task,
} from "./schema";
export type DashboardData = {
metrics: {
totalCustomers: number;
activeCustomers: number;
monthIncome: number; // current month income
prevMonthIncome: number; // previous month income (for delta)
outstanding: number; // unpaid invoices total (draft+sent+overdue)
overdueCount: number;
openTasks: number;
urgentTasks: number;
};
monthlyIncome: { month: string; income: number; expense: number }[]; // last 12 months
topCustomers: { name: string; total: number }[]; // top 5 by paid invoice total
recentTransactions: {
id: string;
type: FinanceEntry["type"];
amount: number;
date: string;
customerName: string;
description: string;
}[];
topServices: { name: string; total: number; count: number }[]; // top 5 services by revenue (qty*unitPrice)
newCustomersMonthly: { month: string; count: number }[]; // last 6 months
};
const MONTH_SHORT = ["Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara"];
function monthKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
function monthLabel(d: Date): string {
return MONTH_SHORT[d.getMonth()];
}
export async function getDashboardData(
tenantId: string,
currentUserId?: string,
): Promise<DashboardData> {
const { tablesDB } = createAdminClient();
const [customers, invoices, financeEntries, tasks, services] = await Promise.all([
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.customers,
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
})
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.invoices,
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
})
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("date"),
Query.limit(2000),
],
})
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tasks,
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
})
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.services,
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
})
.catch(() => ({ rows: [] as unknown[] })),
]);
const customerList = customers.rows as unknown as Customer[];
const invoiceList = invoices.rows as unknown as Invoice[];
// Dashboard KPIs reflect company finances only — personal-scope rows belong
// to a single user and shouldn't influence team-level metrics.
const entryList = (financeEntries.rows as unknown as FinanceEntry[]).filter(
(e) => (e.scope ?? "company") === "company",
);
const taskList = tasks.rows as unknown as Task[];
const customerMap = new Map(customerList.map((c) => [c.$id, c.name]));
// ---------------- Metrics ----------------
const now = new Date();
const thisMonth = monthKey(now);
const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevMonth = monthKey(prev);
let monthIncome = 0;
let prevMonthIncome = 0;
for (const e of entryList) {
if (e.type !== "income") continue;
const k = monthKey(new Date(e.date));
if (k === thisMonth) monthIncome += e.amount;
else if (k === prevMonth) prevMonthIncome += e.amount;
}
let outstanding = 0;
let overdueCount = 0;
const today = new Date();
for (const inv of invoiceList) {
const status = inv.status ?? "draft";
if (status === "paid" || status === "cancelled") continue;
outstanding += inv.total ?? 0;
if (status === "overdue" || (inv.dueDate && new Date(inv.dueDate) < today)) {
overdueCount += 1;
}
}
let openTasks = 0;
let urgentTasks = 0;
for (const t of taskList) {
if ((t.status ?? "todo") === "done") continue;
// Personal scope: own assigned + unassigned. Falls back to all if no userId.
if (currentUserId) {
const assignee = t.assigneeId ?? "";
if (assignee && assignee !== currentUserId) continue;
}
openTasks += 1;
if ((t.priority ?? "medium") === "urgent") urgentTasks += 1;
}
const activeCustomers = customerList.filter((c) => (c.status ?? "active") === "active").length;
// ---------------- Monthly income/expense (last 12 months) ----------------
const monthSeries: DashboardData["monthlyIncome"] = [];
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
monthSeries.push({ month: monthLabel(d), income: 0, expense: 0 });
}
for (const e of entryList) {
const ed = new Date(e.date);
const monthsAgo =
(now.getFullYear() - ed.getFullYear()) * 12 + (now.getMonth() - ed.getMonth());
if (monthsAgo < 0 || monthsAgo > 11) continue;
const idx = 11 - monthsAgo;
if (e.type === "income") monthSeries[idx].income += e.amount;
else if (e.type === "expense") monthSeries[idx].expense += e.amount;
}
// ---------------- Top customers (by paid invoice total) ----------------
const customerRevenue = new Map<string, number>();
for (const inv of invoiceList) {
if (inv.status !== "paid") continue;
customerRevenue.set(
inv.customerId,
(customerRevenue.get(inv.customerId) ?? 0) + (inv.total ?? 0),
);
}
const topCustomers = Array.from(customerRevenue.entries())
.map(([id, total]) => ({ name: customerMap.get(id) ?? "—", total }))
.sort((a, b) => b.total - a.total)
.slice(0, 5);
// ---------------- Recent transactions (last 8) ----------------
const recentTransactions = entryList.slice(0, 8).map((e) => ({
id: e.$id,
type: e.type,
amount: e.amount,
date: e.date,
customerName: e.customerId ? customerMap.get(e.customerId) ?? "" : "",
description: e.description ?? "",
}));
// ---------------- Top services (by current MRR estimate) ----------------
const svcMap = new Map<string, { name: string; total: number; count: number }>();
for (const s of services.rows as unknown as { name: string; unitPrice?: number }[]) {
const key = s.name;
const entry = svcMap.get(key) ?? { name: s.name, total: 0, count: 0 };
entry.total += s.unitPrice ?? 0;
entry.count += 1;
svcMap.set(key, entry);
}
const topServices = Array.from(svcMap.values())
.sort((a, b) => b.total - a.total)
.slice(0, 5);
// ---------------- New customers per month (last 6) ----------------
const newCustomersMonthly: DashboardData["newCustomersMonthly"] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
newCustomersMonthly.push({ month: monthLabel(d), count: 0 });
}
for (const c of customerList) {
const cd = new Date(c.$createdAt);
const monthsAgo =
(now.getFullYear() - cd.getFullYear()) * 12 + (now.getMonth() - cd.getMonth());
if (monthsAgo < 0 || monthsAgo > 5) continue;
const idx = 5 - monthsAgo;
newCustomersMonthly[idx].count += 1;
}
return {
metrics: {
totalCustomers: customerList.length,
activeCustomers,
monthIncome,
prevMonthIncome,
outstanding,
overdueCount,
openTasks,
urgentTasks,
},
monthlyIncome: monthSeries,
topCustomers,
recentTransactions,
topServices,
newCustomersMonthly,
};
}
-215
View File
@@ -1,215 +0,0 @@
"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 };
}
-30
View File
@@ -1,30 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { canAccessRow } from "./scope-permissions";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
export async function listFinanceEntries(
tenantId: string,
currentUserId?: string,
): Promise<FinanceEntry[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("date"),
Query.limit(1000),
],
});
const rows = result.rows as unknown as FinanceEntry[];
if (!currentUserId) return rows;
return rows.filter((r) => canAccessRow(r, currentUserId));
} catch {
return [];
}
}
-401
View File
@@ -1,401 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import {
DATABASE_ID,
TABLES,
type BankAccount,
type BankLoan,
type CreditCard,
type CreditCardStatement,
type Customer,
type FinanceEntry,
type Invoice,
type LoanInstallment,
} from "./schema";
export type ReportPeriod = "month" | "quarter" | "year" | "all";
export type FinancialReport = {
period: ReportPeriod;
periodStart: string | null;
periodEnd: string | null;
// KPIs
kpi: {
income: number;
expense: number;
net: number;
cashPosition: number; // bank balances + receivables - loan remaining - card outstanding
};
// Cash composition (right now, not period-bound)
composition: {
bankBalances: number;
receivables: number; // unpaid invoices total (not paid, not cancelled)
loanRemaining: number;
cardOutstanding: number;
};
// Trend (last 12 months income/expense)
trend: { month: string; income: number; expense: number; net: number }[];
// Top customers by paid invoice total (period-bound when period != all)
topCustomers: { name: string; total: number }[];
// Top expense buckets — auto-grouped by source
expenseBreakdown: {
invoices: number; // expenses linked to invoices? we don't track AP invoices yet — leave 0
loans: number; // finance_entries linked through loan installments (description match heuristic)
cards: number;
other: number;
};
// Active loans
loans: {
id: string;
bankName: string;
loanName: string;
principal: number;
remaining: number;
monthlyPayment: number;
nextDue: string | null;
}[];
// Credit card outstanding statements
cardStatements: {
id: string;
cardLabel: string;
period: string;
dueDate: string;
remaining: number;
status: "pending" | "partial" | "overdue";
}[];
// Outstanding (unpaid) invoices
outstandingInvoices: {
id: string;
number: string;
customerName: string;
dueDate: string;
total: number;
overdue: boolean;
}[];
};
const MONTH_SHORT = ["Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara"];
function periodBounds(p: ReportPeriod): { start: Date | null; end: Date | null } {
const now = new Date();
if (p === "month") {
return {
start: new Date(now.getFullYear(), now.getMonth(), 1),
end: new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59),
};
}
if (p === "quarter") {
const q = Math.floor(now.getMonth() / 3);
return {
start: new Date(now.getFullYear(), q * 3, 1),
end: new Date(now.getFullYear(), q * 3 + 3, 0, 23, 59, 59),
};
}
if (p === "year") {
return {
start: new Date(now.getFullYear(), 0, 1),
end: new Date(now.getFullYear(), 11, 31, 23, 59, 59),
};
}
return { start: null, end: null };
}
function inRange(iso: string, start: Date | null, end: Date | null): boolean {
if (!start || !end) return true;
const t = new Date(iso).getTime();
return t >= start.getTime() && t <= end.getTime();
}
export async function getFinancialReport(
tenantId: string,
period: ReportPeriod = "month",
): Promise<FinancialReport> {
const { tablesDB } = createAdminClient();
const [
customers,
invoices,
finance,
bankAccounts,
loans,
installments,
cards,
statements,
] = await Promise.all([
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.customers,
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
})
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.invoices,
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
})
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [Query.equal("tenantId", tenantId), Query.limit(5000)],
})
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.bankAccounts,
queries: [Query.equal("tenantId", tenantId), Query.limit(200)],
})
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.bankLoans,
queries: [Query.equal("tenantId", tenantId), Query.limit(200)],
})
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.loanInstallments,
queries: [Query.equal("tenantId", tenantId), Query.limit(5000)],
})
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.creditCards,
queries: [Query.equal("tenantId", tenantId), Query.limit(200)],
})
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.creditCardStatements,
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
})
.catch(() => ({ rows: [] as unknown[] })),
]);
// Reports reflect COMPANY finances only — personal-scope entities are
// private to their creator and must not flow into team-level metrics.
const isCompany = <T extends { scope?: "company" | "personal" }>(r: T) =>
(r.scope ?? "company") === "company";
const customerList = customers.rows as unknown as Customer[];
const invoiceList = invoices.rows as unknown as Invoice[];
const entryList = (finance.rows as unknown as FinanceEntry[]).filter(isCompany);
const bankList = (bankAccounts.rows as unknown as BankAccount[]).filter(isCompany);
const loanList = (loans.rows as unknown as BankLoan[]).filter(isCompany);
const cardList = (cards.rows as unknown as CreditCard[]).filter(isCompany);
const visibleLoanIds = new Set(loanList.map((l) => l.$id));
const visibleCardIds = new Set(cardList.map((c) => c.$id));
const installmentList = (installments.rows as unknown as LoanInstallment[]).filter(
(i) => visibleLoanIds.has(i.loanId),
);
const statementList = (statements.rows as unknown as CreditCardStatement[]).filter(
(s) => visibleCardIds.has(s.cardId),
);
const customerMap = new Map(customerList.map((c) => [c.$id, c.name]));
const cardMap = new Map(
cardList.map((c) => [c.$id, `${c.bankName}${c.cardName}${c.last4 ? ` **${c.last4}` : ""}`]),
);
const { start, end } = periodBounds(period);
// ---------- KPIs (period-bound for income/expense, current for cash) ----------
let income = 0;
let expense = 0;
for (const e of entryList) {
if (!inRange(e.date, start, end)) continue;
if (e.type === "income") income += e.amount;
else if (e.type === "expense") expense += e.amount;
}
// bank balance (today, not period-bound)
const balances = new Map<string, number>();
for (const a of bankList) balances.set(a.$id, a.openingBalance ?? 0);
for (const e of entryList) {
if (!e.bankAccountId) continue;
const cur = balances.get(e.bankAccountId);
if (cur === undefined) continue;
if (e.type === "income") balances.set(e.bankAccountId, cur + e.amount);
else if (e.type === "expense") balances.set(e.bankAccountId, cur - e.amount);
}
const bankBalances = Array.from(balances.values()).reduce((s, n) => s + n, 0);
// receivables = sum of unpaid invoices
const receivables = invoiceList.reduce((s, inv) => {
const st = inv.status ?? "draft";
if (st === "paid" || st === "cancelled") return s;
return s + (inv.total ?? 0);
}, 0);
// loan remaining = sum of unpaid installments
const loanRemaining = installmentList.reduce(
(s, i) => (i.paid ? s : s + (i.amount ?? 0)),
0,
);
// card outstanding = sum of (totalDebt - paidAmount) for non-paid statements
const cardOutstanding = statementList.reduce(
(s, st) =>
st.status === "paid" ? s : s + ((st.totalDebt ?? 0) - (st.paidAmount ?? 0)),
0,
);
const cashPosition = bankBalances + receivables - loanRemaining - cardOutstanding;
// ---------- Trend (last 12 months always) ----------
const trend: FinancialReport["trend"] = [];
const now = new Date();
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
trend.push({
month: MONTH_SHORT[d.getMonth()],
income: 0,
expense: 0,
net: 0,
});
}
for (const e of entryList) {
const ed = new Date(e.date);
const monthsAgo =
(now.getFullYear() - ed.getFullYear()) * 12 + (now.getMonth() - ed.getMonth());
if (monthsAgo < 0 || monthsAgo > 11) continue;
const idx = 11 - monthsAgo;
if (e.type === "income") trend[idx].income += e.amount;
else if (e.type === "expense") trend[idx].expense += e.amount;
}
for (const t of trend) t.net = t.income - t.expense;
// ---------- Top customers (period-bound paid invoices) ----------
const customerRevenue = new Map<string, number>();
for (const inv of invoiceList) {
if (inv.status !== "paid") continue;
if (start && !inRange(inv.issueDate, start, end)) continue;
customerRevenue.set(
inv.customerId,
(customerRevenue.get(inv.customerId) ?? 0) + (inv.total ?? 0),
);
}
const topCustomers = Array.from(customerRevenue.entries())
.map(([id, total]) => ({ name: customerMap.get(id) ?? "—", total }))
.sort((a, b) => b.total - a.total)
.slice(0, 8);
// ---------- Expense breakdown (period-bound) ----------
let expLoans = 0;
let expCards = 0;
let expOther = 0;
const installmentEntryIds = new Set(
installmentList.map((i) => i.financeEntryId).filter(Boolean) as string[],
);
const statementEntryIds = new Set(
statementList.map((s) => s.financeEntryId).filter(Boolean) as string[],
);
for (const e of entryList) {
if (e.type !== "expense") continue;
if (!inRange(e.date, start, end)) continue;
if (installmentEntryIds.has(e.$id)) expLoans += e.amount;
else if (statementEntryIds.has(e.$id)) expCards += e.amount;
else expOther += e.amount;
}
// ---------- Active loans summary ----------
const installmentsByLoan = new Map<string, LoanInstallment[]>();
for (const i of installmentList) {
const arr = installmentsByLoan.get(i.loanId) ?? [];
arr.push(i);
installmentsByLoan.set(i.loanId, arr);
}
const loansSummary = loanList
.filter((l) => (l.status ?? "active") === "active")
.map((l) => {
const items = installmentsByLoan.get(l.$id) ?? [];
const remaining = items.filter((i) => !i.paid).reduce((s, i) => s + i.amount, 0);
const unpaid = items.filter((i) => !i.paid).sort(
(a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime(),
);
return {
id: l.$id,
bankName: l.bankName,
loanName: l.loanName,
principal: l.principal,
remaining,
monthlyPayment: l.monthlyPayment ?? 0,
nextDue: unpaid[0]?.dueDate ?? null,
};
})
.sort((a, b) => b.remaining - a.remaining);
// ---------- Credit card outstanding statements ----------
const cardStmts = statementList
.filter((s) => s.status !== "paid")
.map((s) => ({
id: s.$id,
cardLabel: cardMap.get(s.cardId) ?? "—",
period: s.period,
dueDate: s.dueDate,
remaining: (s.totalDebt ?? 0) - (s.paidAmount ?? 0),
status: (s.status ?? "pending") as "pending" | "partial" | "overdue",
}))
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime());
// ---------- Outstanding invoices ----------
const today = new Date();
const outstandingInvoices = invoiceList
.filter((inv) => {
const st = inv.status ?? "draft";
return st !== "paid" && st !== "cancelled";
})
.map((inv) => ({
id: inv.$id,
number: inv.number,
customerName: customerMap.get(inv.customerId) ?? "—",
dueDate: inv.dueDate,
total: inv.total ?? 0,
overdue: new Date(inv.dueDate) < today,
}))
.sort((a, b) => {
if (a.overdue !== b.overdue) return a.overdue ? -1 : 1;
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
})
.slice(0, 12);
return {
period,
periodStart: start ? start.toISOString() : null,
periodEnd: end ? end.toISOString() : null,
kpi: {
income,
expense,
net: income - expense,
cashPosition,
},
composition: {
bankBalances,
receivables,
loanRemaining,
cardOutstanding,
},
trend,
topCustomers,
expenseBreakdown: {
invoices: 0,
loans: expLoans,
cards: expCards,
other: expOther,
},
loans: loansSummary.slice(0, 8),
cardStatements: cardStmts.slice(0, 12),
outstandingInvoices,
};
}
-8
View File
@@ -1,8 +0,0 @@
export type FinanceActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
code?: "PLAN_LIMIT_EXCEEDED";
};
export const initialFinanceState: FinanceActionState = { ok: false };
-620
View File
@@ -1,620 +0,0 @@
"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 };
}
-64
View File
@@ -1,64 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES, type Invoice, type InvoiceItem } from "./schema";
export async function listInvoices(tenantId: string): Promise<Invoice[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.invoices,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("issueDate"),
Query.limit(500),
],
});
return result.rows as unknown as Invoice[];
} catch {
return [];
}
}
export async function getInvoice(
tenantId: string,
id: string,
): Promise<Invoice | null> {
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.invoices,
id,
)) as unknown as Invoice;
if (row.tenantId !== tenantId) return null;
return row;
} catch {
return null;
}
}
export async function listInvoiceItems(
tenantId: string,
invoiceId: string,
): Promise<InvoiceItem[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.invoiceItems,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("invoiceId", invoiceId),
Query.orderAsc("$createdAt"),
Query.limit(500),
],
});
return result.rows as unknown as InvoiceItem[];
} catch {
return [];
}
}
-8
View File
@@ -1,8 +0,0 @@
export type InvoiceActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
invoiceId?: string;
};
export const initialInvoiceState: InvoiceActionState = { ok: false };
-291
View File
@@ -1,291 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import type { LeadActionState } from "./lead-types";
import { DATABASE_ID, TABLES, type Lead, type LeadStatus } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import { leadSchema } from "@/lib/validation/leads";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) return e.message || "Beklenmeyen bir hata oluştu.";
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 pickFormFields(formData: FormData) {
return {
name: String(formData.get("name") ?? "").trim(),
contactName: String(formData.get("contactName") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
phone: String(formData.get("phone") ?? "").trim(),
source: String(formData.get("source") ?? "other"),
status: String(formData.get("status") ?? "cold"),
estimatedValue: String(formData.get("estimatedValue") ?? ""),
currency: String(formData.get("currency") ?? "TRY"),
notes: String(formData.get("notes") ?? "").trim(),
assigneeId: String(formData.get("assigneeId") ?? ""),
};
}
export async function createLeadAction(
_prev: LeadActionState,
formData: FormData,
): Promise<LeadActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = leadSchema.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.leads,
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: "lead",
entityId: row.$id,
changes: { name: parsed.data.name, status: parsed.data.status },
});
revalidatePath("/leads");
return { ok: true, leadId: row.$id };
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
}
export async function updateLeadAction(
_prev: LeadActionState,
formData: FormData,
): Promise<LeadActionState> {
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 = leadSchema.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.leads, id)) as unknown as Lead;
if (existing.tenantId !== ctx.tenantId) return { ok: false, error: "Erişim engellendi." };
await tablesDB.updateRow(DATABASE_ID, TABLES.leads, id, parsed.data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "lead",
entityId: id,
changes: parsed.data,
});
revalidatePath("/leads");
return { ok: true, leadId: id };
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
}
export async function moveLeadAction(
leadId: string,
status: LeadStatus,
): Promise<LeadActionState> {
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.leads, leadId)) as unknown as Lead;
if (existing.tenantId !== ctx.tenantId) return { ok: false, error: "Erişim engellendi." };
const prevStatus = existing.status;
await tablesDB.updateRow(DATABASE_ID, TABLES.leads, leadId, {
status,
lastContactAt: new Date().toISOString(),
});
// Auto-create a status_change activity
await tablesDB.createRow(
DATABASE_ID,
TABLES.leadActivities,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
leadId,
type: "status_change",
content: `${prevStatus ?? "cold"}${status}`,
occurredAt: new Date().toISOString(),
},
[
Permission.read(Role.team(ctx.tenantId)),
Permission.update(Role.team(ctx.tenantId)),
Permission.delete(Role.team(ctx.tenantId, "owner")),
],
);
revalidatePath("/leads");
return { ok: true };
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
}
export async function deleteLeadAction(formData: FormData): Promise<LeadActionState> {
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.leads, id)) as unknown as Lead;
if (existing.tenantId !== ctx.tenantId) return { ok: false, error: "Erişim engellendi." };
await tablesDB.deleteRow(DATABASE_ID, TABLES.leads, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "lead",
entityId: id,
changes: { name: existing.name },
});
revalidatePath("/leads");
return { ok: true };
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
}
export async function convertLeadToCustomerAction(formData: FormData): Promise<LeadActionState> {
const leadId = String(formData.get("leadId") ?? "");
if (!leadId) return { ok: false, error: "Lead ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const lead = (await tablesDB.getRow(DATABASE_ID, TABLES.leads, leadId)) as unknown as Lead;
if (lead.tenantId !== ctx.tenantId) return { ok: false, error: "Erişim engellendi." };
const permissions = teamRowPermissions(ctx.tenantId);
// Create customer from lead data
const customer = await tablesDB.createRow(
DATABASE_ID,
TABLES.customers,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
name: lead.contactName || lead.name,
email: lead.email,
phone: lead.phone,
notes: lead.notes,
status: "active",
},
permissions,
);
// Update lead: mark converted + link customerId
await tablesDB.updateRow(DATABASE_ID, TABLES.leads, leadId, {
status: "converted",
customerId: customer.$id,
lastContactAt: new Date().toISOString(),
});
await tablesDB.createRow(
DATABASE_ID,
TABLES.leadActivities,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
leadId,
type: "status_change",
content: `Müşteriye dönüştürüldü → ${lead.contactName || lead.name}`,
occurredAt: new Date().toISOString(),
},
permissions,
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "customer_from_lead",
entityId: customer.$id,
changes: { leadId, customerId: customer.$id },
});
revalidatePath("/leads");
revalidatePath("/customers");
return { ok: true, leadId: customer.$id };
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
}
-145
View File
@@ -1,145 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { ID, Permission, Role } from "node-appwrite";
import { DATABASE_ID, TABLES, type Lead, type LeadActivityType } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
export type ActivityActionState = { ok: boolean; error?: string };
export async function addLeadActivityAction(
_prev: ActivityActionState,
formData: FormData,
): Promise<ActivityActionState> {
const leadId = String(formData.get("leadId") ?? "");
const type = String(formData.get("type") ?? "note") as LeadActivityType;
const content = String(formData.get("content") ?? "").trim();
const occurredAt = String(formData.get("occurredAt") ?? "") || new Date().toISOString();
if (!leadId || !content) return { ok: false, error: "Zorunlu alanlar eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const lead = (await tablesDB.getRow(DATABASE_ID, TABLES.leads, leadId)) as unknown as Lead;
if (lead.tenantId !== ctx.tenantId) return { ok: false, error: "Erişim engellendi." };
await tablesDB.createRow(
DATABASE_ID,
TABLES.leadActivities,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
leadId,
type,
content,
occurredAt,
},
[
Permission.read(Role.team(ctx.tenantId)),
Permission.update(Role.team(ctx.tenantId)),
Permission.delete(Role.team(ctx.tenantId, "owner")),
],
);
// Update lastContactAt on the lead
await tablesDB.updateRow(DATABASE_ID, TABLES.leads, leadId, {
lastContactAt: new Date().toISOString(),
});
revalidatePath("/leads");
return { ok: true };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "Hata oluştu." };
}
}
export async function scheduleFollowUpAction(
_prev: ActivityActionState,
formData: FormData,
): Promise<ActivityActionState> {
const leadId = String(formData.get("leadId") ?? "");
const followUpAt = String(formData.get("followUpAt") ?? "");
const note = String(formData.get("note") ?? "").trim();
if (!leadId || !followUpAt) return { ok: false, error: "Tarih seçin." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const lead = (await tablesDB.getRow(DATABASE_ID, TABLES.leads, leadId)) as unknown as Lead;
if (lead.tenantId !== ctx.tenantId) return { ok: false, error: "Erişim engellendi." };
const followUpDate = new Date(followUpAt);
const endDate = new Date(followUpDate.getTime() + 60 * 60 * 1000); // +1h
const permissions = [
Permission.read(Role.team(ctx.tenantId)),
Permission.update(Role.team(ctx.tenantId)),
Permission.delete(Role.team(ctx.tenantId, "owner")),
];
// Create calendar event
const event = await tablesDB.createRow(
DATABASE_ID,
TABLES.calendarEvents,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
title: `Takip: ${lead.contactName || lead.name}`,
description: note || `Lead takip görüşmesi — ${lead.name}`,
start: followUpDate.toISOString(),
end: endDate.toISOString(),
allDay: false,
leadId,
color: "#f97316", // orange — lead events
},
permissions,
);
// Update lead nextFollowUpAt + calendarEventId
await tablesDB.updateRow(DATABASE_ID, TABLES.leads, leadId, {
nextFollowUpAt: followUpDate.toISOString(),
calendarEventId: event.$id,
});
// Log activity
await tablesDB.createRow(
DATABASE_ID,
TABLES.leadActivities,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
leadId,
type: "meeting",
content: `Takip planlandı: ${followUpDate.toLocaleString("tr-TR")}${note ? `${note}` : ""}`,
calendarEventId: event.$id,
occurredAt: new Date().toISOString(),
},
permissions,
);
revalidatePath("/leads");
revalidatePath("/calendar");
return { ok: true };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "Hata oluştu." };
}
}
-51
View File
@@ -1,51 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES, type Lead, type LeadActivity } from "./schema";
export async function listLeads(tenantId: string): Promise<Lead[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.leads,
queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(500)],
});
return result.rows as unknown as Lead[];
} catch {
return [];
}
}
export async function getLead(tenantId: string, leadId: string): Promise<Lead | null> {
try {
const { tablesDB } = createAdminClient();
const row = await tablesDB.getRow(DATABASE_ID, TABLES.leads, leadId);
const lead = row as unknown as Lead;
if (lead.tenantId !== tenantId) return null;
return lead;
} catch {
return null;
}
}
export async function listLeadActivities(tenantId: string, leadId: string): Promise<LeadActivity[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.leadActivities,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("leadId", leadId),
Query.orderDesc("$createdAt"),
Query.limit(100),
],
});
return result.rows as unknown as LeadActivity[];
} catch {
return [];
}
}
-8
View File
@@ -1,8 +0,0 @@
export type LeadActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
leadId?: string;
};
export const initialLeadState: LeadActionState = { ok: false };
-442
View File
@@ -1,442 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Query } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import {
DATABASE_ID,
TABLES,
type BankLoan,
type LoanInstallment,
} from "./schema";
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { LoanActionState } from "./loan-types";
import { bankLoanSchema } from "@/lib/validation/bank-loans";
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 pickLoanFields(formData: FormData) {
return {
bankAccountId: String(formData.get("bankAccountId") ?? ""),
bankName: String(formData.get("bankName") ?? "").trim(),
loanName: String(formData.get("loanName") ?? "").trim(),
loanType: formData.get("loanType") as
| "consumer"
| "vehicle"
| "housing"
| "commercial"
| "kmh"
| "other"
| null,
principal: String(formData.get("principal") ?? "0"),
interestRate: String(formData.get("interestRate") ?? "0"),
termMonths: String(formData.get("termMonths") ?? "12"),
startDate: String(formData.get("startDate") ?? ""),
paymentDay: String(formData.get("paymentDay") ?? "1"),
notes: String(formData.get("notes") ?? "").trim(),
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;
}
/**
* Standard amortization (eşit taksitli kredi).
* monthlyPayment = P × r × (1+r)^n / ((1+r)^n 1)
* where r = monthly interest rate (decimal), n = termMonths
*/
function computeAmortization(
principal: number,
monthlyRatePct: number,
n: number,
): {
monthlyPayment: number;
schedule: Array<{ principalPart: number; interestPart: number; amount: number }>;
} {
const r = monthlyRatePct / 100;
let monthlyPayment: number;
if (r === 0) {
monthlyPayment = principal / n;
} else {
const factor = Math.pow(1 + r, n);
monthlyPayment = (principal * r * factor) / (factor - 1);
}
monthlyPayment = Number(monthlyPayment.toFixed(2));
const schedule: Array<{ principalPart: number; interestPart: number; amount: number }> = [];
let remaining = principal;
for (let i = 0; i < n; i++) {
const interestPart = Number((remaining * r).toFixed(2));
let principalPart = Number((monthlyPayment - interestPart).toFixed(2));
// Final installment absorbs rounding drift
if (i === n - 1) {
principalPart = Number(remaining.toFixed(2));
}
const amount = Number((interestPart + principalPart).toFixed(2));
remaining = Number((remaining - principalPart).toFixed(2));
schedule.push({ principalPart, interestPart, amount });
}
return { monthlyPayment, schedule };
}
function shiftMonth(date: Date, monthsAhead: number, paymentDay: number): Date {
const d = new Date(date.getFullYear(), date.getMonth() + monthsAhead, 1);
// clamp paymentDay to last day of that month
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(paymentDay, lastDay));
return d;
}
export async function createLoanAction(
_prev: LoanActionState,
formData: FormData,
): Promise<LoanActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = bankLoanSchema.safeParse(pickLoanFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
const { schedule, monthlyPayment } = computeAmortization(
parsed.data.principal,
parsed.data.interestRate,
parsed.data.termMonths,
);
let loanId: string | null = null;
const admin = createAdminClient();
// Installments inherit the loan's scope so personal-loan installments stay
// hidden from the rest of the team too.
const rowPerms = scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope);
try {
const loan = await admin.tablesDB.createRow(
DATABASE_ID,
TABLES.bankLoans,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
bankAccountId: parsed.data.bankAccountId,
bankName: parsed.data.bankName,
loanName: parsed.data.loanName,
loanType: parsed.data.loanType,
principal: parsed.data.principal,
interestRate: parsed.data.interestRate,
termMonths: parsed.data.termMonths,
monthlyPayment,
startDate: toIso(parsed.data.startDate),
paymentDay: parsed.data.paymentDay,
status: "active",
notes: parsed.data.notes,
scope: parsed.data.scope,
},
rowPerms,
);
loanId = loan.$id;
const start = new Date(toIso(parsed.data.startDate));
for (let i = 0; i < parsed.data.termMonths; i++) {
const due = shiftMonth(start, i + 1, parsed.data.paymentDay ?? 1);
const slice = schedule[i];
await admin.tablesDB.createRow(
DATABASE_ID,
TABLES.loanInstallments,
ID.unique(),
{
tenantId: ctx.tenantId,
loanId,
installmentNo: i + 1,
dueDate: due.toISOString(),
amount: slice.amount,
principalPart: slice.principalPart,
interestPart: slice.interestPart,
paid: false,
},
rowPerms,
);
}
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "bank_loan",
entityId: loan.$id,
changes: { ...parsed.data, monthlyPayment, installments: parsed.data.termMonths },
});
} catch (e) {
if (loanId) {
// Best-effort rollback: delete partially-created installments + loan
try {
const partial = await admin.tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.loanInstallments,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("loanId", loanId),
Query.limit(500),
],
});
for (const r of partial.rows) {
await admin.tablesDB.deleteRow(DATABASE_ID, TABLES.loanInstallments, r.$id);
}
await admin.tablesDB.deleteRow(DATABASE_ID, TABLES.bankLoans, loanId);
} catch {
/* ignore */
}
}
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/loans");
return { ok: true, loanId };
}
export async function deleteLoanAction(formData: FormData): Promise<LoanActionState> {
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.bankLoans,
id,
)) as unknown as BankLoan;
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
// Delete installments first
const installments = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.loanInstallments,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("loanId", id),
Query.limit(500),
],
});
for (const r of installments.rows) {
await tablesDB.deleteRow(DATABASE_ID, TABLES.loanInstallments, r.$id);
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.bankLoans, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "bank_loan",
entityId: id,
changes: { loanName: existing.loanName, installments: installments.rows.length },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/loans");
return { ok: true };
}
export async function payInstallmentAction(formData: FormData): Promise<LoanActionState> {
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.loanInstallments,
id,
)) as unknown as LoanInstallment;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
if (existing.paid) {
return { ok: false, error: "Bu taksit zaten ödenmiş." };
}
const loan = (await tablesDB.getRow(
DATABASE_ID,
TABLES.bankLoans,
existing.loanId,
)) as unknown as BankLoan;
if (!canAccessRow(loan, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
const loanScope = loan.scope ?? "company";
// Create finance entry: expense, linked. Inherit loan's scope so personal
// installments don't leak into company finance.
const fe = await tablesDB.createRow(
DATABASE_ID,
TABLES.financeEntries,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
type: "expense",
amount: existing.amount,
date: new Date().toISOString(),
description: `${loan.bankName}${loan.loanName} #${existing.installmentNo} taksit ödemesi`,
bankAccountId: loan.bankAccountId,
scope: loanScope,
},
scopedRowPermissions(ctx.tenantId, ctx.user.id, loanScope),
);
await tablesDB.updateRow(DATABASE_ID, TABLES.loanInstallments, id, {
paid: true,
paidAt: new Date().toISOString(),
financeEntryId: fe.$id,
});
// If this was the last unpaid one, mark loan closed
const remaining = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.loanInstallments,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("loanId", existing.loanId),
Query.equal("paid", false),
Query.limit(1),
],
});
if (remaining.rows.length === 0) {
await tablesDB.updateRow(DATABASE_ID, TABLES.bankLoans, existing.loanId, {
status: "closed",
});
}
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "loan_installment",
entityId: id,
changes: { paid: true, financeEntryId: fe.$id, amount: existing.amount },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/loans");
revalidatePath("/finance");
return { ok: true };
}
export async function unpayInstallmentAction(
formData: FormData,
): Promise<LoanActionState> {
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.loanInstallments,
id,
)) as unknown as LoanInstallment;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
if (!existing.paid) return { ok: true };
// Verify the parent loan is also accessible (handles personal scope).
const parent = (await tablesDB.getRow(
DATABASE_ID,
TABLES.bankLoans,
existing.loanId,
)) as unknown as BankLoan;
if (!canAccessRow(parent, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
if (existing.financeEntryId) {
try {
await tablesDB.deleteRow(
DATABASE_ID,
TABLES.financeEntries,
existing.financeEntryId,
);
} catch {
/* ignore: maybe already gone */
}
}
await tablesDB.updateRow(DATABASE_ID, TABLES.loanInstallments, id, {
paid: false,
paidAt: null,
financeEntryId: null,
});
// If loan was closed, reopen it
await tablesDB.updateRow(DATABASE_ID, TABLES.bankLoans, existing.loanId, {
status: "active",
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "loan_installment",
entityId: id,
changes: { paid: false },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/loans");
revalidatePath("/finance");
return { ok: true };
}
-91
View File
@@ -1,91 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { canAccessRow } from "./scope-permissions";
import { createAdminClient } from "./server";
import {
DATABASE_ID,
TABLES,
type BankLoan,
type LoanInstallment,
} from "./schema";
export async function listLoans(
tenantId: string,
currentUserId?: string,
): Promise<BankLoan[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.bankLoans,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("$createdAt"),
Query.limit(200),
],
});
const rows = result.rows as unknown as BankLoan[];
if (!currentUserId) return rows;
return rows.filter((r) => canAccessRow(r, currentUserId));
} catch {
return [];
}
}
export async function listInstallmentsForLoan(
tenantId: string,
loanId: string,
): Promise<LoanInstallment[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.loanInstallments,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("loanId", loanId),
Query.orderAsc("installmentNo"),
Query.limit(500),
],
});
return result.rows as unknown as LoanInstallment[];
} catch {
return [];
}
}
/**
* Pulls all installments and filters to those whose parent loan is visible to the user.
*/
export async function listAllInstallments(
tenantId: string,
currentUserId?: string,
): Promise<LoanInstallment[]> {
try {
const { tablesDB } = createAdminClient();
const [allInst, allLoans] = await Promise.all([
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.loanInstallments,
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
}),
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.bankLoans,
queries: [Query.equal("tenantId", tenantId), Query.limit(500)],
}),
]);
const visibleLoanIds = new Set(
(allLoans.rows as unknown as BankLoan[])
.filter((l) => !currentUserId || canAccessRow(l, currentUserId))
.map((l) => l.$id),
);
return (allInst.rows as unknown as LoanInstallment[]).filter((i) =>
visibleLoanIds.has(i.loanId),
);
} catch {
return [];
}
}
-8
View File
@@ -1,8 +0,0 @@
export type LoanActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
loanId?: string;
};
export const initialLoanState: LoanActionState = { ok: false };
-123
View File
@@ -1,123 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES, type TenantPlan } from "./schema";
import type { TenantContext } from "./tenant-guard";
export type PlanResource = "customers" | "financeEntries" | "software" | "members";
export const PLAN_LIMIT_EXCEEDED = "PLAN_LIMIT_EXCEEDED";
const INF = Number.POSITIVE_INFINITY;
export const PLAN_LIMITS: Record<TenantPlan, Record<PlanResource, number>> = {
free: {
customers: 50,
financeEntries: 100,
software: 5,
members: 1,
},
pro: {
customers: INF,
financeEntries: INF,
software: INF,
members: INF,
},
};
export const RESOURCE_LABELS: Record<PlanResource, string> = {
customers: "müşteri",
financeEntries: "finans kaydı",
software: "yazılım",
members: "ekip üyesi",
};
export function getEffectivePlan(ctx: TenantContext): TenantPlan {
const plan = ctx.settings?.plan ?? "free";
if (plan === "pro") {
const expires = ctx.settings?.planExpiresAt;
if (expires && new Date(expires).getTime() < Date.now()) {
return "free";
}
}
return plan;
}
async function countResource(
tenantId: string,
resource: PlanResource,
): Promise<number> {
const { tablesDB, teams } = createAdminClient();
if (resource === "members") {
const result = await teams.listMemberships(tenantId);
return result.total;
}
const tableMap: Record<Exclude<PlanResource, "members">, string> = {
customers: TABLES.customers,
financeEntries: TABLES.financeEntries,
software: TABLES.software,
};
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: tableMap[resource],
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
});
return result.total;
}
export type PlanUsage = {
plan: TenantPlan;
usage: Record<PlanResource, { used: number; limit: number; reached: boolean }>;
};
export async function getPlanUsage(ctx: TenantContext): Promise<PlanUsage> {
const plan = getEffectivePlan(ctx);
const limits = PLAN_LIMITS[plan];
const resources: PlanResource[] = ["customers", "financeEntries", "software", "members"];
const counts = await Promise.all(resources.map((r) => countResource(ctx.tenantId, r)));
const usage = {} as PlanUsage["usage"];
resources.forEach((r, i) => {
const used = counts[i];
const limit = limits[r];
usage[r] = { used, limit, reached: used >= limit };
});
return { plan, usage };
}
export class PlanLimitError extends Error {
code = PLAN_LIMIT_EXCEEDED;
constructor(public resource: PlanResource, public limit: number) {
super(`Plan limit reached for ${resource} (${limit})`);
}
}
export async function requirePlanCapacity(
ctx: TenantContext,
resource: PlanResource,
): Promise<void> {
const plan = getEffectivePlan(ctx);
const limit = PLAN_LIMITS[plan][resource];
if (limit === INF) return;
const used = await countResource(ctx.tenantId, resource);
if (used >= limit) {
throw new PlanLimitError(resource, limit);
}
}
export function isPlanLimitError(e: unknown): e is PlanLimitError {
return e instanceof PlanLimitError;
}
export function planLimitMessage(resource: PlanResource, limit: number): string {
const label = RESOURCE_LABELS[resource];
return `Ücretsiz planda en fazla ${limit} ${label} ekleyebilirsiniz. Pro'ya geçerek sınırı kaldırın.`;
}
-155
View File
@@ -1,155 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { ID, Permission, Query, Role } from "node-appwrite";
import { logAudit } from "./audit";
import { DATABASE_ID, TABLES, type CardBrand, type SavedCard } from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
const VALID_BRANDS: CardBrand[] = ["visa", "mastercard", "amex", "troy", "unknown"];
function teamCardPermissions(tenantId: string) {
return [
Permission.read(Role.team(tenantId, "owner")),
Permission.read(Role.team(tenantId, "admin")),
Permission.update(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "owner")),
];
}
async function clearOtherDefaults(tenantId: string, exceptId?: string): Promise<void> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.savedCards,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("isDefault", true),
Query.limit(20),
],
});
for (const row of result.rows) {
if (exceptId && row.$id === exceptId) continue;
await tablesDB.updateRow(DATABASE_ID, TABLES.savedCards, row.$id, {
isDefault: false,
});
}
}
type SaveCardInput = {
brand: string;
last4: string;
expiryMonth: number;
expiryYear: number;
holderName: string;
makeDefault: boolean;
};
export async function persistCardFromMockCheckout(input: SaveCardInput): Promise<void> {
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const brand = (VALID_BRANDS as string[]).includes(input.brand)
? (input.brand as CardBrand)
: "unknown";
const last4 = input.last4.replace(/\D/g, "").slice(-4).padStart(4, "0");
if (last4.length !== 4) throw new Error("Geçersiz kart son 4 hanesi.");
if (input.expiryMonth < 1 || input.expiryMonth > 12) throw new Error("Geçersiz ay.");
if (input.expiryYear < 2026 || input.expiryYear > 2099) throw new Error("Geçersiz yıl.");
if (input.makeDefault) {
await clearOtherDefaults(ctx.tenantId);
}
const { tablesDB } = createAdminClient();
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.savedCards,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
brand,
last4,
expiryMonth: input.expiryMonth,
expiryYear: input.expiryYear,
holderName: input.holderName.trim().slice(0, 128) || undefined,
provider: "mock",
isDefault: input.makeDefault,
},
teamCardPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "saved_card",
entityId: row.$id,
changes: { brand, last4, isDefault: input.makeDefault },
});
revalidatePath("/settings/billing");
}
export async function setDefaultCardAction(formData: FormData): Promise<void> {
const id = String(formData.get("id") ?? "");
if (!id) throw new Error("ID eksik.");
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.savedCards,
id,
)) as unknown as SavedCard;
if (existing.tenantId !== ctx.tenantId) throw new Error("Erişim engellendi.");
await clearOtherDefaults(ctx.tenantId, id);
await tablesDB.updateRow(DATABASE_ID, TABLES.savedCards, id, { isDefault: true });
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "saved_card",
entityId: id,
changes: { isDefault: true },
});
revalidatePath("/settings/billing");
}
export async function removeCardAction(formData: FormData): Promise<void> {
const id = String(formData.get("id") ?? "");
if (!id) throw new Error("ID eksik.");
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.savedCards,
id,
)) as unknown as SavedCard;
if (existing.tenantId !== ctx.tenantId) throw new Error("Erişim engellendi.");
await tablesDB.deleteRow(DATABASE_ID, TABLES.savedCards, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "saved_card",
entityId: id,
changes: { last4: existing.last4 },
});
revalidatePath("/settings/billing");
}
-35
View File
@@ -1,35 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type SavedCard } from "./schema";
import { createAdminClient } from "./server";
export async function listSavedCards(tenantId: string): Promise<SavedCard[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.savedCards,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("isDefault"),
Query.orderDesc("$createdAt"),
Query.limit(20),
],
});
return result.rows as unknown as SavedCard[];
}
export async function getDefaultCard(tenantId: string): Promise<SavedCard | null> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.savedCards,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("isDefault", true),
Query.limit(1),
],
});
return (result.rows[0] as unknown as SavedCard) ?? null;
}
+193 -326
View File
@@ -1,31 +1,20 @@
export const DATABASE_ID = "isletmem";
export const DATABASE_ID = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID ?? "kovakemlak-db";
export const BUCKETS = {
propertyImages: "property-images",
tenantLogos: "tenant-logos",
} as const;
export const TABLES = {
tenantSettings: "tenant_settings",
properties: "properties",
customers: "customers",
services: "services",
software: "software",
customerSoftware: "customer_software",
calendarEvents: "calendar_events",
tasks: "tasks",
financeEntries: "finance_entries",
invoices: "invoices",
invoiceItems: "invoice_items",
auditLogs: "audit_logs",
customerSearches: "customer_searches",
propertyMatches: "property_matches",
presentations: "presentations",
investors: "investors",
activities: "activities",
tenantSettings: "tenant_settings",
inviteLinks: "invite_links",
bankAccounts: "bank_accounts",
bankLoans: "bank_loans",
loanInstallments: "loan_installments",
creditCards: "credit_cards",
creditCardStatements: "credit_card_statements",
subscriptionPayments: "subscription_payments",
savedCards: "saved_cards",
leads: "leads",
leadActivities: "lead_activities",
} as const;
export type TableId = (typeof TABLES)[keyof typeof TABLES];
@@ -43,318 +32,196 @@ export type SystemRow = {
type Row = SystemRow;
export type TenantRole = "owner" | "admin" | "member";
export type TenantPlan = "free" | "pro";
export interface TenantSettings extends Row {
tenantId: string;
companyName: string;
companyTaxId?: string;
companyAddress?: string;
companyEmail?: string;
companyPhone?: string;
logo?: string;
defaultVatRate?: number;
invoicePrefix?: string;
invoiceCounter?: number;
plan?: TenantPlan;
planStartedAt?: string;
planExpiresAt?: string;
lastPaymentId?: string;
}
export type CustomerStatus = "active" | "passive";
export interface Customer extends Row {
tenantId: string;
createdBy: string;
name: string;
email?: string;
phone?: string;
taxId?: string;
address?: string;
notes?: string;
status?: CustomerStatus;
}
export type BillingPeriod = "monthly" | "yearly" | "onetime";
export interface Service extends Row {
tenantId: string;
createdBy: string;
customerId: string;
name: string;
description?: string;
unitPrice: number;
currency?: string;
recurring?: boolean;
billingPeriod?: BillingPeriod;
assigneeIds?: string[];
}
export interface Software extends Row {
tenantId: string;
createdBy: string;
name: string;
version?: string;
description?: string;
defaultFee?: number;
}
export interface CustomerSoftware extends Row {
tenantId: string;
createdBy: string;
customerId: string;
softwareId: string;
startDate?: string;
endDate?: string;
fee?: number;
billingPeriod?: BillingPeriod;
notes?: string;
}
export interface CalendarEvent extends Row {
tenantId: string;
createdBy: string;
title: string;
description?: string;
start: string;
end: string;
allDay?: boolean;
customerId?: string;
leadId?: string;
color?: string;
}
export type LeadStatus = "cold" | "warm" | "hot" | "converted" | "lost";
export type LeadSource = "website" | "social" | "referral" | "cold_call" | "event" | "other";
export type LeadActivityType = "note" | "call" | "meeting" | "email" | "status_change";
export interface Lead extends Row {
tenantId: string;
createdBy: string;
name: string;
contactName?: string;
email?: string;
phone?: string;
source?: LeadSource;
status?: LeadStatus;
estimatedValue?: number;
currency?: string;
notes?: string;
assigneeId?: string;
lastContactAt?: string;
nextFollowUpAt?: string;
calendarEventId?: string;
customerId?: string;
}
export interface LeadActivity extends Row {
tenantId: string;
createdBy: string;
leadId: string;
type: LeadActivityType;
content: string;
calendarEventId?: string;
occurredAt?: string;
}
export type TaskStatus = "backlog" | "todo" | "in_progress" | "done";
export type TaskPriority = "low" | "medium" | "high" | "urgent";
export interface Task extends Row {
tenantId: string;
createdBy: string;
title: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
dueDate?: string;
assigneeId?: string;
customerId?: string;
order?: number;
}
export type FinanceType = "income" | "expense" | "debt" | "receivable";
export type PaymentMethod = "cash" | "transfer" | "card" | "check" | "other";
export interface FinanceEntry extends Row {
tenantId: string;
createdBy: string;
type: FinanceType;
amount: number;
date: string;
description?: string;
customerId?: string;
invoiceId?: string;
paymentMethod?: PaymentMethod;
bankAccountId?: string;
scope?: "company" | "personal";
}
export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue" | "cancelled";
export interface Invoice extends Row {
tenantId: string;
createdBy: string;
number: string;
customerId: string;
issueDate: string;
dueDate: string;
status?: InvoiceStatus;
subtotal?: number;
vatTotal?: number;
total?: number;
notes?: string;
}
export interface InvoiceItem extends Row {
tenantId: string;
createdBy: string;
invoiceId: string;
description: string;
quantity: number;
unitPrice: number;
vatRate?: number;
lineTotal: number;
}
export type AuditAction = "create" | "update" | "delete";
export interface AuditLog extends Row {
tenantId: string;
userId: string;
action: AuditAction;
entityType: string;
entityId: string;
changes?: string;
ipAddress?: string;
userAgent?: string;
}
export type FinanceScope = "company" | "personal";
export interface BankAccount extends Row {
tenantId: string;
createdBy: string;
bankName: string;
accountName: string;
iban?: string;
openingBalance?: number;
notes?: string;
archived?: boolean;
scope?: FinanceScope;
}
export type LoanType = "consumer" | "vehicle" | "housing" | "commercial" | "kmh" | "other";
export type LoanStatus = "active" | "closed" | "defaulted";
export interface BankLoan extends Row {
tenantId: string;
createdBy: string;
bankAccountId?: string;
bankName: string;
loanName: string;
loanType?: LoanType;
principal: number;
interestRate: number; // monthly nominal %
termMonths: number;
monthlyPayment?: number;
startDate: string;
paymentDay?: number;
status?: LoanStatus;
notes?: string;
scope?: FinanceScope;
}
export interface LoanInstallment extends Row {
tenantId: string;
loanId: string;
installmentNo: number;
dueDate: string;
amount: number;
principalPart?: number;
interestPart?: number;
paid?: boolean;
paidAt?: string;
financeEntryId?: string;
}
export interface CreditCard extends Row {
tenantId: string;
createdBy: string;
bankName: string;
cardName: string;
last4?: string;
creditLimit?: number;
statementDay?: number;
dueDay?: number;
interestRate?: number;
bankAccountId?: string;
archived?: boolean;
notes?: string;
scope?: FinanceScope;
}
export type StatementStatus = "pending" | "partial" | "paid" | "overdue";
export interface CreditCardStatement extends Row {
tenantId: string;
createdBy: string;
cardId: string;
period: string; // YYYY-MM
statementDate: string;
dueDate: string;
totalDebt: number;
minimumPayment?: number;
paidAmount?: number;
status?: StatementStatus;
financeEntryId?: string;
notes?: string;
}
export type SubscriptionStatus = "pending" | "success" | "failed" | "refunded";
export type SubscriptionProvider = "mock" | "shopier" | "polar";
export type CardBrand = "visa" | "mastercard" | "amex" | "troy" | "unknown";
export interface SavedCard extends Row {
tenantId: string;
createdBy: string;
brand?: CardBrand;
last4: string;
expiryMonth: number;
expiryYear: number;
holderName?: string;
providerToken?: string;
provider?: SubscriptionProvider;
isDefault?: boolean;
}
export interface SubscriptionPayment extends Row {
tenantId: string;
createdBy: string;
orderId: string;
plan: TenantPlan;
amount: number;
currency?: string;
status?: SubscriptionStatus;
provider?: SubscriptionProvider;
providerPayload?: string;
processedAt?: string;
}
export type InviteRole = "admin" | "member";
export type InviteStatus = "pending" | "accepted" | "cancelled" | "expired";
export interface InviteLink extends Row {
export interface InviteLink extends SystemRow {
tenantId: string;
code: string;
email: string;
role?: InviteRole;
status?: InviteStatus;
role: InviteRole;
status: "pending" | "accepted" | "expired" | "cancelled";
invitedBy: string;
expiresAt?: string;
expiresAt: string;
acceptedAt?: string;
acceptedBy?: string;
}
export type PropertyType = "daire" | "villa" | "arsa" | "dukkan" | "ofis" | "depo";
export type ListingType = "satilik" | "kiralik";
export type PropertyStatus = "aktif" | "pasif" | "satildi" | "kiralandit";
export type CustomerType = "alici" | "kiraci" | "yatirimci";
export type ActivityType = "gorusme" | "teklif" | "ziyaret" | "arama" | "not";
export interface Property extends Row {
tenantId: string;
title: string;
description?: string;
propertyType: PropertyType;
listingType: ListingType;
status: PropertyStatus;
price: number;
currency?: string;
roomCount?: string;
grossM2?: number;
netM2?: number;
floor?: number;
totalFloors?: number;
buildingAge?: number;
city: string;
district?: string;
neighborhood?: string;
address?: string;
mapLat?: number;
mapLng?: number;
featuresJson?: string;
imageIds?: string;
createdBy: string;
assigneeId?: string;
}
export interface Customer extends Row {
tenantId: string;
name: string;
email?: string;
phone?: string;
type: CustomerType;
notes?: string;
createdBy: string;
}
export interface CustomerSearch extends Row {
tenantId: string;
customerId: string;
listingType?: ListingType;
propertyTypes?: string;
roomCounts?: string;
minPrice?: number;
maxPrice?: number;
minM2?: number;
maxM2?: number;
cities?: string;
districts?: string;
featuresJson?: string;
isActive?: boolean;
notes?: string;
createdBy: string;
}
export interface PropertyMatch extends Row {
tenantId: string;
propertyId: string;
customerId: string;
searchId: string;
notified?: boolean;
viewedAt?: string;
createdBy: string;
}
export interface Presentation extends Row {
tenantId: string;
title: string;
customerId?: string;
propertyIds: string;
shareToken: string;
expiresAt?: string;
viewCount?: number;
notes?: string;
createdBy: string;
}
export interface Investor extends Row {
tenantId: string;
userId?: string;
name: string;
email: string;
phone?: string;
budget?: number;
currency?: string;
notes?: string;
createdBy: string;
}
export interface Activity extends Row {
tenantId: string;
customerId?: string;
propertyId?: string;
type: ActivityType;
title: string;
description?: string;
dueDate?: string;
completedAt?: string;
createdBy: string;
}
export interface TenantSettings extends Row {
tenantId: string;
officeName?: string;
logo?: string;
defaultCurrency?: string;
phone?: string;
email?: string;
address?: string;
createdBy: string;
}
export type PropertyFeature =
| "balkon"
| "otopark"
| "asansor"
| "havuz"
| "spor_salonu"
| "guvenlik"
| "site_ici"
| "esyali"
| "merkezi_isitma"
| "dogalgaz"
| "deprem_yalitim"
| "bahce";
export const ROOM_COUNT_OPTIONS = [
"Stüdyo",
"1+0",
"1+1",
"2+1",
"3+1",
"4+1",
"4+2",
"5+1",
"5+2",
"6+",
] as const;
export const PROPERTY_TYPE_LABELS: Record<PropertyType, string> = {
daire: "Daire",
villa: "Villa",
arsa: "Arsa",
dukkan: "Dükkan",
ofis: "Ofis",
depo: "Depo",
};
export const LISTING_TYPE_LABELS: Record<ListingType, string> = {
satilik: "Satılık",
kiralik: "Kiralık",
};
export const PROPERTY_STATUS_LABELS: Record<PropertyStatus, string> = {
aktif: "Aktif",
pasif: "Pasif",
satildi: "Satıldı",
kiralandit: "Kiralandı",
};
export const CUSTOMER_TYPE_LABELS: Record<CustomerType, string> = {
alici: "Alıcı",
kiraci: "Kiracı",
yatirimci: "Yatırımcı",
};
export const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
gorusme: "Görüşme",
teklif: "Teklif",
ziyaret: "Ziyaret",
arama: "Arama",
not: "Not",
};
-53
View File
@@ -1,53 +0,0 @@
import "server-only";
import { Permission, Role } from "node-appwrite";
import type { FinanceScope } from "./schema";
/**
* Returns row-level permissions for finance-related entities.
*
* - `company`: visible to the whole tenant team. Owner/admin can delete.
* - `personal`: visible/editable/deletable only by the creator.
*
* The Appwrite "users" table-level perms still gate writes; these row-level
* perms gate reads and per-row mutations.
*/
export function scopedRowPermissions(
tenantId: string,
createdBy: string,
scope: FinanceScope,
): string[] {
if (scope === "personal") {
return [
Permission.read(Role.user(createdBy)),
Permission.update(Role.user(createdBy)),
Permission.delete(Role.user(createdBy)),
];
}
return [
Permission.read(Role.team(tenantId)),
Permission.update(Role.team(tenantId)),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
];
}
export function normalizeScope(v: unknown): FinanceScope {
return v === "personal" ? "personal" : "company";
}
/**
* Returns true if the current user is allowed to read the row.
* - company-scope: any team member
* - personal-scope: only the creator
*/
export function canAccessRow(
row: { scope?: FinanceScope; createdBy?: string },
currentUserId: string,
): boolean {
if ((row.scope ?? "company") === "personal") {
return row.createdBy === currentUserId;
}
return true;
}
-241
View File
@@ -1,241 +0,0 @@
"use server";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import {
DATABASE_ID,
TABLES,
type CalendarEvent,
type Customer,
type FinanceEntry,
type Invoice,
type Service,
type Software,
type Task,
} from "./schema";
import { requireTenant } from "./tenant-guard";
export type SearchHit = {
id: string;
title: string;
subtitle?: string;
url: string;
group: SearchGroup;
};
export type SearchGroup =
| "customers"
| "invoices"
| "tasks"
| "services"
| "software"
| "events"
| "finance";
export type SearchResults = Record<SearchGroup, SearchHit[]>;
const PAGE_LIMIT = 200; // per entity, plenty for search-time scan
const MAX_HITS_PER_GROUP = 8;
const TYPE_LABEL: Record<string, string> = {
income: "Gelir",
expense: "Gider",
debt: "Borç",
receivable: "Alacak",
};
function tryMatch(haystack: string | undefined | null, needle: string): boolean {
if (!haystack) return false;
return haystack.toLocaleLowerCase("tr-TR").includes(needle);
}
export async function globalSearchAction(rawQuery: string): Promise<SearchResults> {
const empty: SearchResults = {
customers: [],
invoices: [],
tasks: [],
services: [],
software: [],
events: [],
finance: [],
};
const q = rawQuery.trim().toLocaleLowerCase("tr-TR");
if (!q || q.length < 2) return empty;
let ctx;
try {
ctx = await requireTenant();
} catch {
return empty;
}
const { tablesDB } = createAdminClient();
const tenantQ = [Query.equal("tenantId", ctx.tenantId), Query.limit(PAGE_LIMIT)];
const [customers, invoices, tasks, services, software, events, finance] =
await Promise.all([
tablesDB
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: tenantQ })
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.invoices, queries: tenantQ })
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.tasks, queries: tenantQ })
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.services, queries: tenantQ })
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.software, queries: tenantQ })
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.calendarEvents, queries: tenantQ })
.catch(() => ({ rows: [] as unknown[] })),
tablesDB
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.financeEntries, queries: tenantQ })
.catch(() => ({ rows: [] as unknown[] })),
]);
const customerMap = new Map<string, string>();
for (const c of customers.rows as unknown as Customer[]) {
customerMap.set(c.$id, c.name);
}
// ---------- Customers ----------
const customerHits: SearchHit[] = [];
for (const c of customers.rows as unknown as Customer[]) {
if (
tryMatch(c.name, q) ||
tryMatch(c.email, q) ||
tryMatch(c.phone, q) ||
tryMatch(c.taxId, q)
) {
customerHits.push({
id: c.$id,
title: c.name,
subtitle: c.email || c.phone || c.taxId || undefined,
url: "/customers",
group: "customers",
});
if (customerHits.length >= MAX_HITS_PER_GROUP) break;
}
}
// ---------- Invoices ----------
const invoiceHits: SearchHit[] = [];
for (const inv of invoices.rows as unknown as Invoice[]) {
if (
tryMatch(inv.number, q) ||
tryMatch(inv.notes, q) ||
tryMatch(customerMap.get(inv.customerId), q)
) {
invoiceHits.push({
id: inv.$id,
title: inv.number,
subtitle: customerMap.get(inv.customerId) ?? undefined,
url: `/invoices/${inv.$id}`,
group: "invoices",
});
if (invoiceHits.length >= MAX_HITS_PER_GROUP) break;
}
}
// ---------- Tasks ----------
const taskHits: SearchHit[] = [];
for (const t of tasks.rows as unknown as Task[]) {
if (tryMatch(t.title, q) || tryMatch(t.description, q)) {
taskHits.push({
id: t.$id,
title: t.title,
subtitle: t.description ? t.description.slice(0, 80) : undefined,
url: "/tasks",
group: "tasks",
});
if (taskHits.length >= MAX_HITS_PER_GROUP) break;
}
}
// ---------- Services ----------
const serviceHits: SearchHit[] = [];
for (const s of services.rows as unknown as Service[]) {
if (tryMatch(s.name, q) || tryMatch(s.description, q)) {
serviceHits.push({
id: s.$id,
title: s.name,
subtitle: customerMap.get(s.customerId) ?? undefined,
url: "/services",
group: "services",
});
if (serviceHits.length >= MAX_HITS_PER_GROUP) break;
}
}
// ---------- Software ----------
const softwareHits: SearchHit[] = [];
for (const s of software.rows as unknown as Software[]) {
if (tryMatch(s.name, q) || tryMatch(s.version, q) || tryMatch(s.description, q)) {
softwareHits.push({
id: s.$id,
title: s.name,
subtitle: s.version ? `v${s.version}` : undefined,
url: "/software",
group: "software",
});
if (softwareHits.length >= MAX_HITS_PER_GROUP) break;
}
}
// ---------- Calendar events ----------
const eventHits: SearchHit[] = [];
for (const e of events.rows as unknown as CalendarEvent[]) {
if (tryMatch(e.title, q) || tryMatch(e.description, q)) {
eventHits.push({
id: e.$id,
title: e.title,
subtitle: new Date(e.start).toLocaleDateString("tr-TR", {
day: "2-digit",
month: "short",
year: "numeric",
}),
url: "/calendar",
group: "events",
});
if (eventHits.length >= MAX_HITS_PER_GROUP) break;
}
}
// ---------- Finance entries ----------
const financeHits: SearchHit[] = [];
for (const e of finance.rows as unknown as FinanceEntry[]) {
const amountStr = e.amount.toString();
if (
tryMatch(e.description, q) ||
tryMatch(customerMap.get(e.customerId ?? ""), q) ||
tryMatch(amountStr, q)
) {
financeHits.push({
id: e.$id,
title: `${TYPE_LABEL[e.type]}${e.amount.toLocaleString("tr-TR", { minimumFractionDigits: 2 })} ₺`,
subtitle:
(customerMap.get(e.customerId ?? "") || e.description || "").slice(0, 80) ||
undefined,
url: "/finance",
group: "finance",
});
if (financeHits.length >= MAX_HITS_PER_GROUP) break;
}
}
return {
customers: customerHits,
invoices: invoiceHits,
tasks: taskHits,
services: serviceHits,
software: softwareHits,
events: eventHits,
finance: financeHits,
};
}
-187
View File
@@ -1,187 +0,0 @@
"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 Service } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { ServiceActionState } from "./service-types";
import { serviceSchema } from "@/lib/validation/services";
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 {
customerId: String(formData.get("customerId") ?? ""),
name: String(formData.get("name") ?? "").trim(),
description: String(formData.get("description") ?? "").trim(),
unitPrice: String(formData.get("unitPrice") ?? "0"),
currency: (formData.get("currency") as "TRY" | "USD" | "EUR" | null) ?? "TRY",
recurring: formData.get("recurring") ?? false,
billingPeriod: (formData.get("billingPeriod") as "monthly" | "yearly" | "onetime" | null) ??
"onetime",
assigneeIds: formData.getAll("assigneeIds").map(String).filter(Boolean),
};
}
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 createServiceAction(
_prev: ServiceActionState,
formData: FormData,
): Promise<ServiceActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = serviceSchema.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.services,
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: "service",
entityId: row.$id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/services");
return { ok: true };
}
export async function updateServiceAction(
_prev: ServiceActionState,
formData: FormData,
): Promise<ServiceActionState> {
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 = serviceSchema.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.services,
id,
)) as unknown as Service;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.services, id, parsed.data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "service",
entityId: id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/services");
return { ok: true };
}
export async function deleteServiceAction(formData: FormData): Promise<ServiceActionState> {
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.services,
id,
)) as unknown as Service;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.services, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "service",
entityId: id,
changes: { name: existing.name },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/services");
return { ok: true };
}
-46
View File
@@ -1,46 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES, type Service } from "./schema";
export async function listServices(tenantId: string): Promise<Service[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.services,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("$createdAt"),
Query.limit(500),
],
});
return result.rows as unknown as Service[];
} catch {
return [];
}
}
export async function listServicesByCustomer(
tenantId: string,
customerId: string,
): Promise<Service[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.services,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("customerId", customerId),
Query.orderDesc("$createdAt"),
Query.limit(500),
],
});
return result.rows as unknown as Service[];
} catch {
return [];
}
}
-7
View File
@@ -1,7 +0,0 @@
export type ServiceActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialServiceState: ServiceActionState = { ok: false };
-388
View File
@@ -1,388 +0,0 @@
"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 {
isPlanLimitError,
planLimitMessage,
requirePlanCapacity,
} from "./plan-limits";
import {
DATABASE_ID,
TABLES,
type CustomerSoftware,
type Software,
} from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { SoftwareActionState } from "./software-types";
import { customerSoftwareSchema, softwareSchema } from "@/lib/validation/software";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) {
return e.message || "Beklenmeyen bir hata oluştu.";
}
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")),
];
}
// -------------------- Software (catalog) --------------------
function pickSoftwareFields(formData: FormData) {
return {
name: String(formData.get("name") ?? "").trim(),
version: String(formData.get("version") ?? "").trim(),
description: String(formData.get("description") ?? "").trim(),
defaultFee: String(formData.get("defaultFee") ?? ""),
};
}
export async function createSoftwareAction(
_prev: SoftwareActionState,
formData: FormData,
): Promise<SoftwareActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = softwareSchema.safeParse(pickSoftwareFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
await requirePlanCapacity(ctx, "software");
} 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 row = await tablesDB.createRow(
DATABASE_ID,
TABLES.software,
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: "software",
entityId: row.$id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
export async function updateSoftwareAction(
_prev: SoftwareActionState,
formData: FormData,
): Promise<SoftwareActionState> {
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 = softwareSchema.safeParse(pickSoftwareFields(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.software,
id,
)) as unknown as Software;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.software, id, parsed.data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "software",
entityId: id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
export async function deleteSoftwareAction(formData: FormData): Promise<SoftwareActionState> {
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.software,
id,
)) as unknown as Software;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
// Detach from all customer_software rows first
const assignments = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.customerSoftware,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("softwareId", id),
Query.limit(500),
],
});
for (const row of assignments.rows) {
await tablesDB.deleteRow(DATABASE_ID, TABLES.customerSoftware, row.$id);
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.software, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "software",
entityId: id,
changes: { name: existing.name, detachedAssignments: assignments.rows.length },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
// -------------------- customer_software (assignments) --------------------
function pickAssignmentFields(formData: FormData) {
return {
customerId: String(formData.get("customerId") ?? ""),
softwareId: String(formData.get("softwareId") ?? ""),
startDate: String(formData.get("startDate") ?? ""),
endDate: String(formData.get("endDate") ?? ""),
fee: String(formData.get("fee") ?? ""),
billingPeriod: (formData.get("billingPeriod") as "monthly" | "yearly" | "onetime" | null) ??
"monthly",
notes: String(formData.get("notes") ?? "").trim(),
};
}
function toIsoDate(v?: string): string | undefined {
if (!v) return undefined;
// input type=date sends YYYY-MM-DD; Appwrite expects ISO with timezone
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
return v;
}
export async function createAssignmentAction(
_prev: SoftwareActionState,
formData: FormData,
): Promise<SoftwareActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = customerSoftwareSchema.safeParse(pickAssignmentFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const data = {
...parsed.data,
startDate: toIsoDate(parsed.data.startDate),
endDate: toIsoDate(parsed.data.endDate),
};
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.customerSoftware,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
...data,
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "customer_software",
entityId: row.$id,
changes: data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
export async function updateAssignmentAction(
_prev: SoftwareActionState,
formData: FormData,
): Promise<SoftwareActionState> {
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 = customerSoftwareSchema.safeParse(pickAssignmentFields(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.customerSoftware,
id,
)) as unknown as CustomerSoftware;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const data = {
...parsed.data,
startDate: toIsoDate(parsed.data.startDate),
endDate: toIsoDate(parsed.data.endDate),
};
await tablesDB.updateRow(DATABASE_ID, TABLES.customerSoftware, id, data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "customer_software",
entityId: id,
changes: data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
export async function deleteAssignmentAction(formData: FormData): Promise<SoftwareActionState> {
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.customerSoftware,
id,
)) as unknown as CustomerSoftware;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.customerSoftware, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "customer_software",
entityId: id,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
-47
View File
@@ -1,47 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import {
DATABASE_ID,
TABLES,
type CustomerSoftware,
type Software,
} from "./schema";
export async function listSoftware(tenantId: string): Promise<Software[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.software,
queries: [
Query.equal("tenantId", tenantId),
Query.orderAsc("name"),
Query.limit(500),
],
});
return result.rows as unknown as Software[];
} catch {
return [];
}
}
export async function listAssignments(tenantId: string): Promise<CustomerSoftware[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.customerSoftware,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("$createdAt"),
Query.limit(1000),
],
});
return result.rows as unknown as CustomerSoftware[];
} catch {
return [];
}
}
-8
View File
@@ -1,8 +0,0 @@
export type SoftwareActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
code?: "PLAN_LIMIT_EXCEEDED";
};
export const initialSoftwareState: SoftwareActionState = { ok: false };
-323
View File
@@ -1,323 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { ID, Permission, Query, Role } from "node-appwrite";
import { logAudit } from "./audit";
import { persistCardFromMockCheckout } from "./saved-card-actions";
import { getDefaultCard } from "./saved-card-queries";
import { DATABASE_ID, TABLES, type SubscriptionPayment, type TenantPlan } from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
import { PLAN_CATALOG } from "./subscription-types";
import { getShopierPlanUrl, isShopierEnabled } from "../payments/shopier";
import { createPolarCheckout, isPolarEnabled } from "../payments/polar";
const PRO_VALIDITY_DAYS = 30;
function teamRowPermissions(tenantId: string) {
return [
Permission.read(Role.team(tenantId, "owner")),
Permission.read(Role.team(tenantId, "admin")),
Permission.update(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "owner")),
];
}
function generateOrderId(): string {
const t = Date.now().toString(36);
const r = Math.random().toString(36).slice(2, 10);
return `ord_${t}_${r}`;
}
export async function startMockCheckoutAction(formData: FormData): Promise<void> {
const plan = String(formData.get("plan") ?? "") as TenantPlan;
if (plan !== "pro") throw new Error("Geçersiz plan.");
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const catalog = PLAN_CATALOG[plan];
const orderId = generateOrderId();
const { tablesDB } = createAdminClient();
await tablesDB.createRow(
DATABASE_ID,
TABLES.subscriptionPayments,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
orderId,
plan,
amount: catalog.price,
currency: catalog.currency,
status: "pending",
provider: "mock",
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "subscription_payment",
entityId: orderId,
changes: { plan, amount: catalog.price, provider: "mock" },
});
redirect(`/settings/billing/checkout/${orderId}`);
}
async function findPendingPaymentByOrderId(
tenantId: string,
orderId: string,
): Promise<SubscriptionPayment | null> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.subscriptionPayments,
queries: [Query.equal("orderId", orderId), Query.equal("tenantId", tenantId), Query.limit(1)],
});
return (result.rows[0] as unknown as SubscriptionPayment) ?? null;
}
export async function confirmMockPaymentAction(formData: FormData): Promise<void> {
const orderId = String(formData.get("orderId") ?? "");
if (!orderId) throw new Error("orderId eksik.");
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const payment = await findPendingPaymentByOrderId(ctx.tenantId, orderId);
if (!payment) throw new Error("Ödeme bulunamadı.");
if (payment.status === "success") {
redirect(`/settings/billing?upgraded=1`);
}
if (payment.provider !== "mock") {
throw new Error("Bu ödeme mock olarak onaylanamaz.");
}
const saveCard = String(formData.get("saveCard") ?? "") === "true";
const useSavedCardId = String(formData.get("savedCardId") ?? "");
const { tablesDB } = createAdminClient();
const now = new Date();
const expires = new Date(now.getTime() + PRO_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
status: "success",
processedAt: now.toISOString(),
providerPayload: JSON.stringify({
mock: true,
confirmedBy: ctx.user.id,
usedSavedCardId: useSavedCardId || undefined,
}),
});
if (ctx.settings) {
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
plan: payment.plan,
planStartedAt: now.toISOString(),
planExpiresAt: expires.toISOString(),
lastPaymentId: payment.$id,
});
}
if (saveCard && !useSavedCardId) {
const last4 = String(formData.get("cardLast4") ?? "").replace(/\D/g, "").slice(-4);
const month = parseInt(String(formData.get("cardExpiryMonth") ?? "0"), 10);
const year = parseInt(String(formData.get("cardExpiryYear") ?? "0"), 10);
const brand = String(formData.get("cardBrand") ?? "unknown");
const holder = String(formData.get("cardHolder") ?? "").trim();
if (last4.length === 4 && month > 0 && year >= 2026) {
const existingDefault = await getDefaultCard(ctx.tenantId);
try {
await persistCardFromMockCheckout({
brand,
last4,
expiryMonth: month,
expiryYear: year,
holderName: holder,
makeDefault: !existingDefault,
});
} catch {
// best-effort — payment already succeeded, don't fail upgrade if card save errors
}
}
}
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "subscription_payment",
entityId: payment.$id,
changes: { status: "success", plan: payment.plan, expires: expires.toISOString() },
});
revalidatePath("/settings/billing");
redirect(`/settings/billing?upgraded=1`);
}
export async function cancelMockPaymentAction(formData: FormData): Promise<void> {
const orderId = String(formData.get("orderId") ?? "");
if (!orderId) throw new Error("orderId eksik.");
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const payment = await findPendingPaymentByOrderId(ctx.tenantId, orderId);
if (!payment) {
redirect(`/settings/billing`);
}
if (payment && payment.status === "pending") {
const { tablesDB } = createAdminClient();
await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
status: "failed",
processedAt: new Date().toISOString(),
providerPayload: JSON.stringify({ mock: true, cancelledBy: ctx.user.id }),
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "subscription_payment",
entityId: payment.$id,
changes: { status: "failed" },
});
}
revalidatePath("/settings/billing");
redirect(`/settings/billing?cancelled=1`);
}
export async function downgradeToFreeAction(): Promise<void> {
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
if (!ctx.settings) throw new Error("Ayar yok.");
const { tablesDB } = createAdminClient();
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
plan: "free",
planExpiresAt: null,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "tenant_settings",
entityId: ctx.settings.$id,
changes: { plan: "free" },
});
revalidatePath("/settings/billing");
redirect(`/settings/billing?downgraded=1`);
}
export async function startShopierCheckoutAction(formData: FormData): Promise<void> {
const plan = String(formData.get("plan") ?? "") as TenantPlan;
if (plan !== "pro") throw new Error("Geçersiz plan.");
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const storeUrl = getShopierPlanUrl(plan);
if (!storeUrl) throw new Error("Shopier mağaza URL'i ayarlanmamış.");
const catalog = PLAN_CATALOG[plan];
const orderId = generateOrderId();
const { tablesDB } = createAdminClient();
await tablesDB.createRow(
DATABASE_ID,
TABLES.subscriptionPayments,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
orderId,
plan,
amount: catalog.price,
currency: catalog.currency,
status: "pending",
provider: "shopier",
// Webhook'ta tenant eşleştirmek için alıcı emailini sakla
providerPayload: JSON.stringify({ userEmail: ctx.user.email }),
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "subscription_payment",
entityId: orderId,
changes: { plan, amount: catalog.price, provider: "shopier" },
});
// Kullanıcıyı doğrudan Shopier mağaza ürün sayfasına yönlendir
redirect(storeUrl);
}
export async function startPolarCheckoutAction(formData: FormData): Promise<void> {
const plan = String(formData.get("plan") ?? "") as TenantPlan;
if (plan !== "pro") throw new Error("Geçersiz plan.");
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const catalog = PLAN_CATALOG[plan];
const orderId = generateOrderId();
const appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3000";
const { tablesDB } = createAdminClient();
await tablesDB.createRow(
DATABASE_ID,
TABLES.subscriptionPayments,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
orderId,
plan,
amount: catalog.price,
currency: catalog.currency,
status: "pending",
provider: "polar",
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "subscription_payment",
entityId: orderId,
changes: { plan, amount: catalog.price, provider: "polar" },
});
const checkout = await createPolarCheckout({
orderId,
tenantId: ctx.tenantId,
userEmail: ctx.user.email,
successUrl: `${appUrl}/settings/billing?upgraded=1`,
});
redirect(checkout.url);
}
// Unified entry point — PAYMENT_PROVIDER env ile yönlendirir.
export async function startCheckoutAction(formData: FormData): Promise<void> {
if (isPolarEnabled()) return startPolarCheckoutAction(formData);
if (isShopierEnabled()) return startShopierCheckoutAction(formData);
return startMockCheckoutAction(formData);
}
-40
View File
@@ -1,40 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type SubscriptionPayment } from "./schema";
import { createAdminClient } from "./server";
export async function getPaymentByOrderId(
tenantId: string,
orderId: string,
): Promise<SubscriptionPayment | null> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.subscriptionPayments,
queries: [
Query.equal("orderId", orderId),
Query.equal("tenantId", tenantId),
Query.limit(1),
],
});
return (result.rows[0] as unknown as SubscriptionPayment) ?? null;
}
export async function listPaymentsForTenant(
tenantId: string,
limit = 20,
): Promise<SubscriptionPayment[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.subscriptionPayments,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("$createdAt"),
Query.limit(limit),
],
});
return result.rows as unknown as SubscriptionPayment[];
}
-40
View File
@@ -1,40 +0,0 @@
import type { TenantPlan } from "./schema";
export type PlanCatalogEntry = {
id: TenantPlan;
name: string;
price: number;
currency: string;
description: string;
features: string[];
};
export const PLAN_CATALOG: Record<TenantPlan, PlanCatalogEntry> = {
free: {
id: "free",
name: "Ücretsiz",
price: 0,
currency: "TRY",
description: "Tek kullanıcı, denemek için.",
features: [
"50 müşteri",
"100 finans kaydı",
"5 yazılım",
"Tek kullanıcı",
],
},
pro: {
id: "pro",
name: "Pro",
price: 299,
currency: "TRY",
description: "Sınırsız büyüyen ekipler için.",
features: [
"Sınırsız müşteri",
"Sınırsız finans kaydı",
"Sınırsız yazılım",
"Sınırsız ekip üyesi",
"Audit log + öncelikli destek",
],
},
};
-244
View File
@@ -1,244 +0,0 @@
"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 Task, type TaskStatus } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { TaskActionState } from "./task-types";
import { taskSchema } from "@/lib/validation/tasks";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) {
return e.message || "Beklenmeyen bir hata oluştu.";
}
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 pickFormFields(formData: FormData) {
return {
title: String(formData.get("title") ?? "").trim(),
description: String(formData.get("description") ?? "").trim(),
status: (formData.get("status") as TaskStatus | null) ?? "todo",
priority: (formData.get("priority") as "low" | "medium" | "high" | "urgent" | null) ??
"medium",
dueDate: String(formData.get("dueDate") ?? ""),
assigneeId: String(formData.get("assigneeId") ?? ""),
customerId: String(formData.get("customerId") ?? ""),
};
}
function toIsoDate(v?: string): string | undefined {
if (!v) return undefined;
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
return v;
}
export async function createTaskAction(
_prev: TaskActionState,
formData: FormData,
): Promise<TaskActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = taskSchema.safeParse(pickFormFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const data = { ...parsed.data, dueDate: toIsoDate(parsed.data.dueDate) };
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.tasks,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
order: Date.now(),
...data,
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "task",
entityId: row.$id,
changes: data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/tasks");
return { ok: true };
}
export async function updateTaskAction(
_prev: TaskActionState,
formData: FormData,
): Promise<TaskActionState> {
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 = taskSchema.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.tasks,
id,
)) as unknown as Task;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const data = { ...parsed.data, dueDate: toIsoDate(parsed.data.dueDate) };
await tablesDB.updateRow(DATABASE_ID, TABLES.tasks, id, data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "task",
entityId: id,
changes: data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/tasks");
return { ok: true };
}
export async function deleteTaskAction(formData: FormData): Promise<TaskActionState> {
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.tasks,
id,
)) as unknown as Task;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.tasks, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "task",
entityId: id,
changes: { title: existing.title },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/tasks");
return { ok: true };
}
/**
* Used by Kanban drag-drop. Updates status and order in one server call.
*/
export async function moveTaskAction(
id: string,
status: TaskStatus,
order: number,
): Promise<TaskActionState> {
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.tasks,
id,
)) as unknown as Task;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.tasks, id, { status, order });
if (existing.status !== status) {
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "task",
entityId: id,
changes: { from: existing.status, to: status },
});
}
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/tasks");
return { ok: true };
}
-24
View File
@@ -1,24 +0,0 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES, type Task } from "./schema";
export async function listTasks(tenantId: string): Promise<Task[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tasks,
queries: [
Query.equal("tenantId", tenantId),
Query.orderAsc("order"),
Query.limit(1000),
],
});
return result.rows as unknown as Task[];
} catch {
return [];
}
}
-7
View File
@@ -1,7 +0,0 @@
export type TaskActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialTaskState: TaskActionState = { ok: false };
-18
View File
@@ -5,11 +5,6 @@ import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { logAudit } from "./audit";
import {
isPlanLimitError,
planLimitMessage,
requirePlanCapacity,
} from "./plan-limits";
import { DATABASE_ID, TABLES, type InviteLink, type InviteRole } from "./schema";
import { createAdminClient, createSessionClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
@@ -71,19 +66,6 @@ export async function inviteMemberAction(
return { ok: false, error: "Kendinizi davet edemezsiniz." };
}
try {
await requirePlanCapacity(ctx, "members");
} catch (e) {
if (isPlanLimitError(e)) {
return {
ok: false,
error: planLimitMessage(e.resource, e.limit),
code: "PLAN_LIMIT_EXCEEDED",
};
}
throw e;
}
const admin = createAdminClient();
// 1. Kullanıcı zaten Appwrite'ta var mı?