diff --git a/src/app/(dashboard)/finance/banks/components/bank-form-sheet.tsx b/src/app/(dashboard)/finance/banks/components/bank-form-sheet.tsx index 46d633e..ab6c8dd 100644 --- a/src/app/(dashboard)/finance/banks/components/bank-form-sheet.tsx +++ b/src/app/(dashboard)/finance/banks/components/bank-form-sheet.tsx @@ -21,6 +21,7 @@ import { updateBankAccountAction, } from "@/lib/appwrite/bank-account-actions"; import { initialBankAccountState } from "@/lib/appwrite/bank-account-types"; +import { ScopeToggle } from "@/components/finance/scope-toggle"; import type { BankAccountRow } from "./types"; @@ -60,6 +61,8 @@ export function BankFormSheet({ open, onOpenChange, account }: Props) { {isEdit && account && }
+ +
diff --git a/src/app/(dashboard)/finance/banks/components/banks-client.tsx b/src/app/(dashboard)/finance/banks/components/banks-client.tsx index 925701f..963b0a4 100644 --- a/src/app/(dashboard)/finance/banks/components/banks-client.tsx +++ b/src/app/(dashboard)/finance/banks/components/banks-client.tsx @@ -36,6 +36,7 @@ import { deleteBankAccountAction, } from "@/lib/appwrite/bank-account-actions"; import { formatTRY } from "@/lib/format"; +import { ScopeBadge } from "@/components/finance/scope-toggle"; import { cn } from "@/lib/utils"; import { BankFormSheet } from "./bank-form-sheet"; @@ -229,6 +230,7 @@ function AccountCard({ Arşivli )} +

{account.accountName}

{account.iban && ( diff --git a/src/app/(dashboard)/finance/banks/components/types.ts b/src/app/(dashboard)/finance/banks/components/types.ts index 4a7e052..0495794 100644 --- a/src/app/(dashboard)/finance/banks/components/types.ts +++ b/src/app/(dashboard)/finance/banks/components/types.ts @@ -7,4 +7,5 @@ export type BankAccountRow = { notes: string; archived: boolean; balance: number; + scope: "company" | "personal"; }; diff --git a/src/app/(dashboard)/finance/banks/page.tsx b/src/app/(dashboard)/finance/banks/page.tsx index 6981860..d1c4243 100644 --- a/src/app/(dashboard)/finance/banks/page.tsx +++ b/src/app/(dashboard)/finance/banks/page.tsx @@ -21,8 +21,8 @@ export default async function BanksPage() { } const [accounts, balances] = await Promise.all([ - listBankAccounts(ctx.tenantId), - getBankAccountBalances(ctx.tenantId), + listBankAccounts(ctx.tenantId, ctx.user.id), + getBankAccountBalances(ctx.tenantId, ctx.user.id), ]); return ( @@ -45,6 +45,7 @@ export default async function BanksPage() { notes: a.notes ?? "", archived: Boolean(a.archived), balance: balances.get(a.$id) ?? a.openingBalance ?? 0, + scope: (a.scope ?? "company") as "company" | "personal", }))} />
diff --git a/src/app/(dashboard)/finance/cards/components/card-form-sheet.tsx b/src/app/(dashboard)/finance/cards/components/card-form-sheet.tsx index 7b52839..973d4ef 100644 --- a/src/app/(dashboard)/finance/cards/components/card-form-sheet.tsx +++ b/src/app/(dashboard)/finance/cards/components/card-form-sheet.tsx @@ -28,6 +28,7 @@ import { updateCreditCardAction, } from "@/lib/appwrite/credit-card-actions"; import { initialCreditCardState } from "@/lib/appwrite/credit-card-types"; +import { ScopeToggle } from "@/components/finance/scope-toggle"; import type { BankAccountOption, CreditCardRow } from "./types"; @@ -78,6 +79,8 @@ export function CardFormSheet({ {isEdit && card && }
+ +
diff --git a/src/app/(dashboard)/finance/cards/components/types.ts b/src/app/(dashboard)/finance/cards/components/types.ts index cab5377..596a377 100644 --- a/src/app/(dashboard)/finance/cards/components/types.ts +++ b/src/app/(dashboard)/finance/cards/components/types.ts @@ -11,6 +11,7 @@ export type CreditCardRow = { bankAccountLabel: string; archived: boolean; notes: string; + scope: "company" | "personal"; }; export type StatementRow = { diff --git a/src/app/(dashboard)/finance/cards/page.tsx b/src/app/(dashboard)/finance/cards/page.tsx index 862ac29..8178c85 100644 --- a/src/app/(dashboard)/finance/cards/page.tsx +++ b/src/app/(dashboard)/finance/cards/page.tsx @@ -22,9 +22,9 @@ export default async function CardsPage() { } const [cards, statements, bankAccounts] = await Promise.all([ - listCreditCards(ctx.tenantId), - listStatements(ctx.tenantId), - listBankAccounts(ctx.tenantId), + listCreditCards(ctx.tenantId, ctx.user.id), + listStatements(ctx.tenantId, ctx.user.id), + listBankAccounts(ctx.tenantId, ctx.user.id), ]); const bankMap = new Map( @@ -55,6 +55,7 @@ export default async function CardsPage() { bankAccountLabel: c.bankAccountId ? bankMap.get(c.bankAccountId) ?? "" : "", archived: Boolean(c.archived), notes: c.notes ?? "", + scope: (c.scope ?? "company") as "company" | "personal", }))} statements={statements.map((s) => ({ id: s.$id, diff --git a/src/app/(dashboard)/finance/components/finance-form-sheet.tsx b/src/app/(dashboard)/finance/components/finance-form-sheet.tsx index 5f47295..4545758 100644 --- a/src/app/(dashboard)/finance/components/finance-form-sheet.tsx +++ b/src/app/(dashboard)/finance/components/finance-form-sheet.tsx @@ -28,6 +28,7 @@ import { updateFinanceEntryAction, } from "@/lib/appwrite/finance-actions"; import { initialFinanceState } from "@/lib/appwrite/finance-types"; +import { ScopeToggle } from "@/components/finance/scope-toggle"; import type { BankAccountOption, Customer, FinanceRow, FinanceType } from "./types"; @@ -95,6 +96,10 @@ export function FinanceFormSheet({ {isEdit && entry && }
+ +
diff --git a/src/app/(dashboard)/finance/loans/components/loan-form-sheet.tsx b/src/app/(dashboard)/finance/loans/components/loan-form-sheet.tsx index 6d87335..39be8f8 100644 --- a/src/app/(dashboard)/finance/loans/components/loan-form-sheet.tsx +++ b/src/app/(dashboard)/finance/loans/components/loan-form-sheet.tsx @@ -26,6 +26,7 @@ import { Textarea } from "@/components/ui/textarea"; import { createLoanAction } from "@/lib/appwrite/loan-actions"; import { initialLoanState } from "@/lib/appwrite/loan-types"; import { formatTRY } from "@/lib/format"; +import { ScopeToggle } from "@/components/finance/scope-toggle"; import type { BankAccountOption } from "./types"; @@ -85,6 +86,8 @@ export function LoanFormSheet({ className="flex flex-1 flex-col" >
+ +
diff --git a/src/app/(dashboard)/finance/loans/components/types.ts b/src/app/(dashboard)/finance/loans/components/types.ts index 5785205..cbca62d 100644 --- a/src/app/(dashboard)/finance/loans/components/types.ts +++ b/src/app/(dashboard)/finance/loans/components/types.ts @@ -17,6 +17,7 @@ export type LoanRow = { paidAmount: number; remainingCount: number; nextDue: string | null; + scope: "company" | "personal"; }; export type InstallmentRow = { diff --git a/src/app/(dashboard)/finance/loans/page.tsx b/src/app/(dashboard)/finance/loans/page.tsx index 47c4bca..b509475 100644 --- a/src/app/(dashboard)/finance/loans/page.tsx +++ b/src/app/(dashboard)/finance/loans/page.tsx @@ -19,9 +19,9 @@ export default async function LoansPage() { } const [loans, installments, bankAccounts] = await Promise.all([ - listLoans(ctx.tenantId), - listAllInstallments(ctx.tenantId), - listBankAccounts(ctx.tenantId), + listLoans(ctx.tenantId, ctx.user.id), + listAllInstallments(ctx.tenantId, ctx.user.id), + listBankAccounts(ctx.tenantId, ctx.user.id), ]); const bankMap = new Map( @@ -90,6 +90,7 @@ export default async function LoansPage() { paidAmount: m.paidAmount, remainingCount: m.remainingCount, nextDue: m.nextDue, + scope: (l.scope ?? "company") as "company" | "personal", }; })} installments={installments.map((i) => ({ diff --git a/src/app/(dashboard)/finance/page.tsx b/src/app/(dashboard)/finance/page.tsx index 24e8f5b..172280f 100644 --- a/src/app/(dashboard)/finance/page.tsx +++ b/src/app/(dashboard)/finance/page.tsx @@ -20,9 +20,9 @@ export default async function FinancePage() { } const [entries, customers, bankAccounts] = await Promise.all([ - listFinanceEntries(ctx.tenantId), + listFinanceEntries(ctx.tenantId, ctx.user.id), listCustomers(ctx.tenantId), - listBankAccounts(ctx.tenantId), + listBankAccounts(ctx.tenantId, ctx.user.id), ]); const customerMap = new Map(customers.map((c) => [c.$id, c.name])); diff --git a/src/components/finance/scope-toggle.tsx b/src/components/finance/scope-toggle.tsx new file mode 100644 index 0000000..32d8404 --- /dev/null +++ b/src/components/finance/scope-toggle.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import { Building2, User } from "lucide-react"; + +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +type Scope = "company" | "personal"; + +export function ScopeToggle({ + name = "scope", + defaultValue = "company", + label = "Kapsam", + description, +}: { + name?: string; + defaultValue?: Scope; + label?: string; + description?: string; +}) { + const [value, setValue] = useState(defaultValue); + return ( +
+ + +
+ + +
+ {description &&

{description}

} +
+ ); +} + +export function ScopeBadge({ scope }: { scope?: Scope }) { + if (scope === "personal") { + return ( + + + Bireysel + + ); + } + return null; // Company is default — no badge needed +} diff --git a/src/lib/appwrite/bank-account-actions.ts b/src/lib/appwrite/bank-account-actions.ts index 8ec1cc4..f7e0ea3 100644 --- a/src/lib/appwrite/bank-account-actions.ts +++ b/src/lib/appwrite/bank-account-actions.ts @@ -1,11 +1,12 @@ "use server"; import { revalidatePath } from "next/cache"; -import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite"; +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"; @@ -25,15 +26,6 @@ function flattenErrors(err: z.ZodError): Record { 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 { bankName: String(formData.get("bankName") ?? "").trim(), @@ -41,6 +33,7 @@ function pickFormFields(formData: FormData) { 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", }; } @@ -71,7 +64,7 @@ export async function createBankAccountAction( createdBy: ctx.user.id, ...parsed.data, }, - teamRowPermissions(ctx.tenantId), + scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope), ); await logAudit({ @@ -116,11 +109,17 @@ export async function updateBankAccountAction( TABLES.bankAccounts, id, )) as unknown as BankAccount; - if (existing.tenantId !== ctx.tenantId) { + 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); + await tablesDB.updateRow( + DATABASE_ID, + TABLES.bankAccounts, + id, + parsed.data, + scopedRowPermissions(ctx.tenantId, existing.createdBy, parsed.data.scope), + ); await logAudit({ tenantId: ctx.tenantId, @@ -158,7 +157,7 @@ export async function archiveBankAccountAction( TABLES.bankAccounts, id, )) as unknown as BankAccount; - if (existing.tenantId !== ctx.tenantId) { + if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) { return { ok: false, error: "Erişim engellendi." }; } @@ -203,7 +202,7 @@ export async function deleteBankAccountAction( TABLES.bankAccounts, id, )) as unknown as BankAccount; - if (existing.tenantId !== ctx.tenantId) { + if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) { return { ok: false, error: "Erişim engellendi." }; } diff --git a/src/lib/appwrite/bank-account-queries.ts b/src/lib/appwrite/bank-account-queries.ts index 3c14e35..6175162 100644 --- a/src/lib/appwrite/bank-account-queries.ts +++ b/src/lib/appwrite/bank-account-queries.ts @@ -2,6 +2,7 @@ import "server-only"; import { Query } from "node-appwrite"; +import { canAccessRow } from "./scope-permissions"; import { createAdminClient } from "./server"; import { DATABASE_ID, @@ -10,7 +11,15 @@ import { type FinanceEntry, } from "./schema"; -export async function listBankAccounts(tenantId: string): Promise { +/** + * 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 { try { const { tablesDB } = createAdminClient(); const result = await tablesDB.listRows({ @@ -22,30 +31,28 @@ export async function listBankAccounts(tenantId: string): Promise Query.limit(200), ], }); - return result.rows as unknown as BankAccount[]; + 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 account: openingBalance + Σ(income/receivable) − Σ(expense/debt). + * Computes a current balance for each visible account. */ export async function getBankAccountBalances( tenantId: string, + currentUserId?: string, ): Promise> { const balances = new Map(); try { - const { tablesDB } = createAdminClient(); - const accounts = await tablesDB.listRows({ - databaseId: DATABASE_ID, - tableId: TABLES.bankAccounts, - queries: [Query.equal("tenantId", tenantId), Query.limit(200)], - }); - for (const a of accounts.rows as unknown as BankAccount[]) { - balances.set(a.$id, a.openingBalance ?? 0); - } + 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, @@ -56,12 +63,11 @@ export async function getBankAccountBalances( ], }); for (const e of entries.rows as unknown as FinanceEntry[]) { - if (!e.bankAccountId) continue; + 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); - // debt/receivable don't affect cash balance } } catch { /* ignore */ diff --git a/src/lib/appwrite/credit-card-actions.ts b/src/lib/appwrite/credit-card-actions.ts index 51c2573..66a3ec5 100644 --- a/src/lib/appwrite/credit-card-actions.ts +++ b/src/lib/appwrite/credit-card-actions.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; -import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite"; +import { AppwriteException, ID, Query } from "node-appwrite"; import { z } from "zod"; import { logAudit } from "./audit"; @@ -11,6 +11,7 @@ import { 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"; @@ -30,15 +31,6 @@ function flattenErrors(err: z.ZodError): Record { 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`; @@ -58,6 +50,7 @@ function pickCardFields(formData: FormData) { 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", }; } @@ -88,7 +81,7 @@ export async function createCreditCardAction( createdBy: ctx.user.id, ...parsed.data, }, - teamRowPermissions(ctx.tenantId), + scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope), ); await logAudit({ @@ -133,11 +126,17 @@ export async function updateCreditCardAction( TABLES.creditCards, id, )) as unknown as CreditCard; - if (existing.tenantId !== ctx.tenantId) { + 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); + await tablesDB.updateRow( + DATABASE_ID, + TABLES.creditCards, + id, + parsed.data, + scopedRowPermissions(ctx.tenantId, existing.createdBy, parsed.data.scope), + ); await logAudit({ tenantId: ctx.tenantId, @@ -175,7 +174,7 @@ export async function archiveCreditCardAction( TABLES.creditCards, id, )) as unknown as CreditCard; - if (existing.tenantId !== ctx.tenantId) { + if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) { return { ok: false, error: "Erişim engellendi." }; } const newArchivedState = !existing.archived; @@ -219,7 +218,7 @@ export async function deleteCreditCardAction( TABLES.creditCards, id, )) as unknown as CreditCard; - if (existing.tenantId !== ctx.tenantId) { + if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) { return { ok: false, error: "Erişim engellendi." }; } @@ -310,11 +309,13 @@ export async function createStatementAction( TABLES.creditCards, parsed.data.cardId, )) as unknown as CreditCard; - if (card.tenantId !== ctx.tenantId) { + 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, @@ -333,7 +334,7 @@ export async function createStatementAction( status, notes: parsed.data.notes, }, - teamRowPermissions(ctx.tenantId), + scopedRowPermissions(ctx.tenantId, ctx.user.id, cardScope), ); await logAudit({ @@ -393,6 +394,10 @@ export async function payStatementAction(formData: FormData): Promise { +export async function listCreditCards( + tenantId: string, + currentUserId?: string, +): Promise { try { const { tablesDB } = createAdminClient(); const result = await tablesDB.listRows({ @@ -22,27 +26,48 @@ export async function listCreditCards(tenantId: string): Promise { Query.limit(200), ], }); - return result.rows as unknown as CreditCard[]; + 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 { try { const { tablesDB } = createAdminClient(); - const result = await tablesDB.listRows({ - databaseId: DATABASE_ID, - tableId: TABLES.creditCardStatements, - queries: [ - Query.equal("tenantId", tenantId), - Query.orderDesc("statementDate"), - Query.limit(500), - ], - }); - return result.rows as unknown as CreditCardStatement[]; + 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 []; } diff --git a/src/lib/appwrite/dashboard-queries.ts b/src/lib/appwrite/dashboard-queries.ts index 72d1ae8..529e492 100644 --- a/src/lib/appwrite/dashboard-queries.ts +++ b/src/lib/appwrite/dashboard-queries.ts @@ -97,7 +97,11 @@ export async function getDashboardData( const customerList = customers.rows as unknown as Customer[]; const invoiceList = invoices.rows as unknown as Invoice[]; - const entryList = financeEntries.rows as unknown as FinanceEntry[]; + // 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])); diff --git a/src/lib/appwrite/finance-actions.ts b/src/lib/appwrite/finance-actions.ts index dfe4656..c2f66be 100644 --- a/src/lib/appwrite/finance-actions.ts +++ b/src/lib/appwrite/finance-actions.ts @@ -1,11 +1,12 @@ "use server"; import { revalidatePath } from "next/cache"; -import { AppwriteException, ID, Permission, Role } from "node-appwrite"; +import { AppwriteException, ID } from "node-appwrite"; import { z } from "zod"; import { logAudit } from "./audit"; 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"; @@ -25,15 +26,6 @@ function flattenErrors(err: z.ZodError): Record { 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 { type: formData.get("type") as "income" | "expense" | "debt" | "receivable", @@ -50,6 +42,7 @@ function pickFormFields(formData: FormData) { | "other" | null, bankAccountId: String(formData.get("bankAccountId") ?? ""), + scope: (formData.get("scope") as "company" | "personal" | null) ?? "company", }; } @@ -87,7 +80,7 @@ export async function createFinanceEntryAction( createdBy: ctx.user.id, ...data, }, - teamRowPermissions(ctx.tenantId), + scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope), ); await logAudit({ @@ -132,12 +125,18 @@ export async function updateFinanceEntryAction( TABLES.financeEntries, id, )) as unknown as FinanceEntry; - if (existing.tenantId !== ctx.tenantId) { + 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); + await tablesDB.updateRow( + DATABASE_ID, + TABLES.financeEntries, + id, + data, + scopedRowPermissions(ctx.tenantId, existing.createdBy, parsed.data.scope), + ); await logAudit({ tenantId: ctx.tenantId, @@ -175,7 +174,7 @@ export async function deleteFinanceEntryAction( TABLES.financeEntries, id, )) as unknown as FinanceEntry; - if (existing.tenantId !== ctx.tenantId) { + if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) { return { ok: false, error: "Erişim engellendi." }; } diff --git a/src/lib/appwrite/finance-queries.ts b/src/lib/appwrite/finance-queries.ts index 604bdce..7b88595 100644 --- a/src/lib/appwrite/finance-queries.ts +++ b/src/lib/appwrite/finance-queries.ts @@ -2,10 +2,14 @@ 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): Promise { +export async function listFinanceEntries( + tenantId: string, + currentUserId?: string, +): Promise { try { const { tablesDB } = createAdminClient(); const result = await tablesDB.listRows({ @@ -17,7 +21,9 @@ export async function listFinanceEntries(tenantId: string): Promise canAccessRow(r, currentUserId)); } catch { return []; } diff --git a/src/lib/appwrite/finance-report-queries.ts b/src/lib/appwrite/finance-report-queries.ts index 155bdc7..2ff3a86 100644 --- a/src/lib/appwrite/finance-report-queries.ts +++ b/src/lib/appwrite/finance-report-queries.ts @@ -183,14 +183,25 @@ export async function getFinancialReport( .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 = (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[]; - const bankList = bankAccounts.rows as unknown as BankAccount[]; - const loanList = loans.rows as unknown as BankLoan[]; - const installmentList = installments.rows as unknown as LoanInstallment[]; - const cardList = cards.rows as unknown as CreditCard[]; - const statementList = statements.rows as unknown as CreditCardStatement[]; + 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( diff --git a/src/lib/appwrite/invoice-actions.ts b/src/lib/appwrite/invoice-actions.ts index bdffc35..7e71c11 100644 --- a/src/lib/appwrite/invoice-actions.ts +++ b/src/lib/appwrite/invoice-actions.ts @@ -111,6 +111,7 @@ async function syncPaymentEntry( description: `Fatura ${invoice.number} tahsilatı`, customerId: invoice.customerId, invoiceId: invoice.$id, + scope: "company", }, [ Permission.read(Role.team(tenantId)), diff --git a/src/lib/appwrite/loan-actions.ts b/src/lib/appwrite/loan-actions.ts index b070cb7..729f2b4 100644 --- a/src/lib/appwrite/loan-actions.ts +++ b/src/lib/appwrite/loan-actions.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; -import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite"; +import { AppwriteException, ID, Query } from "node-appwrite"; import { z } from "zod"; import { logAudit } from "./audit"; @@ -11,6 +11,7 @@ import { 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"; @@ -30,15 +31,6 @@ function flattenErrors(err: z.ZodError): Record { 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 pickLoanFields(formData: FormData) { return { bankAccountId: String(formData.get("bankAccountId") ?? ""), @@ -58,6 +50,7 @@ function pickLoanFields(formData: FormData) { 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", }; } @@ -138,6 +131,9 @@ export async function createLoanAction( 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, @@ -158,8 +154,9 @@ export async function createLoanAction( paymentDay: parsed.data.paymentDay, status: "active", notes: parsed.data.notes, + scope: parsed.data.scope, }, - teamRowPermissions(ctx.tenantId), + rowPerms, ); loanId = loan.$id; @@ -181,7 +178,7 @@ export async function createLoanAction( interestPart: slice.interestPart, paid: false, }, - teamRowPermissions(ctx.tenantId), + rowPerms, ); } @@ -239,7 +236,7 @@ export async function deleteLoanAction(formData: FormData): Promise { +export async function listLoans( + tenantId: string, + currentUserId?: string, +): Promise { try { const { tablesDB } = createAdminClient(); const result = await tablesDB.listRows({ @@ -22,7 +26,9 @@ export async function listLoans(tenantId: string): Promise { Query.limit(200), ], }); - return result.rows as unknown as BankLoan[]; + const rows = result.rows as unknown as BankLoan[]; + if (!currentUserId) return rows; + return rows.filter((r) => canAccessRow(r, currentUserId)); } catch { return []; } @@ -50,15 +56,35 @@ export async function listInstallmentsForLoan( } } -export async function listAllInstallments(tenantId: string): Promise { +/** + * Pulls all installments and filters to those whose parent loan is visible to the user. + */ +export async function listAllInstallments( + tenantId: string, + currentUserId?: string, +): Promise { try { const { tablesDB } = createAdminClient(); - const result = await tablesDB.listRows({ - databaseId: DATABASE_ID, - tableId: TABLES.loanInstallments, - queries: [Query.equal("tenantId", tenantId), Query.limit(2000)], - }); - return result.rows as unknown as LoanInstallment[]; + 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 []; } diff --git a/src/lib/appwrite/schema.ts b/src/lib/appwrite/schema.ts index dc06631..f111525 100644 --- a/src/lib/appwrite/schema.ts +++ b/src/lib/appwrite/schema.ts @@ -139,6 +139,7 @@ export interface FinanceEntry extends Row { invoiceId?: string; paymentMethod?: PaymentMethod; bankAccountId?: string; + scope?: "company" | "personal"; } export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue" | "cancelled"; @@ -181,6 +182,8 @@ export interface AuditLog extends Row { userAgent?: string; } +export type FinanceScope = "company" | "personal"; + export interface BankAccount extends Row { tenantId: string; createdBy: string; @@ -190,6 +193,7 @@ export interface BankAccount extends Row { openingBalance?: number; notes?: string; archived?: boolean; + scope?: FinanceScope; } export type LoanType = "consumer" | "vehicle" | "housing" | "commercial" | "kmh" | "other"; @@ -210,6 +214,7 @@ export interface BankLoan extends Row { paymentDay?: number; status?: LoanStatus; notes?: string; + scope?: FinanceScope; } export interface LoanInstallment extends Row { @@ -238,6 +243,7 @@ export interface CreditCard extends Row { bankAccountId?: string; archived?: boolean; notes?: string; + scope?: FinanceScope; } export type StatementStatus = "pending" | "partial" | "paid" | "overdue"; diff --git a/src/lib/appwrite/scope-permissions.ts b/src/lib/appwrite/scope-permissions.ts new file mode 100644 index 0000000..286bfcb --- /dev/null +++ b/src/lib/appwrite/scope-permissions.ts @@ -0,0 +1,53 @@ +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; +} diff --git a/src/lib/validation/bank-accounts.ts b/src/lib/validation/bank-accounts.ts index b79f3c1..cd2f663 100644 --- a/src/lib/validation/bank-accounts.ts +++ b/src/lib/validation/bank-accounts.ts @@ -23,6 +23,7 @@ export const bankAccountSchema = z.object({ .max(500) .optional() .transform((v) => (v ? v : undefined)), + scope: z.enum(["company", "personal"]).optional().default("company"), }); export type BankAccountInput = z.infer; diff --git a/src/lib/validation/bank-loans.ts b/src/lib/validation/bank-loans.ts index c953036..5ed7bd0 100644 --- a/src/lib/validation/bank-loans.ts +++ b/src/lib/validation/bank-loans.ts @@ -30,6 +30,7 @@ export const bankLoanSchema = z.object({ return Number.isFinite(n) ? Math.min(28, Math.max(1, n)) : 1; }), notes: z.string().trim().max(1000).optional().transform((v) => (v ? v : undefined)), + scope: z.enum(["company", "personal"]).optional().default("company"), }); export type BankLoanInput = z.infer; diff --git a/src/lib/validation/credit-cards.ts b/src/lib/validation/credit-cards.ts index b85ca0e..0981a37 100644 --- a/src/lib/validation/credit-cards.ts +++ b/src/lib/validation/credit-cards.ts @@ -43,6 +43,7 @@ export const creditCardSchema = z.object({ }), bankAccountId: z.string().optional().transform((v) => (v ? v : undefined)), notes: z.string().trim().max(500).optional().transform((v) => (v ? v : undefined)), + scope: z.enum(["company", "personal"]).optional().default("company"), }); export type CreditCardInput = z.infer; diff --git a/src/lib/validation/finance.ts b/src/lib/validation/finance.ts index d9cf054..ca9d0f4 100644 --- a/src/lib/validation/finance.ts +++ b/src/lib/validation/finance.ts @@ -15,6 +15,7 @@ export const financeEntrySchema = z.object({ .optional() .transform((v) => v || undefined), bankAccountId: z.string().optional().transform((v) => (v ? v : undefined)), + scope: z.enum(["company", "personal"]).optional().default("company"), }); export type FinanceEntryInput = z.infer;