feat(households): hane rolleri, servis, DAL hane kapsamı ve denetim kaydı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:35:01 +03:00
parent 291a117b4f
commit de91f2505a
10 changed files with 731 additions and 0 deletions
+54
View File
@@ -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();
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { HouseholdRole } from "./types";
/** Hane içi yetki sırası: viewer < member < owner. */
const RANK: Record<HouseholdRole, number> = { 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<T extends { id: string; role: HouseholdRole }>(
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;
}
+399
View File
@@ -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<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,
);
// 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<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;
}