From de91f2505a272d5a6af4a086fef0644fb8a61b61 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 02:35:01 +0300 Subject: [PATCH] =?UTF-8?q?feat(households):=20hane=20rolleri,=20servis,?= =?UTF-8?q?=20DAL=20hane=20kapsam=C4=B1=20ve=20denetim=20kayd=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/features/households/roles.test.ts | 54 ++++ src/features/households/roles.ts | 43 +++ src/features/households/service.ts | 399 ++++++++++++++++++++++++++ src/lib/active-household.ts | 24 ++ src/lib/audit.test.ts | 13 + src/lib/audit.ts | 41 +++ src/lib/dal.ts | 68 +++++ src/lib/object-id.ts | 8 + src/models/audit-log.ts | 44 +++ src/shared/schemas/household.ts | 37 +++ 10 files changed, 731 insertions(+) create mode 100644 src/features/households/roles.test.ts create mode 100644 src/features/households/roles.ts create mode 100644 src/features/households/service.ts create mode 100644 src/lib/active-household.ts create mode 100644 src/lib/audit.test.ts create mode 100644 src/lib/audit.ts create mode 100644 src/lib/object-id.ts create mode 100644 src/models/audit-log.ts create mode 100644 src/shared/schemas/household.ts diff --git a/src/features/households/roles.test.ts b/src/features/households/roles.test.ts new file mode 100644 index 0000000..ea77e4e --- /dev/null +++ b/src/features/households/roles.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { checkMemberChange, hasHouseholdRole, pickActiveHousehold } from "./roles"; + +describe("hasHouseholdRole", () => { + it("viewer < member < owner sırasını uygular", () => { + expect(hasHouseholdRole("owner", "member")).toBe(true); + expect(hasHouseholdRole("member", "member")).toBe(true); + expect(hasHouseholdRole("viewer", "member")).toBe(false); + expect(hasHouseholdRole("member", "owner")).toBe(false); + expect(hasHouseholdRole("viewer", "viewer")).toBe(true); + }); +}); + +describe("checkMemberChange", () => { + it("son sahip düşürülemez veya çıkarılamaz", () => { + const ctx = { targetRole: "owner" as const, ownerCount: 1 }; + expect(checkMemberChange({ type: "remove" }, ctx)).toBe("lastOwner"); + expect(checkMemberChange({ type: "setRole", role: "member" }, ctx)).toBe("lastOwner"); + expect(checkMemberChange({ type: "setRole", role: "owner" }, ctx)).toBeNull(); + }); + + it("birden çok sahip varken biri düşürülebilir; sahip olmayanlar serbest", () => { + expect( + checkMemberChange({ type: "remove" }, { targetRole: "owner", ownerCount: 2 }), + ).toBeNull(); + expect( + checkMemberChange({ type: "remove" }, { targetRole: "viewer", ownerCount: 1 }), + ).toBeNull(); + expect( + checkMemberChange( + { type: "setRole", role: "owner" }, + { targetRole: "member", ownerCount: 1 }, + ), + ).toBeNull(); + }); +}); + +describe("pickActiveHousehold", () => { + const list = [ + { id: "a", role: "viewer" as const }, + { id: "b", role: "owner" as const }, + { id: "c", role: "owner" as const }, + ]; + it("üyesi olunan tercih edilen haneyi seçer", () => { + expect(pickActiveHousehold(list, "a")?.id).toBe("a"); + }); + it("tercih yoksa veya uydurmaysa en yüksek rolün olduğu ilk haneye düşer", () => { + expect(pickActiveHousehold(list, "x")?.id).toBe("b"); + expect(pickActiveHousehold(list, undefined)?.id).toBe("b"); + }); + it("hiç üyelik yoksa null", () => { + expect(pickActiveHousehold([], "a")).toBeNull(); + }); +}); diff --git a/src/features/households/roles.ts b/src/features/households/roles.ts new file mode 100644 index 0000000..44bf221 --- /dev/null +++ b/src/features/households/roles.ts @@ -0,0 +1,43 @@ +import type { HouseholdRole } from "./types"; + +/** Hane içi yetki sırası: viewer < member < owner. */ +const RANK: Record = { viewer: 0, member: 1, owner: 2 }; + +/** `role`, en az `min` yetkisine sahip mi? */ +export function hasHouseholdRole(role: HouseholdRole, min: HouseholdRole): boolean { + return RANK[role] >= RANK[min]; +} + +export type MemberChange = { type: "setRole"; role: HouseholdRole } | { type: "remove" }; + +export type MemberChangeViolation = "lastOwner"; + +/** + * Üyelik değişikliği kuralı: hanenin son sahibi (owner) düşürülemez veya çıkarılamaz. + * Böylece her hanenin ayarlarını ve üyelerini yönetebilecek en az bir kişi kalır. + */ +export function checkMemberChange( + change: MemberChange, + ctx: { targetRole: HouseholdRole; ownerCount: number }, +): MemberChangeViolation | null { + const removesOwner = + ctx.targetRole === "owner" && + (change.type === "remove" || (change.type === "setRole" && change.role !== "owner")); + return removesOwner && ctx.ownerCount <= 1 ? "lastOwner" : null; +} + +/** + * Aktif hane seçimi: tercih edilen (cookie) hane kullanıcının üyelikleri arasındaysa o; + * değilse en yüksek rolün olduğu hane (eşitlikte listedeki ilk). Uydurma veya artık üye + * olunmayan bir hane kimliği asla seçilmez. + */ +export function pickActiveHousehold( + memberships: T[], + preferredId: string | undefined, +): T | null { + const preferred = memberships.find((m) => m.id === preferredId); + if (preferred) return preferred; + let best: T | null = null; + for (const m of memberships) if (!best || RANK[m.role] > RANK[best.role]) best = m; + return best; +} diff --git a/src/features/households/service.ts b/src/features/households/service.ts new file mode 100644 index 0000000..4b2a3ec --- /dev/null +++ b/src/features/households/service.ts @@ -0,0 +1,399 @@ +import "server-only"; +import { Types } from "mongoose"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { diffFields, writeAudit } from "@/lib/audit"; +import { getDb } from "@/lib/db/mongo"; +import { connectMongoose } from "@/lib/db/mongoose"; +import { toObjectId } from "@/lib/object-id"; +import { HouseholdModel } from "@/models/household"; +import { HouseholdMemberModel } from "@/models/household-member"; +import type { HouseholdSettings } from "@/shared/schemas/household"; +import { compareTr } from "@/shared/search"; +import { checkMemberChange, hasHouseholdRole, type MemberChange } from "./roles"; +import type { HouseholdRole, HouseholdSummary } from "./types"; + +/** + * Hane servisi — framework'ten bağımsız (web Server Action'ları ve ileride /api/v1 aynı + * fonksiyonları çağırır). Hane kapsamlı her fonksiyon üyeliği KENDİSİ de doğrular: + * çağıran taraf DAL kontrolünü atlasa bile başka hanenin verisine erişilemez. + */ + +const t = tr.households; + +export type Membership = { householdId: Types.ObjectId; role: HouseholdRole; name: string }; + +export type HouseholdMemberView = { + userId: string; + name: string; + username: string; + role: HouseholdRole; + banned: boolean; +}; + +export type HouseholdDetail = { + id: string; + name: string; + settings: HouseholdSettings; + members: HouseholdMemberView[]; +}; + +/** Kullanıcının üye olduğu (arşivlenmemiş) haneler, ada göre sıralı. */ +export async function listMemberships(userId: string): Promise { + const uid = toObjectId(userId); + if (!uid) return []; + await connectMongoose(); + const memberships = await HouseholdMemberModel.find({ userId: uid }).lean(); + if (!memberships.length) return []; + const households = await HouseholdModel.find({ + _id: { $in: memberships.map((m) => m.householdId) }, + archivedAt: null, + }) + .select({ name: 1 }) + .lean(); + const roleOf = new Map(memberships.map((m) => [String(m.householdId), m.role])); + return households + .map((h) => ({ id: String(h._id), name: h.name, role: roleOf.get(String(h._id))! })) + .sort((a, b) => compareTr(a.name, b.name)); +} + +/** + * VERİ İZOLASYONUNUN ÇEKİRDEĞİ: kullanıcı bu hanenin en az `minRole` yetkili üyesi mi? + * Değilse null. Geçersiz/uydurma kimlikler de null döner. + */ +export async function resolveMembership( + userId: string, + householdId: string, + minRole: HouseholdRole = "viewer", +): Promise { + const uid = toObjectId(userId); + const hid = toObjectId(householdId); + if (!uid || !hid) return null; + await connectMongoose(); + const member = await HouseholdMemberModel.findOne({ userId: uid, householdId: hid }).lean(); + if (!member || !hasHouseholdRole(member.role, minRole)) return null; + const household = await HouseholdModel.findOne({ _id: hid, archivedAt: null }) + .select({ name: 1 }) + .lean(); + if (!household) return null; + return { householdId: hid, role: member.role, name: household.name }; +} + +async function memberViews(householdId: Types.ObjectId): Promise { + const members = await HouseholdMemberModel.find({ householdId }).lean(); + const users = await getDb() + .collection("user") + .find( + { _id: { $in: members.map((m) => m.userId) } }, + { projection: { name: 1, username: 1, banned: 1 } }, + ) + .toArray(); + const byId = new Map(users.map((u) => [String(u._id), u])); + return members + .map((m) => { + const u = byId.get(String(m.userId)); + return { + userId: String(m.userId), + name: (u?.name as string | undefined) ?? "—", + username: (u?.username as string | undefined) ?? "", + role: m.role, + banned: Boolean(u?.banned), + }; + }) + .sort((a, b) => compareTr(a.name, b.name)); +} + +function toSettings(h: { + name: string; + settings?: Partial | null; +}): HouseholdSettings { + const s = h.settings ?? {}; + return { + name: h.name, + budgetMonthStartDay: s.budgetMonthStartDay ?? 1, + installmentBudgetMode: s.installmentBudgetMode ?? "per_installment", + dailyPlanMode: s.dailyPlanMode ?? "even", + includePrivateAccountsInTotals: s.includePrivateAccountsInTotals ?? true, + extraStoreSavingThresholdMinor: s.extraStoreSavingThresholdMinor ?? 10_000, + }; +} + +/** Üye için hane ayrıntısı (ayarlar + üyeler). Üye değilse null. */ +export async function getHouseholdDetail( + userId: string, + householdId: string, +): Promise { + const membership = await resolveMembership(userId, householdId, "viewer"); + if (!membership) return null; + const household = await HouseholdModel.findById(membership.householdId).lean(); + if (!household) return null; + return { + id: String(household._id), + name: household.name, + settings: toSettings(household), + members: await memberViews(membership.householdId), + }; +} + +/** Owner: hane adı ve ayarları. */ +export async function updateHouseholdSettings( + actorId: string, + householdId: string, + input: HouseholdSettings, +): Promise { + const membership = await resolveMembership(actorId, householdId, "owner"); + if (!membership) return fail(tr.common.unauthorized); + const before = await HouseholdModel.findById(membership.householdId).lean(); + if (!before) return fail(tr.common.notFound); + + const { name, ...settings } = input; + const actor = new Types.ObjectId(actorId); + await HouseholdModel.updateOne( + { _id: membership.householdId }, + { $set: { name, settings, updatedBy: actor } }, + ); + await writeAudit({ + action: "household.settings.update", + actorId: actor, + householdId: membership.householdId, + targetType: "household", + targetId: String(membership.householdId), + diff: diffFields(toSettings(before), input), + }); + return ok(undefined); +} + +/** Ortak üyelik değişikliği (owner yolu ve admin yolu aynı kuralı uygular). */ +async function applyMemberChange( + actor: Types.ObjectId, + householdId: Types.ObjectId, + targetUserId: string, + change: MemberChange, +): Promise { + const target = toObjectId(targetUserId); + if (!target) return fail(tr.common.notFound); + const member = await HouseholdMemberModel.findOne({ householdId, userId: target }).lean(); + if (!member) return fail(tr.common.notFound); + if (change.type === "setRole" && change.role === member.role) return ok(undefined); + + const ownerCount = await HouseholdMemberModel.countDocuments({ householdId, role: "owner" }); + if (checkMemberChange(change, { targetRole: member.role, ownerCount })) return fail(t.lastOwner); + + if (change.type === "remove") { + await HouseholdMemberModel.deleteOne({ _id: member._id }); + } else { + await HouseholdMemberModel.updateOne( + { _id: member._id }, + { $set: { role: change.role, updatedBy: actor } }, + ); + } + await writeAudit({ + action: change.type === "remove" ? "household.member.remove" : "household.member.role", + actorId: actor, + householdId, + targetType: "user", + targetId: targetUserId, + diff: + change.type === "remove" + ? { role: { from: member.role, to: null } } + : { role: { from: member.role, to: change.role } }, + }); + return ok(undefined); +} + +/** Owner: üyenin hane içi rolünü değiştirir. */ +export async function setMemberRole( + actorId: string, + householdId: string, + targetUserId: string, + role: HouseholdRole, +): Promise { + const membership = await resolveMembership(actorId, householdId, "owner"); + if (!membership) return fail(tr.common.unauthorized); + return applyMemberChange(new Types.ObjectId(actorId), membership.householdId, targetUserId, { + type: "setRole", + role, + }); +} + +/** Owner: üyeyi haneden çıkarır. */ +export async function removeMember( + actorId: string, + householdId: string, + targetUserId: string, +): Promise { + const membership = await resolveMembership(actorId, householdId, "owner"); + if (!membership) return fail(tr.common.unauthorized); + return applyMemberChange(new Types.ObjectId(actorId), membership.householdId, targetUserId, { + type: "remove", + }); +} + +// --- Sistem admini (yönetim) — çağıran Server Action admin rolünü doğrular. --- +// Admin haneleri ve üyelikleri yönetir; hanenin finansal verisini GÖRMEZ (Faz 0 kararı). + +export type AdminHouseholdRow = { + id: string; + name: string; + memberCount: number; + owners: string[]; + members: HouseholdMemberView[]; + createdAt: string; +}; + +export async function adminListHouseholds(): Promise { + await connectMongoose(); + const households = await HouseholdModel.find({ archivedAt: null }).sort({ name: 1 }).lean(); + const rows = await Promise.all( + households.map(async (h) => { + const members = await memberViews(h._id); + return { + id: String(h._id), + name: h.name, + memberCount: members.length, + owners: members.filter((m) => m.role === "owner").map((m) => m.name), + members, + createdAt: new Date(h.createdAt).toISOString(), + }; + }), + ); + return rows.sort((a, b) => compareTr(a.name, b.name)); +} + +export type UserOption = { id: string; name: string; username: string; banned: boolean }; + +export async function adminListUserOptions(): Promise { + const users = await getDb() + .collection("user") + .find({}, { projection: { name: 1, username: 1, banned: 1 } }) + .toArray(); + return users + .map((u) => ({ + id: String(u._id), + name: String(u.name ?? ""), + username: String(u.username ?? ""), + banned: Boolean(u.banned), + })) + .sort((a, b) => compareTr(a.name, b.name)); +} + +/** Admin: hane + ilk sahip üyeliği tek transaction'da. */ +export async function adminCreateHousehold( + actorId: string, + input: { name: string; ownerUserId: string }, +): Promise> { + const actor = new Types.ObjectId(actorId); + const owner = toObjectId(input.ownerUserId); + if (!owner) return fail(tr.validation.selectUser); + const ownerExists = await getDb().collection("user").countDocuments({ _id: owner }, { limit: 1 }); + if (!ownerExists) return fail(tr.validation.selectUser); + + const mongoose = await connectMongoose(); + let createdId = ""; + await mongoose.connection.transaction(async (session) => { + const [household] = await HouseholdModel.create( + [{ name: input.name, createdBy: actor, updatedBy: actor }], + { + session, + }, + ); + createdId = String(household!._id); + await HouseholdMemberModel.create( + [ + { + householdId: household!._id, + userId: owner, + role: "owner", + createdBy: actor, + updatedBy: actor, + }, + ], + { session }, + ); + await writeAudit( + { + action: "household.create", + actorId: actor, + householdId: household!._id, + targetType: "household", + targetId: createdId, + diff: { name: input.name, ownerUserId: input.ownerUserId }, + }, + session, + ); + // FAZ 6: varsayılan Türkçe kategori seti bu transaction içinde yüklenecek. + }); + return ok({ id: createdId }); +} + +/** Admin: kullanıcıyı haneye ekler (zaten üyeyse rolünü günceller). */ +export async function adminAddMember( + actorId: string, + householdId: string, + input: { userId: string; role: HouseholdRole }, +): Promise { + const actor = new Types.ObjectId(actorId); + const hid = toObjectId(householdId); + const uid = toObjectId(input.userId); + if (!hid || !uid) return fail(tr.common.notFound); + await connectMongoose(); + const household = await HouseholdModel.exists({ _id: hid, archivedAt: null }); + const user = await getDb().collection("user").countDocuments({ _id: uid }, { limit: 1 }); + if (!household || !user) return fail(tr.common.notFound); + + const existing = await HouseholdMemberModel.findOne({ householdId: hid, userId: uid }).lean(); + if (existing) + return applyMemberChange(actor, hid, input.userId, { type: "setRole", role: input.role }); + + await HouseholdMemberModel.create({ + householdId: hid, + userId: uid, + role: input.role, + createdBy: actor, + updatedBy: actor, + }); + await writeAudit({ + action: "household.member.add", + actorId: actor, + householdId: hid, + targetType: "user", + targetId: input.userId, + diff: { role: { from: null, to: input.role } }, + }); + return ok(undefined); +} + +export async function adminSetMemberRole( + actorId: string, + householdId: string, + targetUserId: string, + role: HouseholdRole, +): Promise { + const hid = toObjectId(householdId); + if (!hid) return fail(tr.common.notFound); + await connectMongoose(); + return applyMemberChange(new Types.ObjectId(actorId), hid, targetUserId, { + type: "setRole", + role, + }); +} + +export async function adminRemoveMember( + actorId: string, + householdId: string, + targetUserId: string, +): Promise { + const hid = toObjectId(householdId); + if (!hid) return fail(tr.common.notFound); + await connectMongoose(); + return applyMemberChange(new Types.ObjectId(actorId), hid, targetUserId, { type: "remove" }); +} + +/** Kullanıcı silindiğinde tüm üyeliklerini temizler (admin kullanıcı silme akışı). */ +export async function removeAllMembershipsOfUser(userId: string): Promise { + const uid = toObjectId(userId); + if (!uid) return 0; + await connectMongoose(); + const res = await HouseholdMemberModel.deleteMany({ userId: uid }); + return res.deletedCount; +} diff --git a/src/lib/active-household.ts b/src/lib/active-household.ts new file mode 100644 index 0000000..2ce19fb --- /dev/null +++ b/src/lib/active-household.ts @@ -0,0 +1,24 @@ +import "server-only"; +import { cookies } from "next/headers"; +import { env } from "@/lib/env"; + +/** + * Aktif hane seçimi cookie'de tutulur. Cookie yalnızca bir TERCİHtir: DAL her istekte + * kullanıcının gerçekten o hanenin üyesi olduğunu doğrular; değilse ilk haneye düşer. + */ +export const ACTIVE_HOUSEHOLD_COOKIE = "butcem.hh"; + +export async function readActiveHouseholdId(): Promise { + return (await cookies()).get(ACTIVE_HOUSEHOLD_COOKIE)?.value; +} + +/** Yalnızca Server Action / Route Handler içinde çağrılabilir. */ +export async function writeActiveHouseholdId(householdId: string): Promise { + (await cookies()).set(ACTIVE_HOUSEHOLD_COOKIE, householdId, { + httpOnly: true, + sameSite: "lax", + secure: env().NODE_ENV === "production", + path: "/", + maxAge: 60 * 60 * 24 * 365, + }); +} diff --git a/src/lib/audit.test.ts b/src/lib/audit.test.ts new file mode 100644 index 0000000..d3d98a9 --- /dev/null +++ b/src/lib/audit.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { diffFields } from "./audit"; + +describe("diffFields", () => { + it("yalnızca değişen alanları döner", () => { + expect(diffFields({ a: 1, b: "x", c: true }, { a: 2, b: "x" })).toEqual({ + a: { from: 1, to: 2 }, + }); + }); + it("iç içe değerleri içerik olarak karşılaştırır", () => { + expect(diffFields({ s: { d: 1 } }, { s: { d: 1 } })).toEqual({}); + }); +}); diff --git a/src/lib/audit.ts b/src/lib/audit.ts new file mode 100644 index 0000000..75ba1d2 --- /dev/null +++ b/src/lib/audit.ts @@ -0,0 +1,41 @@ +import "server-only"; +import type { ClientSession, Types } from "mongoose"; +import { connectMongoose } from "@/lib/db/mongoose"; +import { AuditLogModel, type AuditAction } from "@/models/audit-log"; + +export type AuditEntry = { + action: AuditAction; + actorId: Types.ObjectId | string; + householdId?: Types.ObjectId | null; + targetType: string; + targetId: string; + diff?: Record | null; +}; + +/** Önce/sonra nesnelerinden yalnızca değişen alanları çıkarır: { alan: { from, to } }. */ +export function diffFields>(before: T, after: Partial) { + const out: Record = {}; + for (const [key, to] of Object.entries(after)) { + const from = before[key]; + if (JSON.stringify(from) !== JSON.stringify(to)) out[key] = { from, to }; + } + return out; +} + +/** + * Denetim kaydı yazar. `session` verilirse transaction'ın parçası olur (işlem geri alınırsa + * kayıt da geri alınır). Session yoksa hata asıl işlemi bozmaz; sunucuda loglanır. + */ +export async function writeAudit(entry: AuditEntry, session?: ClientSession): Promise { + const doc = { ...entry, householdId: entry.householdId ?? null, diff: entry.diff ?? null }; + if (session) { + await AuditLogModel.create([doc], { session }); + return; + } + try { + await connectMongoose(); + await AuditLogModel.create(doc); + } catch (error) { + console.error("[audit] kayıt yazılamadı", entry.action, error); + } +} diff --git a/src/lib/dal.ts b/src/lib/dal.ts index f648cc4..75567a1 100644 --- a/src/lib/dal.ts +++ b/src/lib/dal.ts @@ -3,6 +3,10 @@ import { headers } from "next/headers"; import { notFound, redirect } from "next/navigation"; import { cache } from "react"; import type { SessionUser } from "@/features/auth/types"; +import { hasHouseholdRole, pickActiveHousehold } from "@/features/households/roles"; +import { listMemberships, resolveMembership, type Membership } from "@/features/households/service"; +import type { HouseholdRole, HouseholdSummary } from "@/features/households/types"; +import { readActiveHouseholdId } from "@/lib/active-household"; import { getAuth } from "@/lib/auth"; import { visibleEmail } from "@/shared/account-email"; import type { SystemRole } from "@/shared/schemas/common"; @@ -66,3 +70,67 @@ export async function getActionUser(role?: SystemRole): Promise => { + const user = await getCurrentUser(); + if (!user) return null; + const [memberships, preferred] = await Promise.all([ + listMemberships(user.id), + readActiveHouseholdId(), + ]); + const household = pickActiveHousehold(memberships, preferred); + return { user, household, memberships }; +}); + +export type ActiveHousehold = HouseholdContext & { household: HouseholdSummary }; + +/** + * Sayfa: aktif hanede en az `minRole` yetkisi. Oturum yoksa /login, hanesi yoksa /hane-yok, + * yetkisi yetmiyorsa 404. + */ +export async function requireActiveHousehold( + minRole: HouseholdRole = "viewer", +): Promise { + const ctx = await getHouseholdContext(); + if (!ctx) redirect("/login"); + if (!ctx.household) redirect("/hane-yok"); + if (!hasHouseholdRole(ctx.household.role, minRole)) notFound(); + return ctx as ActiveHousehold; +} + +/** Server Action: aktif hanede en az `minRole` yetkisi; yoksa null (yönlendirme yapmaz). */ +export async function getHouseholdActionContext( + minRole: HouseholdRole = "viewer", +): Promise { + const ctx = await getHouseholdContext(); + if (!ctx?.household || !hasHouseholdRole(ctx.household.role, minRole)) return null; + return ctx as ActiveHousehold; +} + +/** + * Belirli bir hane için üyelik kontrolü (ör. URL'den veya istek gövdesinden gelen householdId). + * Üye değilse veya yetkisi yetmiyorsa null — çağıran 404/403 döner. + */ +export async function requireHouseholdMember( + householdId: string, + minRole: HouseholdRole = "viewer", +): Promise<{ user: SessionUser; membership: Membership } | null> { + const user = await getCurrentUser(); + if (!user) return null; + const membership = await resolveMembership(user.id, householdId, minRole); + return membership ? { user, membership } : null; +} diff --git a/src/lib/object-id.ts b/src/lib/object-id.ts new file mode 100644 index 0000000..5952942 --- /dev/null +++ b/src/lib/object-id.ts @@ -0,0 +1,8 @@ +import { Types } from "mongoose"; + +/** Geçerli 24 haneli hex ise ObjectId, değilse null (kullanıcı girdisi asla doğrudan sorguya girmez). */ +export function toObjectId(value: unknown): Types.ObjectId | null { + return typeof value === "string" && /^[a-f0-9]{24}$/i.test(value) + ? new Types.ObjectId(value) + : null; +} diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts new file mode 100644 index 0000000..ea2f870 --- /dev/null +++ b/src/models/audit-log.ts @@ -0,0 +1,44 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; + +/** + * Denetim kaydı (yalnızca eklenir, güncellenmez). Bölüm 7.2: hane ayarları, üye değişiklikleri, + * silmeler, aynî borç kapatma ve mutabakat düzeltmeleri; ayrıca admin kullanıcı işlemleri. + */ +export const AUDIT_ACTIONS = [ + "household.create", + "household.settings.update", + "household.member.add", + "household.member.role", + "household.member.remove", + "user.create", + "user.update", + "user.role", + "user.password.reset", + "user.ban", + "user.unban", + "user.remove", +] as const; +export type AuditAction = (typeof AUDIT_ACTIONS)[number]; + +const auditLogSchema = new Schema( + { + /** Hane kapsamındaki işlemlerde dolu; sistem (admin) işlemlerinde null. */ + householdId: { type: Schema.Types.ObjectId, default: null }, + actorId: { type: Schema.Types.ObjectId, required: true }, + action: { type: String, enum: AUDIT_ACTIONS, required: true }, + targetType: { type: String, required: true }, + targetId: { type: String, required: true }, + /** Değişen alanlar: { alan: { from, to } } veya özet bilgi. Şifre vb. sır asla yazılmaz. */ + diff: { type: Schema.Types.Mixed, default: null }, + at: { type: Date, default: () => new Date() }, + }, + { collection: "auditLogs", versionKey: false }, +); + +auditLogSchema.index({ householdId: 1, at: -1 }); +auditLogSchema.index({ actorId: 1, at: -1 }); + +export type AuditLog = InferSchemaType; + +export const AuditLogModel: Model = + (models.AuditLog as Model | undefined) ?? model("AuditLog", auditLogSchema); diff --git a/src/shared/schemas/household.ts b/src/shared/schemas/household.ts new file mode 100644 index 0000000..cd370dc --- /dev/null +++ b/src/shared/schemas/household.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { HOUSEHOLD_ROLES } from "../../features/households/types"; +import { tr } from "../../i18n/tr"; + +export const householdRoleSchema = z.enum(HOUSEHOLD_ROLES); + +export const householdNameSchema = z + .string() + .trim() + .min(2, tr.validation.householdNameMin) + .max(80, tr.validation.householdNameMax); + +/** Hane ayarları formu (owner). Tutarlar kuruş cinsinden tamsayı. */ +export const householdSettingsSchema = z.object({ + name: householdNameSchema, + budgetMonthStartDay: z.coerce.number().int().min(1).max(28), + installmentBudgetMode: z.enum(["per_installment", "purchase_month"]), + dailyPlanMode: z.enum(["even", "previous_month_pattern"]), + includePrivateAccountsInTotals: z.boolean(), + extraStoreSavingThresholdMinor: z.coerce.number().int().min(0).max(100_000_00), +}); + +export const householdCreateSchema = z.object({ + name: householdNameSchema, + /** İlk sahip (owner) olacak kullanıcı. */ + ownerUserId: z.string().regex(/^[a-f0-9]{24}$/i, tr.validation.selectUser), +}); + +export const addMemberSchema = z.object({ + userId: z.string().regex(/^[a-f0-9]{24}$/i, tr.validation.selectUser), + role: householdRoleSchema, +}); + +export type HouseholdSettingsInput = z.input; +export type HouseholdSettings = z.output; +export type HouseholdCreateInput = z.input; +export type AddMemberInput = z.input;