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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string | undefined> {
|
||||
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<void> {
|
||||
(await cookies()).set(ACTIVE_HOUSEHOLD_COOKIE, householdId, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env().NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
});
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | null;
|
||||
};
|
||||
|
||||
/** Önce/sonra nesnelerinden yalnızca değişen alanları çıkarır: { alan: { from, to } }. */
|
||||
export function diffFields<T extends Record<string, unknown>>(before: T, after: Partial<T>) {
|
||||
const out: Record<string, { from: unknown; to: unknown }> = {};
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<SessionUser | nu
|
||||
if (role && user.role !== role) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
// --- Hane kapsamı (veri izolasyonu) ---
|
||||
|
||||
export type HouseholdContext = {
|
||||
user: SessionUser;
|
||||
/** Aktif hane; kullanıcı hiçbir haneye üye değilse null. */
|
||||
household: HouseholdSummary | null;
|
||||
memberships: HouseholdSummary[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Oturum + kullanıcının haneleri + aktif hane (istek başına bir kez). Cookie'deki hane
|
||||
* kullanıcının üyesi olduğu bir hane değilse yok sayılır ve ilk haneye düşülür.
|
||||
* Sistem admini için ayrıcalık YOKTUR: yalnızca üyesi olduğu haneler görünür.
|
||||
*/
|
||||
export const getHouseholdContext = cache(async (): Promise<HouseholdContext | null> => {
|
||||
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<ActiveHousehold> {
|
||||
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<ActiveHousehold | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<typeof auditLogSchema>;
|
||||
|
||||
export const AuditLogModel: Model<AuditLog> =
|
||||
(models.AuditLog as Model<AuditLog> | undefined) ?? model<AuditLog>("AuditLog", auditLogSchema);
|
||||
@@ -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<typeof householdSettingsSchema>;
|
||||
export type HouseholdSettings = z.output<typeof householdSettingsSchema>;
|
||||
export type HouseholdCreateInput = z.input<typeof householdCreateSchema>;
|
||||
export type AddMemberInput = z.input<typeof addMemberSchema>;
|
||||
Reference in New Issue
Block a user