Files
butcem/src/features/households/service.ts
T

405 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { installDefaultStockCategories } from "@/features/stock/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<HouseholdSummary[]> {
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<Membership | null> {
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<HouseholdMemberView[]> {
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<HouseholdSettings> | 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<HouseholdDetail | null> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<AdminHouseholdRow[]> {
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<UserOption[]> {
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<ActionResult<{ id: string }>> {
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);
// Varsayılan stok kategorileri (gider eşlemesi yukarıdaki kategorilerden).
await installDefaultStockCategories(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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<number> {
const uid = toObjectId(userId);
if (!uid) return 0;
await connectMongoose();
const res = await HouseholdMemberModel.deleteMany({ userId: uid });
return res.deletedCount;
}