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 { installDefaultCategories } from "@/features/categories/service"; 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, ); // Varsayılan Türkçe kategori seti aynı transaction'da (Bölüm 6.2). await installDefaultCategories(household!._id, actor, session); }); 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; }