d9de29c18d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
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;
|
||
}
|