From de91f2505a272d5a6af4a086fef0644fb8a61b61 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 02:35:01 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(households):=20hane=20rolleri,=20servi?= =?UTF-8?q?s,=20DAL=20hane=20kapsam=C4=B1=20ve=20denetim=20kayd=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/features/households/roles.test.ts | 54 ++++ src/features/households/roles.ts | 43 +++ src/features/households/service.ts | 399 ++++++++++++++++++++++++++ src/lib/active-household.ts | 24 ++ src/lib/audit.test.ts | 13 + src/lib/audit.ts | 41 +++ src/lib/dal.ts | 68 +++++ src/lib/object-id.ts | 8 + src/models/audit-log.ts | 44 +++ src/shared/schemas/household.ts | 37 +++ 10 files changed, 731 insertions(+) create mode 100644 src/features/households/roles.test.ts create mode 100644 src/features/households/roles.ts create mode 100644 src/features/households/service.ts create mode 100644 src/lib/active-household.ts create mode 100644 src/lib/audit.test.ts create mode 100644 src/lib/audit.ts create mode 100644 src/lib/object-id.ts create mode 100644 src/models/audit-log.ts create mode 100644 src/shared/schemas/household.ts diff --git a/src/features/households/roles.test.ts b/src/features/households/roles.test.ts new file mode 100644 index 0000000..ea77e4e --- /dev/null +++ b/src/features/households/roles.test.ts @@ -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(); + }); +}); diff --git a/src/features/households/roles.ts b/src/features/households/roles.ts new file mode 100644 index 0000000..44bf221 --- /dev/null +++ b/src/features/households/roles.ts @@ -0,0 +1,43 @@ +import type { HouseholdRole } from "./types"; + +/** Hane içi yetki sırası: viewer < member < owner. */ +const RANK: Record = { 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( + 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; +} diff --git a/src/features/households/service.ts b/src/features/households/service.ts new file mode 100644 index 0000000..4b2a3ec --- /dev/null +++ b/src/features/households/service.ts @@ -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 { + 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, + ); + // 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 { + 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; +} diff --git a/src/lib/active-household.ts b/src/lib/active-household.ts new file mode 100644 index 0000000..2ce19fb --- /dev/null +++ b/src/lib/active-household.ts @@ -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 { + 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 { + (await cookies()).set(ACTIVE_HOUSEHOLD_COOKIE, householdId, { + httpOnly: true, + sameSite: "lax", + secure: env().NODE_ENV === "production", + path: "/", + maxAge: 60 * 60 * 24 * 365, + }); +} diff --git a/src/lib/audit.test.ts b/src/lib/audit.test.ts new file mode 100644 index 0000000..d3d98a9 --- /dev/null +++ b/src/lib/audit.test.ts @@ -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({}); + }); +}); diff --git a/src/lib/audit.ts b/src/lib/audit.ts new file mode 100644 index 0000000..75ba1d2 --- /dev/null +++ b/src/lib/audit.ts @@ -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 | null; +}; + +/** Önce/sonra nesnelerinden yalnızca değişen alanları çıkarır: { alan: { from, to } }. */ +export function diffFields>(before: T, after: Partial) { + const out: Record = {}; + 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 { + 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); + } +} diff --git a/src/lib/dal.ts b/src/lib/dal.ts index f648cc4..75567a1 100644 --- a/src/lib/dal.ts +++ b/src/lib/dal.ts @@ -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 => { + 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 { + 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 { + 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; +} diff --git a/src/lib/object-id.ts b/src/lib/object-id.ts new file mode 100644 index 0000000..5952942 --- /dev/null +++ b/src/lib/object-id.ts @@ -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; +} diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts new file mode 100644 index 0000000..ea2f870 --- /dev/null +++ b/src/models/audit-log.ts @@ -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; + +export const AuditLogModel: Model = + (models.AuditLog as Model | undefined) ?? model("AuditLog", auditLogSchema); diff --git a/src/shared/schemas/household.ts b/src/shared/schemas/household.ts new file mode 100644 index 0000000..cd370dc --- /dev/null +++ b/src/shared/schemas/household.ts @@ -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; +export type HouseholdSettings = z.output; +export type HouseholdCreateInput = z.input; +export type AddMemberInput = z.input; From 3644e081b189d0256f18abebea7cee2f9dd91679 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 02:35:01 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat(households):=20hane=20se=C3=A7ici,=20a?= =?UTF-8?q?yarlar,=20admin=20hane=20y=C3=B6netimi=20ve=20hane-yok=20sayfas?= =?UTF-8?q?=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/admin/haneler/page.tsx | 18 ++ src/app/(app)/ayarlar/page.tsx | 50 +++ src/app/(app)/dashboard/page.tsx | 6 +- src/app/(app)/hane-yok/page.tsx | 39 +++ src/app/(app)/layout.tsx | 21 +- src/components/layout/app-header.tsx | 9 +- src/components/layout/bottom-nav.tsx | 6 +- src/components/layout/household-switcher.tsx | 36 ++- src/components/layout/nav-config.ts | 2 +- src/features/admin-households/actions.ts | 74 +++++ .../components/households-admin.tsx | 306 ++++++++++++++++++ .../components/user-select.tsx | 48 +++ src/features/admin-households/queries.ts | 10 + src/features/admin-users/actions.ts | 54 +++- src/features/households/actions.ts | 60 ++++ .../components/household-settings-form.tsx | 228 +++++++++++++ .../households/components/members-list.tsx | 169 ++++++++++ .../components/settings-members.tsx | 21 ++ src/features/households/mock.ts | 7 - src/features/households/service.int.test.ts | 207 ++++++++++++ src/i18n/tr.ts | 85 +++++ 21 files changed, 1427 insertions(+), 29 deletions(-) create mode 100644 src/app/(app)/admin/haneler/page.tsx create mode 100644 src/app/(app)/ayarlar/page.tsx create mode 100644 src/app/(app)/hane-yok/page.tsx create mode 100644 src/features/admin-households/actions.ts create mode 100644 src/features/admin-households/components/households-admin.tsx create mode 100644 src/features/admin-households/components/user-select.tsx create mode 100644 src/features/admin-households/queries.ts create mode 100644 src/features/households/actions.ts create mode 100644 src/features/households/components/household-settings-form.tsx create mode 100644 src/features/households/components/members-list.tsx create mode 100644 src/features/households/components/settings-members.tsx delete mode 100644 src/features/households/mock.ts create mode 100644 src/features/households/service.int.test.ts diff --git a/src/app/(app)/admin/haneler/page.tsx b/src/app/(app)/admin/haneler/page.tsx new file mode 100644 index 0000000..046001c --- /dev/null +++ b/src/app/(app)/admin/haneler/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { PageHeader } from "@/components/page-header"; +import { HouseholdsAdmin } from "@/features/admin-households/components/households-admin"; +import { getAdminHouseholdsData } from "@/features/admin-households/queries"; +import { tr } from "@/i18n/tr"; +import { requireRole } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.adminHouseholds.title }; + +export default async function AdminHouseholdsPage() { + const [admin, data] = await Promise.all([requireRole("admin"), getAdminHouseholdsData()]); + return ( + <> + + + + ); +} diff --git a/src/app/(app)/ayarlar/page.tsx b/src/app/(app)/ayarlar/page.tsx new file mode 100644 index 0000000..47f0125 --- /dev/null +++ b/src/app/(app)/ayarlar/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { HouseholdSettingsForm } from "@/features/households/components/household-settings-form"; +import { SettingsMembers } from "@/features/households/components/settings-members"; +import { getHouseholdDetail } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.settings.title }; + +export default async function SettingsPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const detail = await getHouseholdDetail(user.id, household.id); + if (!detail) notFound(); + const isOwner = household.role === "owner"; + const t = tr.settings; + + return ( + <> + +
+ + + {t.household} + {isOwner ? t.householdDescription : t.ownerOnly} + + + + + + + + {t.members} + {t.membersDescription} + + + + + +
+ + ); +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index d3879db..c69c6a3 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -4,7 +4,7 @@ import { PageHeader } from "@/components/page-header"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; import { tr } from "@/i18n/tr"; -import { requireSession } from "@/lib/dal"; +import { requireActiveHousehold } from "@/lib/dal"; import { cn } from "@/lib/utils"; import { formatMoney } from "@/shared/format"; @@ -52,12 +52,12 @@ async function SummaryCards() { } export default async function DashboardPage() { - const user = await requireSession(); + const { user, household } = await requireActiveHousehold("viewer"); return ( <> }> diff --git a/src/app/(app)/hane-yok/page.tsx b/src/app/(app)/hane-yok/page.tsx new file mode 100644 index 0000000..13570a3 --- /dev/null +++ b/src/app/(app)/hane-yok/page.tsx @@ -0,0 +1,39 @@ +import { House } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { tr } from "@/i18n/tr"; +import { getHouseholdContext } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.households.noHousehold.title }; + +/** Hiçbir haneye üye olmayan kullanıcılar (ör. hanesi olmayan admin) buraya yönlenir. */ +export default async function NoHouseholdPage() { + const ctx = await getHouseholdContext(); + if (!ctx) redirect("/login"); + if (ctx.household) redirect("/dashboard"); + const t = tr.households.noHousehold; + return ( +
+ + + + + + {t.title} + {t.description} + + {ctx.user.role === "admin" ? ( + +

{t.adminHint}

+ +
+ ) : null} +
+
+ ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 439af9a..8d26e58 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,3 +1,4 @@ +import { redirect } from "next/navigation"; import { Suspense } from "react"; import { AppHeader } from "@/components/layout/app-header"; import { AppShellFallback } from "@/components/layout/app-shell-fallback"; @@ -5,8 +6,8 @@ import { AppSidebar } from "@/components/layout/app-sidebar"; import { BottomNav } from "@/components/layout/bottom-nav"; import { QuickAddProvider } from "@/components/layout/quick-add"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; -import { MOCK_HOUSEHOLDS } from "@/features/households/mock"; -import { requireSession } from "@/lib/dal"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { getHouseholdContext } from "@/lib/dal"; import { APP_VERSION } from "@/lib/version"; /** @@ -23,18 +24,26 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { } async function AuthenticatedShell({ children }: { children: React.ReactNode }) { - const user = await requireSession(); + const ctx = await getHouseholdContext(); + if (!ctx) redirect("/login"); + const { user, household, memberships } = ctx; + const canWrite = household ? hasHouseholdRole(household.role, "member") : false; return ( - {/* FAZ 5: kullanıcının üye olduğu haneler */} - + {/* Mobilde alt menü yüksekliği kadar boşluk */}
{children}
- +
); diff --git a/src/components/layout/app-header.tsx b/src/components/layout/app-header.tsx index d0e0d20..291acf7 100644 --- a/src/components/layout/app-header.tsx +++ b/src/components/layout/app-header.tsx @@ -11,18 +11,23 @@ import { UserMenu } from "./user-menu"; export function AppHeader({ user, households, + activeHouseholdId, + canWrite, version, }: { user: SessionUser; households: HouseholdSummary[]; + activeHouseholdId: string | undefined; + /** Aktif hanede en az "member" mi? (İzleyici veri giremez.) */ + canWrite: boolean; version: string; }) { return (
- +
- + {canWrite ? : null}
diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index ce8bc15..812d6d2 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -40,7 +40,7 @@ function BottomNavLink({ item, pathname }: { item: NavItem; pathname: string }) } /** Mobil alt gezinme çubuğu (md altı). */ -export function BottomNav() { +export function BottomNav({ canWrite }: { canWrite: boolean }) { const pathname = usePathname(); const { openQuickAdd } = useQuickAdd(); const { setOpenMobile } = useSidebar(); @@ -57,8 +57,10 @@ export function BottomNav() { diff --git a/src/components/layout/household-switcher.tsx b/src/components/layout/household-switcher.tsx index 733738f..73df1b0 100644 --- a/src/components/layout/household-switcher.tsx +++ b/src/components/layout/household-switcher.tsx @@ -1,7 +1,8 @@ "use client"; import { House } from "lucide-react"; -import { useState } from "react"; +import { useTransition } from "react"; +import { toast } from "sonner"; import { Select, SelectContent, @@ -9,17 +10,38 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { switchHouseholdAction } from "@/features/households/actions"; import type { HouseholdSummary } from "@/features/households/types"; import { tr } from "@/i18n/tr"; -/** Üst bar aktif hane seçici. FAZ 5: seçim sunucuda (cookie) saklanacak. */ -export function HouseholdSwitcher({ households }: { households: HouseholdSummary[] }) { - const [value, setValue] = useState(households[0]?.id); - const current = households.find((h) => h.id === value); +/** Üst bar aktif hane seçici. Seçim sunucuda doğrulanır ve cookie'ye yazılır. */ +export function HouseholdSwitcher({ + households, + activeId, +}: { + households: HouseholdSummary[]; + activeId: string | undefined; +}) { + const [pending, startTransition] = useTransition(); + const current = households.find((h) => h.id === activeId); if (households.length === 0) return null; + + function onChange(id: string) { + if (id === activeId) return; + startTransition(async () => { + const result = await switchHouseholdAction(id); + if (result.ok) toast.success(tr.household.switched(result.data.name)); + else toast.error(result.error); + }); + } + return ( - + {/* Etiket children ile verilir: Radix aksi halde hydration'a kadar boş gösterir. */} {current?.name} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 1ede442..62fbfd2 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -25,7 +25,7 @@ export type NavItem = { }; /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ -export const CURRENT_PHASE = 2; +export const CURRENT_PHASE = 5; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; diff --git a/src/features/admin-households/actions.ts b/src/features/admin-households/actions.ts new file mode 100644 index 0000000..972297f --- /dev/null +++ b/src/features/admin-households/actions.ts @@ -0,0 +1,74 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { + adminAddMember, + adminCreateHousehold, + adminRemoveMember, + adminSetMemberRole, +} from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { fail, type ActionResult } from "@/lib/action-result"; +import { getActionUser } from "@/lib/dal"; +import { + addMemberSchema, + householdCreateSchema, + householdRoleSchema, +} from "@/shared/schemas/household"; + +/** Sistem admini: hane ve üyelik yönetimi. Hanenin finansal verisine dokunmaz. */ + +const PATH = "/admin/haneler"; +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +export async function createHouseholdAction(input: unknown): Promise> { + const admin = await getActionUser("admin"); + if (!admin) return fail(tr.common.unauthorized); + const parsed = householdCreateSchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields); + const result = await adminCreateHousehold(admin.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +export async function addMemberAction(householdId: unknown, input: unknown): Promise { + const admin = await getActionUser("admin"); + if (!admin) return fail(tr.common.unauthorized); + const hid = idSchema.safeParse(householdId); + const parsed = addMemberSchema.safeParse(input); + if (!hid.success || !parsed.success) return fail(tr.adminUsers.errors.checkFields); + const result = await adminAddMember(admin.id, hid.data, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +export async function adminSetMemberRoleAction( + householdId: unknown, + userId: unknown, + role: unknown, +): Promise { + const admin = await getActionUser("admin"); + if (!admin) return fail(tr.common.unauthorized); + const hid = idSchema.safeParse(householdId); + const uid = idSchema.safeParse(userId); + const r = householdRoleSchema.safeParse(role); + if (!hid.success || !uid.success || !r.success) return fail(tr.common.genericError); + const result = await adminSetMemberRole(admin.id, hid.data, uid.data, r.data); + if (result.ok) revalidatePath(PATH); + return result; +} + +export async function adminRemoveMemberAction( + householdId: unknown, + userId: unknown, +): Promise { + const admin = await getActionUser("admin"); + if (!admin) return fail(tr.common.unauthorized); + const hid = idSchema.safeParse(householdId); + const uid = idSchema.safeParse(userId); + if (!hid.success || !uid.success) return fail(tr.common.genericError); + const result = await adminRemoveMember(admin.id, hid.data, uid.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} diff --git a/src/features/admin-households/components/households-admin.tsx b/src/features/admin-households/components/households-admin.tsx new file mode 100644 index 0000000..9c18aaf --- /dev/null +++ b/src/features/admin-households/components/households-admin.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2, Plus, UserPlus } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { MembersList } from "@/features/households/components/members-list"; +import type { AdminHouseholdRow, UserOption } from "@/features/households/service"; +import { HOUSEHOLD_ROLES } from "@/features/households/types"; +import { tr } from "@/i18n/tr"; +import { + addMemberSchema, + householdCreateSchema, + type AddMemberInput, + type HouseholdCreateInput, +} from "@/shared/schemas/household"; +import { + addMemberAction, + adminRemoveMemberAction, + adminSetMemberRoleAction, + createHouseholdAction, +} from "../actions"; +import { UserSelect } from "./user-select"; + +const t = tr.adminHouseholds; + +function CreateHouseholdDialog({ + open, + onOpenChange, + users, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + users: UserOption[]; +}) { + const form = useForm({ + resolver: zodResolver(householdCreateSchema), + defaultValues: { name: "", ownerUserId: "" }, + }); + useEffect(() => { + if (open) form.reset({ name: "", ownerUserId: "" }); + }, [open, form]); + + async function onSubmit(values: HouseholdCreateInput) { + const result = await createHouseholdAction(values); + if (result.ok) { + toast.success(t.created(values.name)); + onOpenChange(false); + } else { + form.setError("root", { message: result.error }); + } + } + + return ( + + + + + } + > +
+ + ( + + {t.name} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {t.owner} + + {fieldState.invalid ? : null} + + )} + /> + {form.formState.errors.root ? ( + {form.formState.errors.root.message} + ) : null} + +
+
+ ); +} + +function AddMemberDialog({ + household, + users, + onOpenChange, +}: { + household: AdminHouseholdRow | null; + users: UserOption[]; + onOpenChange: (o: boolean) => void; +}) { + const form = useForm({ + resolver: zodResolver(addMemberSchema), + defaultValues: { userId: "", role: "member" }, + }); + useEffect(() => { + if (household) form.reset({ userId: "", role: "member" }); + }, [household, form]); + + async function onSubmit(values: AddMemberInput) { + if (!household) return; + const result = await addMemberAction(household.id, values); + if (result.ok) { + toast.success(t.memberAdded(users.find((u) => u.id === values.userId)?.name ?? "")); + onOpenChange(false); + } else { + form.setError("root", { message: result.error }); + } + } + + return ( + + + + + } + > +
+ + ( + + {t.user} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {t.role} + + + {tr.household.roleHints[field.value]} + + + )} + /> + {form.formState.errors.root ? ( + {form.formState.errors.root.message} + ) : null} + +
+
+ ); +} + +export function HouseholdsAdmin({ + households, + users, + currentUserId, +}: { + households: AdminHouseholdRow[]; + users: UserOption[]; + currentUserId: string; +}) { + const [creating, setCreating] = useState(false); + const [addingTo, setAddingTo] = useState(null); + + return ( +
+
+

{t.privacyNote}

+ +
+ + {households.length === 0 ? ( +

+ {t.empty} +

+ ) : ( +
+ {households.map((h) => ( + + + {h.name} + + {t.members(h.memberCount)} + {h.owners.length ? ( + + · {t.owners}: {h.owners.join(", ")} + + ) : ( + {t.noOwner} + )} + + + + + + + adminSetMemberRoleAction(h.id, userId, role)} + onRemove={(userId) => adminRemoveMemberAction(h.id, userId)} + /> + + + ))} +
+ )} + + + !o && setAddingTo(null)} + /> +
+ ); +} diff --git a/src/features/admin-households/components/user-select.tsx b/src/features/admin-households/components/user-select.tsx new file mode 100644 index 0000000..894b16f --- /dev/null +++ b/src/features/admin-households/components/user-select.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { UserOption } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; + +/** Kullanıcı seçici (askıdakiler işaretli). */ +export function UserSelect({ + id, + users, + value, + onChange, + invalid, +}: { + id: string; + users: UserOption[]; + value: string; + onChange: (id: string) => void; + invalid?: boolean; +}) { + const selected = users.find((u) => u.id === value); + return ( + + ); +} diff --git a/src/features/admin-households/queries.ts b/src/features/admin-households/queries.ts new file mode 100644 index 0000000..4db9444 --- /dev/null +++ b/src/features/admin-households/queries.ts @@ -0,0 +1,10 @@ +import "server-only"; +import { adminListHouseholds, adminListUserOptions } from "@/features/households/service"; +import { requireRole } from "@/lib/dal"; + +/** Admin: haneler (üyeleriyle) ve hane atamaları için kullanıcı listesi. */ +export async function getAdminHouseholdsData() { + await requireRole("admin"); + const [households, users] = await Promise.all([adminListHouseholds(), adminListUserOptions()]); + return { households, users }; +} diff --git a/src/features/admin-users/actions.ts b/src/features/admin-users/actions.ts index 38e62df..fac87e0 100644 --- a/src/features/admin-users/actions.ts +++ b/src/features/admin-users/actions.ts @@ -7,7 +7,9 @@ import { tr } from "@/i18n/tr"; import { fail, ok, type ActionResult } from "@/lib/action-result"; import { getAuth } from "@/lib/auth"; import { authErrorCode } from "@/lib/auth-errors"; +import { writeAudit } from "@/lib/audit"; import { getActionUser } from "@/lib/dal"; +import { removeAllMembershipsOfUser } from "@/features/households/service"; import { storedEmail } from "@/shared/account-email"; import { systemRoleSchema, type SystemRole } from "@/shared/schemas/common"; import { @@ -102,8 +104,9 @@ export async function createUserAction(input: unknown): Promise { const parsed = userCreateSchema.safeParse(input); if (!parsed.success) return fail(t.errors.checkFields); const v = parsed.data; + let createdId = ""; try { - await ctx.api.createUser({ + const created = await ctx.api.createUser({ headers: ctx.headers, body: { name: v.name, @@ -113,9 +116,17 @@ export async function createUserAction(input: unknown): Promise { data: { username: v.username }, }, }); + createdId = created.user.id; } catch (error) { return mapAuthError(error); } + await writeAudit({ + action: "user.create", + actorId: ctx.actor.id, + targetType: "user", + targetId: createdId, + diff: { username: v.username, role: v.role }, + }); revalidatePath(PATH); return ok(undefined); } @@ -148,6 +159,13 @@ export async function updateUserAction(userId: unknown, input: unknown): Promise } catch (error) { return mapAuthError(error); } + await writeAudit({ + action: "user.update", + actorId: ctx.actor.id, + targetType: "user", + targetId: target.id, + diff: { name: v.name, username: v.username, role: v.role }, + }); revalidatePath(PATH); return ok(undefined); } @@ -167,6 +185,13 @@ export async function setRoleAction(userId: unknown, role: unknown): Promise { } catch (error) { return mapAuthError(error); } + await writeAudit({ + action: "user.unban", + actorId: ctx.actor.id, + targetType: "user", + targetId: id.data, + }); revalidatePath(PATH); return ok(undefined); } @@ -245,6 +289,14 @@ export async function removeUserAction(userId: unknown): Promise { } catch (error) { return mapAuthError(error); } + const removedMemberships = await removeAllMembershipsOfUser(target.id); + await writeAudit({ + action: "user.remove", + actorId: ctx.actor.id, + targetType: "user", + targetId: target.id, + diff: { name: target.name, removedMemberships }, + }); revalidatePath(PATH); return ok(undefined); } diff --git a/src/features/households/actions.ts b/src/features/households/actions.ts new file mode 100644 index 0000000..6d38f3d --- /dev/null +++ b/src/features/households/actions.ts @@ -0,0 +1,60 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { writeActiveHouseholdId } from "@/lib/active-household"; +import { getCurrentUser, getHouseholdActionContext } from "@/lib/dal"; +import { householdRoleSchema, householdSettingsSchema } from "@/shared/schemas/household"; +import { removeMember, resolveMembership, setMemberRole, updateHouseholdSettings } from "./service"; + +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +/** Aktif haneyi değiştirir: önce üyelik doğrulanır, sonra cookie yazılır. */ +export async function switchHouseholdAction( + householdId: unknown, +): Promise> { + const user = await getCurrentUser(); + const id = idSchema.safeParse(householdId); + if (!user || !id.success) return fail(tr.common.unauthorized); + const membership = await resolveMembership(user.id, id.data, "viewer"); + if (!membership) return fail(tr.common.unauthorized); + await writeActiveHouseholdId(id.data); + revalidatePath("/", "layout"); + return ok({ name: membership.name }); +} + +/** Owner: hane adı ve ayarları (aktif hane). */ +export async function updateHouseholdSettingsAction(input: unknown): Promise { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = householdSettingsSchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields); + const result = await updateHouseholdSettings(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +/** Owner: aktif hanedeki üyenin rolü. */ +export async function setMemberRoleAction(userId: unknown, role: unknown): Promise { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const uid = idSchema.safeParse(userId); + const r = householdRoleSchema.safeParse(role); + if (!uid.success || !r.success) return fail(tr.common.genericError); + const result = await setMemberRole(ctx.user.id, ctx.household.id, uid.data, r.data); + if (result.ok) revalidatePath("/ayarlar"); + return result; +} + +/** Owner: üyeyi aktif haneden çıkarır. */ +export async function removeMemberAction(userId: unknown): Promise { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const uid = idSchema.safeParse(userId); + if (!uid.success) return fail(tr.common.genericError); + const result = await removeMember(ctx.user.id, ctx.household.id, uid.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} diff --git a/src/features/households/components/household-settings-form.tsx b/src/features/households/components/household-settings-form.tsx new file mode 100644 index 0000000..e5e6299 --- /dev/null +++ b/src/features/households/components/household-settings-form.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldSet, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { tr } from "@/i18n/tr"; +import { householdSettingsSchema, type HouseholdSettings } from "@/shared/schemas/household"; +import { updateHouseholdSettingsAction } from "../actions"; + +const t = tr.settings.fields; + +/** Form şeması: eşik TL (tam sayı) olarak girilir, kuruşa sunucuya giderken çevrilir. */ +const formSchema = householdSettingsSchema + .omit({ extraStoreSavingThresholdMinor: true, budgetMonthStartDay: true }) + .extend({ + budgetMonthStartDay: z.number().int().min(1).max(28), + extraStoreSavingThresholdTl: z + .number({ error: tr.validation.wholeNumber }) + .int(tr.validation.wholeNumber) + .min(0) + .max(100_000), + }); +type FormValues = z.infer; + +const DAYS = Array.from({ length: 28 }, (_, i) => i + 1); + +export function HouseholdSettingsForm({ + settings, + canEdit, +}: { + settings: HouseholdSettings; + canEdit: boolean; +}) { + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: settings.name, + budgetMonthStartDay: settings.budgetMonthStartDay, + installmentBudgetMode: settings.installmentBudgetMode, + dailyPlanMode: settings.dailyPlanMode, + includePrivateAccountsInTotals: settings.includePrivateAccountsInTotals, + extraStoreSavingThresholdTl: Math.round(settings.extraStoreSavingThresholdMinor / 100), + }, + }); + + async function onSubmit({ extraStoreSavingThresholdTl, ...rest }: FormValues) { + const result = await updateHouseholdSettingsAction({ + ...rest, + extraStoreSavingThresholdMinor: extraStoreSavingThresholdTl * 100, + }); + if (result.ok) { + toast.success(tr.settings.saved); + form.reset({ ...rest, extraStoreSavingThresholdTl }); + } else { + toast.error(result.error); + } + } + + const submitting = form.formState.isSubmitting; + + return ( +
+ {/* Sahip olmayanlar için tüm alanlar salt okunur. */} +
+ + ( + + {t.name} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {t.budgetMonthStartDay} + + + {t.budgetMonthStartDayHint} + + + )} + /> + ( + + {t.installmentBudgetMode} + + + )} + /> + ( + + {t.dailyPlanMode} + + + )} + /> + ( + + {t.extraStoreSavingThreshold} + field.onChange(e.target.valueAsNumber)} + aria-invalid={fieldState.invalid} + aria-describedby="hh-threshold-hint" + /> + + {t.extraStoreSavingThresholdHint} + + {fieldState.invalid ? : null} + + )} + /> + ( + + field.onChange(v === true)} + disabled={!canEdit} + /> + + {t.includePrivateAccountsInTotals} + + + )} + /> + {canEdit ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/src/features/households/components/members-list.tsx b/src/features/households/components/members-list.tsx new file mode 100644 index 0000000..230dcbc --- /dev/null +++ b/src/features/households/components/members-list.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { UserMinus } from "lucide-react"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { initials } from "@/components/layout/user-menu"; +import { tr } from "@/i18n/tr"; +import type { ActionResult } from "@/lib/action-result"; +import { HOUSEHOLD_ROLES, type HouseholdRole } from "../types"; + +export type MemberRow = { + userId: string; + name: string; + username: string; + role: HouseholdRole; + banned: boolean; +}; + +type Props = { + members: MemberRow[]; + householdName: string; + currentUserId: string; + canManage: boolean; + onSetRole: (userId: string, role: HouseholdRole) => Promise; + onRemove: (userId: string) => Promise; +}; + +/** Hane üyeleri: owner (veya admin) rol değiştirir / haneden çıkarır. Kurallar sunucuda. */ +export function MembersList({ + members, + householdName, + currentUserId, + canManage, + onSetRole, + onRemove, +}: Props) { + const [pending, startTransition] = useTransition(); + const [removing, setRemoving] = useState(null); + const t = tr.settings; + + function changeRole(member: MemberRow, role: HouseholdRole) { + startTransition(async () => { + const result = await onSetRole(member.userId, role); + if (result.ok) toast.success(t.roleChanged(member.name)); + else toast.error(result.error); + }); + } + + function confirmRemove() { + const member = removing; + if (!member) return; + startTransition(async () => { + const result = await onRemove(member.userId); + if (result.ok) { + toast.success(t.memberRemoved(member.name)); + setRemoving(null); + } else { + toast.error(result.error); + } + }); + } + + return ( + <> +
    + {members.map((m) => ( +
  • + + + {initials(m.name)} + + +
    +

    + {m.name} + {m.userId === currentUserId ? ( + + ({tr.adminUsers.you}) + + ) : null} +

    +

    @{m.username}

    +
    + {m.banned ? {t.banned} : null} + {canManage ? ( +
    + + +
    + ) : ( + + {tr.household.roles[m.role]} + + )} +
  • + ))} +
+ + !open && setRemoving(null)}> + + + {t.removeTitle} + + {removing ? t.removeDescription(removing.name, householdName) : null} + + + + {tr.common.cancel} + + + + + + ); +} diff --git a/src/features/households/components/settings-members.tsx b/src/features/households/components/settings-members.tsx new file mode 100644 index 0000000..c8632f9 --- /dev/null +++ b/src/features/households/components/settings-members.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { removeMemberAction, setMemberRoleAction } from "../actions"; +import type { HouseholdRole } from "../types"; +import { MembersList, type MemberRow } from "./members-list"; + +/** /ayarlar: aktif hanenin üyeleri (owner action'larına bağlı). */ +export function SettingsMembers(props: { + members: MemberRow[]; + householdName: string; + currentUserId: string; + canManage: boolean; +}) { + return ( + setMemberRoleAction(userId, role)} + onRemove={(userId) => removeMemberAction(userId)} + /> + ); +} diff --git a/src/features/households/mock.ts b/src/features/households/mock.ts deleted file mode 100644 index 04e0c4b..0000000 --- a/src/features/households/mock.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { HouseholdSummary } from "./types"; - -/** FAZ 2 GEÇİCİ: Faz 5'te kullanıcının üye olduğu hanelerle değiştirilecek. */ -export const MOCK_HOUSEHOLDS: HouseholdSummary[] = [ - { id: "h_cakir", name: "Çakır Ailesi", role: "owner" }, - { id: "h_anne", name: "Annemler", role: "viewer" }, -]; diff --git a/src/features/households/service.int.test.ts b/src/features/households/service.int.test.ts new file mode 100644 index 0000000..cf265b4 --- /dev/null +++ b/src/features/households/service.int.test.ts @@ -0,0 +1,207 @@ +import mongoose, { Types } from "mongoose"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getDb, getMongoClient } from "@/lib/db/mongo"; +import { AuditLogModel } from "@/models/audit-log"; +import { HouseholdModel } from "@/models/household"; +import { HouseholdMemberModel } from "@/models/household-member"; +import { runSeed } from "../../../scripts/seed/run"; +import { + adminCreateHousehold, + adminSetMemberRole, + getHouseholdDetail, + listMemberships, + removeAllMembershipsOfUser, + removeMember, + resolveMembership, + setMemberRole, + updateHouseholdSettings, +} from "./service"; + +/** + * VERİ İZOLASYONU (Bölüm 5): başka hanenin verisine erişim imkânsız olmalı. + * Seed verisi: Çakır Ailesi (admin+ayse owner, emre member, zeynep viewer, ozan member), + * Annemler (fatma owner, mehmet member, ayse viewer), burak = hanesi olmayan admin. + */ + +const ids = new Map(); +let cakir = ""; +let annemler = ""; + +const settings = { + name: "Çakır Ailesi", + budgetMonthStartDay: 15, + installmentBudgetMode: "purchase_month" as const, + dailyPlanMode: "even" as const, + includePrivateAccountsInTotals: false, + extraStoreSavingThresholdMinor: 25_000, +}; + +beforeAll(async () => { + expect(getDb().databaseName).toBe("butcem_test"); + await getDb().dropDatabase(); + await runSeed({ log: () => {} }); + for (const u of await getDb().collection("user").find().toArray()) + ids.set(String(u.username), String(u._id)); + cakir = String((await HouseholdModel.findOne({ name: "Çakır Ailesi" }))!._id); + annemler = String((await HouseholdModel.findOne({ name: "Annemler" }))!._id); +}); + +afterAll(async () => { + await getDb().dropDatabase(); + await mongoose.disconnect(); + await getMongoClient().close(); +}); + +const id = (username: string) => ids.get(username)!; + +describe("üyelik çözümleme (izolasyon çekirdeği)", () => { + it("üye olmayan kullanıcı başka hanenin üyeliğini çözemez", async () => { + expect(await resolveMembership(id("fatma"), cakir)).toBeNull(); + expect(await resolveMembership(id("emre"), annemler)).toBeNull(); + }); + + it("rol yetmezse null döner", async () => { + expect((await resolveMembership(id("ayse"), annemler, "viewer"))?.role).toBe("viewer"); + expect(await resolveMembership(id("ayse"), annemler, "member")).toBeNull(); + expect(await resolveMembership(id("zeynep"), cakir, "member")).toBeNull(); + expect((await resolveMembership(id("emre"), cakir, "member"))?.role).toBe("member"); + }); + + it("hanesi olmayan admin hiçbir haneyi göremez (admin ayrıcalığı yok)", async () => { + expect(await listMemberships(id("burak"))).toEqual([]); + expect(await resolveMembership(id("burak"), cakir)).toBeNull(); + expect(await getHouseholdDetail(id("burak"), cakir)).toBeNull(); + }); + + it("geçersiz ve uydurma kimlikler reddedilir", async () => { + const forged = new Types.ObjectId().toHexString(); + expect(await resolveMembership(id("ayse"), forged)).toBeNull(); + expect(await resolveMembership(id("ayse"), "not-an-id")).toBeNull(); + expect(await resolveMembership(id("ayse"), { $ne: null } as unknown as string)).toBeNull(); + expect(await resolveMembership("", cakir)).toBeNull(); + }); + + it("kullanıcı yalnızca kendi hanelerini listeler", async () => { + expect((await listMemberships(id("ayse"))).map((m) => [m.name, m.role])).toEqual([ + ["Annemler", "viewer"], + ["Çakır Ailesi", "owner"], + ]); + expect((await listMemberships(id("fatma"))).map((m) => m.name)).toEqual(["Annemler"]); + }); +}); + +describe("hane ayrıntısı ve ayarlar", () => { + it("başka hanenin ayrıntısı okunamaz", async () => { + expect(await getHouseholdDetail(id("fatma"), cakir)).toBeNull(); + const own = await getHouseholdDetail(id("emre"), cakir); + expect(own?.members.map((m) => m.username).sort()).toEqual([ + "admin", + "ayse", + "emre", + "ozan", + "zeynep", + ]); + }); + + it("başka hanenin sahibi, üye ve izleyici ayarları değiştiremez", async () => { + for (const u of ["fatma", "emre", "zeynep", "burak"]) { + const res = await updateHouseholdSettings(id(u), cakir, settings); + expect(res.ok, u).toBe(false); + } + const h = await HouseholdModel.findById(cakir).lean(); + expect(h?.settings?.budgetMonthStartDay).toBe(1); + }); + + it("sahip ayarları değiştirir ve denetim kaydı yazılır", async () => { + const res = await updateHouseholdSettings(id("ayse"), cakir, settings); + expect(res.ok).toBe(true); + const h = await HouseholdModel.findById(cakir).lean(); + expect(h?.settings?.budgetMonthStartDay).toBe(15); + const audit = await AuditLogModel.findOne({ + action: "household.settings.update", + householdId: cakir, + }).lean(); + expect(audit?.actorId.toString()).toBe(id("ayse")); + expect((audit?.diff as Record).budgetMonthStartDay).toEqual({ + from: 1, + to: 15, + }); + }); +}); + +describe("üyelik değişiklikleri", () => { + it("üye ve izleyici rol değiştiremez, üye çıkaramaz", async () => { + expect((await setMemberRole(id("emre"), cakir, id("zeynep"), "owner")).ok).toBe(false); + expect((await removeMember(id("zeynep"), cakir, id("emre"))).ok).toBe(false); + }); + + it("bir hanenin sahibi başka hanedeki üyeliği etkileyemez", async () => { + // ayse Çakır'ın sahibi; fatma Çakır üyesi değil → işlem bulunamaz, Annemler'deki rolü değişmez. + expect((await setMemberRole(id("ayse"), cakir, id("fatma"), "viewer")).ok).toBe(false); + expect((await removeMember(id("ayse"), cakir, id("mehmet"))).ok).toBe(false); + // fatma Annemler'in sahibi; Çakır üzerinde yetkisi yok. + expect((await setMemberRole(id("fatma"), cakir, id("emre"), "viewer")).ok).toBe(false); + const fatma = await HouseholdMemberModel.findOne({ + householdId: annemler, + userId: id("fatma"), + }).lean(); + const mehmet = await HouseholdMemberModel.findOne({ + householdId: annemler, + userId: id("mehmet"), + }).lean(); + const emre = await HouseholdMemberModel.findOne({ + householdId: cakir, + userId: id("emre"), + }).lean(); + expect([fatma?.role, mehmet?.role, emre?.role]).toEqual(["owner", "member", "member"]); + }); + + it("son sahip düşürülemez; birden çok sahip varken düşürülebilir", async () => { + expect((await adminSetMemberRole(id("admin"), annemler, id("fatma"), "member")).ok).toBe(false); + expect((await setMemberRole(id("fatma"), annemler, id("fatma"), "member")).ok).toBe(false); + // Çakır'da iki sahip var (admin + ayse). + expect((await setMemberRole(id("ayse"), cakir, id("admin"), "member")).ok).toBe(true); + // Artık ayse son sahip. + expect((await setMemberRole(id("ayse"), cakir, id("ayse"), "member")).ok).toBe(false); + expect((await setMemberRole(id("ayse"), cakir, id("admin"), "owner")).ok).toBe(true); + expect( + await AuditLogModel.countDocuments({ action: "household.member.role", householdId: cakir }), + ).toBe(2); + }); + + it("sahip üyeyi haneden çıkarır; kullanıcı artık haneyi göremez", async () => { + expect((await removeMember(id("ayse"), cakir, id("ozan"))).ok).toBe(true); + expect(await resolveMembership(id("ozan"), cakir)).toBeNull(); + expect(await listMemberships(id("ozan"))).toEqual([]); + }); +}); + +describe("admin işlemleri", () => { + it("hane + ilk sahip + denetim kaydı tek transaction'da oluşur", async () => { + const res = await adminCreateHousehold(id("admin"), { + name: "Yazlık", + ownerUserId: id("burak"), + }); + expect(res.ok).toBe(true); + const hid = res.ok ? res.data.id : ""; + expect((await resolveMembership(id("burak"), hid, "owner"))?.name).toBe("Yazlık"); + expect( + await AuditLogModel.countDocuments({ action: "household.create", householdId: hid }), + ).toBe(1); + }); + + it("var olmayan kullanıcıyla hane oluşturulmaz", async () => { + const before = await HouseholdModel.countDocuments(); + const res = await adminCreateHousehold(id("admin"), { + name: "Hayalet", + ownerUserId: new Types.ObjectId().toHexString(), + }); + expect(res.ok).toBe(false); + expect(await HouseholdModel.countDocuments()).toBe(before); + }); + + it("kullanıcı silinince tüm üyelikleri temizlenir", async () => { + expect(await removeAllMembershipsOfUser(id("mehmet"))).toBe(1); + expect(await listMemberships(id("mehmet"))).toEqual([]); + }); +}); diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index e76d8ee..c40a814 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -63,6 +63,87 @@ export const tr = { label: "Aktif hane", select: "Hane seçin", roles: { owner: "Sahip", member: "Üye", viewer: "İzleyici" }, + roleHints: { + owner: "Hane ayarları, üyeler ve hesaplar", + member: "İşlem girer, liste ve stok günceller", + viewer: "Yalnızca görüntüler", + }, + switched: (name: string) => `Aktif hane: ${name}`, + }, + households: { + lastOwner: "Hanenin son sahibi düşürülemez veya çıkarılamaz.", + noHousehold: { + title: "Henüz bir haneye üye değilsiniz", + description: + "Bütçe ve diğer verileri görmek için bir haneye üye olmanız gerekir. Yöneticiden sizi bir haneye eklemesini isteyin.", + adminHint: + "Yönetici olarak Yönetim › Haneler sayfasından hane oluşturabilir ve üye ekleyebilirsiniz.", + adminLink: "Hanelere git", + }, + viewerReadOnly: "İzleyici rolündesiniz; bu hanede değişiklik yapamazsınız.", + }, + settings: { + title: "Ayarlar", + description: "Aktif hanenin ayarları ve üyeleri.", + household: "Hane", + householdDescription: "Bütçe ve planlama tercihleri tüm hane için geçerlidir.", + ownerOnly: "Bu ayarları yalnızca hane sahipleri değiştirebilir.", + fields: { + name: "Hane adı", + budgetMonthStartDay: "Bütçe ayı başlangıç günü", + budgetMonthStartDayHint: + "1 = takvim ayı. Örneğin 15 seçilirse bütçe ayı her ayın 15. günü başlar.", + installmentBudgetMode: "Taksitlerin bütçeye etkisi", + installmentModes: { + per_installment: "Her taksit kendi ayına", + purchase_month: "Tamamı harcama ayına", + }, + dailyPlanMode: "Günlük plan dağıtımı", + dailyPlanModes: { + even: "Değişken bütçe günlere eşit", + previous_month_pattern: "Önceki ayın harcama örüntüsüne göre", + }, + includePrivateAccountsInTotals: "“Yalnızca ben” hesapları hane toplamlarına dahil edilsin", + extraStoreSavingThreshold: "Ek mağaza tasarruf eşiği (TL)", + extraStoreSavingThresholdHint: + "Alışveriş planlayıcı, ek bir mağazayı yalnızca tasarruf bu tutarı aşarsa önerir.", + }, + saved: "Hane ayarları kaydedildi.", + members: "Üyeler", + membersDescription: "Hane içi roller. Yeni üyeyi yönetici ekler.", + memberActions: (name: string) => `${name} — işlemler`, + changeRole: "Rolü değiştir", + removeMember: "Haneden çıkar", + removeTitle: "Üye haneden çıkarılsın mı?", + removeDescription: (name: string, household: string) => + `${name}, “${household}” hanesine artık erişemeyecek. Kullanıcı hesabı silinmez.`, + removeConfirm: "Haneden çıkar", + roleChanged: (name: string) => `${name} için rol güncellendi.`, + memberRemoved: (name: string) => `${name} haneden çıkarıldı.`, + banned: "Askıda", + }, + adminHouseholds: { + title: "Haneler", + description: "Hane oluşturma, kullanıcıları haneye atama ve hane içi roller.", + newHousehold: "Yeni hane", + createTitle: "Yeni hane", + createDescription: "Hane oluşturulur ve seçilen kullanıcı ilk sahibi olur.", + name: "Hane adı", + owner: "İlk sahip", + selectUser: "Kullanıcı seçin", + created: (name: string) => `${name} hanesi oluşturuldu.`, + members: (n: number) => `${n} üye`, + owners: "Sahipler", + noOwner: "Sahibi yok", + addMember: "Üye ekle", + addMemberTitle: (household: string) => `${household} — üye ekle`, + addMemberDescription: "Kullanıcı zaten üyeyse rolü güncellenir.", + user: "Kullanıcı", + role: "Hane içi rol", + memberAdded: (name: string) => `${name} haneye eklendi.`, + empty: "Henüz hane yok.", + privacyNote: + "Yöneticiler haneleri ve üyelikleri yönetir; üyesi olmadıkları hanenin finansal verisini göremez.", }, userMenu: { open: "Kullanıcı menüsü", @@ -196,6 +277,10 @@ export const tr = { passwordMin: "Şifre en az 8 karakter olmalı", passwordMax: "Şifre en fazla 128 karakter olabilir", banReasonMax: "Neden en fazla 200 karakter olabilir", + householdNameMin: "Hane adı en az 2 karakter olmalı", + householdNameMax: "Hane adı en fazla 80 karakter olabilir", + selectUser: "Bir kullanıcı seçin", + wholeNumber: "Tam sayı girin", }, errors: { notFoundTitle: "Sayfa bulunamadı", From b79951601cf96df84356d2f1e480fd64d894a0b5 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 02:35:01 +0300 Subject: [PATCH 3/4] =?UTF-8?q?feat(db):=20migration=20mekanizmas=C4=B1=20?= =?UTF-8?q?ve=20ilk=20index=20migration'=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- package.json | 5 ++-- scripts/migrate.ts | 17 +++++++++++ scripts/migrations/.gitkeep | 0 scripts/migrations/001-initial-indexes.ts | 15 ++++++++++ scripts/migrations/index.ts | 35 +++++++++++++++++++++++ scripts/migrations/types.ts | 8 ++++++ scripts/seed/run.ts | 8 +++--- 7 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 scripts/migrate.ts delete mode 100644 scripts/migrations/.gitkeep create mode 100644 scripts/migrations/001-initial-indexes.ts create mode 100644 scripts/migrations/index.ts create mode 100644 scripts/migrations/types.ts diff --git a/package.json b/package.json index e229238..11ad446 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.4.0", + "version": "0.5.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": { @@ -21,7 +21,8 @@ "seed": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts", "seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset", "icons": "tsx scripts/generate-icons.ts", - "test:integration": "vitest run --config vitest.integration.config.mts" + "test:integration": "vitest run --config vitest.integration.config.mts", + "migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts" }, "dependencies": { "@hookform/resolvers": "^7.0.2", diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 0000000..0edf58c --- /dev/null +++ b/scripts/migrate.ts @@ -0,0 +1,17 @@ +/** + * Migration'ları uygular: npm run migrate (canlıda deploy sonrası; bkz. docs/DEPLOY.md, Faz 15). + * `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır. + */ +import mongoose from "mongoose"; +import { getMongoClient } from "@/lib/db/mongo"; +import { runMigrations } from "./migrations"; + +runMigrations() + .catch((err: unknown) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }) + .finally(async () => { + await mongoose.disconnect(); + await getMongoClient().close(); + }); diff --git a/scripts/migrations/.gitkeep b/scripts/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/migrations/001-initial-indexes.ts b/scripts/migrations/001-initial-indexes.ts new file mode 100644 index 0000000..60c6dec --- /dev/null +++ b/scripts/migrations/001-initial-indexes.ts @@ -0,0 +1,15 @@ +import { AuditLogModel } from "@/models/audit-log"; +import { HouseholdModel } from "@/models/household"; +import { HouseholdMemberModel } from "@/models/household-member"; +import type { Migration } from "./types"; + +/** Hane, üyelik ve denetim kaydı index'leri (Mongoose şemalarındaki tanımlarla). */ +export const migration: Migration = { + name: "001-initial-indexes", + description: "households, householdMembers ve auditLogs index'leri", + async up() { + await HouseholdModel.createIndexes(); + await HouseholdMemberModel.createIndexes(); + await AuditLogModel.createIndexes(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts new file mode 100644 index 0000000..9c76e86 --- /dev/null +++ b/scripts/migrations/index.ts @@ -0,0 +1,35 @@ +import { getDb } from "@/lib/db/mongo"; +import { connectMongoose } from "@/lib/db/mongoose"; +import { migration as m001 } from "./001-initial-indexes"; +import type { Migration } from "./types"; + +/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ +export const MIGRATIONS: Migration[] = [m001]; + +const COLLECTION = "migrations"; + +/** + * Uygulanmamış migration'ları sırayla çalıştırır; her biri bir kez (`migrations` koleksiyonu). + * Aynı anda iki çalıştırma olursa benzersiz `name` index'i ikinci kaydı reddeder. + */ +export async function runMigrations(log: (line: string) => void = console.log): Promise { + const db = getDb(); + await connectMongoose(); + const col = db.collection<{ name: string; appliedAt: Date; durationMs: number }>(COLLECTION); + await col.createIndex({ name: 1 }, { unique: true }); + const applied = new Set( + (await col.find({}, { projection: { name: 1 } }).toArray()).map((d) => d.name), + ); + + const ran: string[] = []; + for (const m of MIGRATIONS) { + if (applied.has(m.name)) continue; + const started = Date.now(); + await m.up(db); + await col.insertOne({ name: m.name, appliedAt: new Date(), durationMs: Date.now() - started }); + ran.push(m.name); + log(`✓ Migration: ${m.name} — ${m.description}`); + } + if (!ran.length) log("• Bekleyen migration yok."); + return ran; +} diff --git a/scripts/migrations/types.ts b/scripts/migrations/types.ts new file mode 100644 index 0000000..595e5e8 --- /dev/null +++ b/scripts/migrations/types.ts @@ -0,0 +1,8 @@ +import type { Db } from "mongodb"; + +/** Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). Geri alma yok; ileri yönlü. */ +export type Migration = { + name: string; + description: string; + up: (db: Db) => Promise; +}; diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index 815964c..da38c25 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -6,6 +6,7 @@ import { env } from "@/lib/env"; import { HouseholdModel } from "@/models/household"; import { HouseholdMemberModel } from "@/models/household-member"; import { storedEmail } from "@/shared/account-email"; +import { runMigrations } from "../migrations"; import { ADMIN_USERNAME, SEED_HOUSEHOLDS, SEED_USERS, type SeedUser } from "./fixtures"; /** @@ -30,7 +31,7 @@ export type SeedSummary = { }; const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"]; -const DOMAIN_COLLECTIONS = ["households", "householdMembers"]; +const DOMAIN_COLLECTIONS = ["households", "householdMembers", "auditLogs"]; type AuthApi = ReturnType["api"]; @@ -83,9 +84,8 @@ export async function runSeed({ log("• Kimlik doğrulama ve hane koleksiyonları temizlendi."); } - // Index'ler (production'da Mongoose autoIndex kapalı olduğu için açıkça oluşturulur). - await HouseholdModel.createIndexes(); - await HouseholdMemberModel.createIndexes(); + // Index'ler ve şema değişiklikleri (production'da Mongoose autoIndex kapalı). + await runMigrations(log); // 1) Admin const adminEmail = e.SEED_ADMIN_EMAIL.toLowerCase(); From be9f2a5c3acebc77a3eb3139ee6f524c1fd401fd Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 02:35:01 +0300 Subject: [PATCH 4/4] docs: CHANGELOG, README ve PROMPT (0.5.0) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 29 ++++++++++++++++++++++++++++- README.md | 6 ++++++ docs/PROMPT.md | 1 + 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dadf941..79f5c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +## [0.5.0] - 2026-11-05 + +Faz 5: Haneler, hane içi roller ve veri izolasyonu. + +### Eklendi + +- Hane içi roller (`viewer < member < owner`) ve kuralları: son sahip düşürülemez veya çıkarılamaz. +- DAL: `getHouseholdContext` (istek başına tek okuma), `requireActiveHousehold(minRole)`, `getHouseholdActionContext(minRole)`, `requireHouseholdMember(householdId, minRole)`. Sistem admini için hane verisinde ayrıcalık yok. +- Hane servisi (`features/households/service.ts`, framework'ten bağımsız): her hane kapsamlı fonksiyon üyeliği kendisi de doğrular; hane oluşturma + ilk sahip + denetim kaydı tek MongoDB transaction'ında. +- Aktif hane seçici (üst bar): seçim sunucuda doğrulanır, `butcem.hh` cookie'sine yazılır; geçersiz/uydurma cookie yok sayılır. Tercih yoksa en yüksek rolün olduğu hane açılır. +- `/ayarlar`: hane adı, bütçe ayı başlangıç günü, taksit modu, günlük plan dağıtımı, "yalnızca ben" hesaplarının toplamlara dahil edilmesi, ek mağaza tasarruf eşiği; üye rolleri ve üye çıkarma (yalnızca sahip; diğer roller salt okunur). +- `/admin/haneler`: hane oluşturma (ilk sahip seçerek), kullanıcıyı haneye ekleme, hane içi rol değiştirme, üye çıkarma. +- `/hane-yok`: hiçbir haneye üye olmayan kullanıcılar için yönlendirme sayfası. +- Denetim kaydı (`auditLogs`): hane ayarları, üye ekleme/rol/çıkarma, hane oluşturma ve tüm admin kullanıcı işlemleri (oluşturma, düzenleme, rol, şifre sıfırlama, askı, silme). +- Migration mekanizması: `npm run migrate`, `scripts/migrations/NNN-*.ts`, uygulananlar `migrations` koleksiyonunda; `001-initial-indexes`. Seed önce migration'ları çalıştırır. +- Veri izolasyonu entegrasyon testleri (başka hanenin üyeliği/ayrıntısı/ayarları, rol yetersizliği, uydurma ve enjeksiyon denemesi kimlikler, hanesi olmayan admin, çapraz hane üyelik değişikliği, son sahip, transaction). + +### Değişti + +- İzleyici (viewer) rolünde Hızlı ekle gizlenir/pasifleşir. +- Kullanıcı silindiğinde tüm hane üyelikleri de temizlenir. + +### Kaldırıldı + +- Hane mock verisi. + ## [0.4.0] - 2026-11-05 Faz 4: Örnek test verisi. @@ -94,7 +120,8 @@ Faz 1: Framework, klasör yapısı ve altyapı. - `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri. - `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`. -[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...HEAD +[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.5.0...HEAD +[0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0 [0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0 [0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0 [0.2.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...v0.2.0 diff --git a/README.md b/README.md index 6653513..35a9630 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kull | `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur | | `npm run seed` / `seed:reset` | Örnek veri (Faz 4) | +## Haneler ve yetki + +- Tüm alan verisi bir **haneye** (`householdId`) aittir; kullanıcı yalnızca üyesi olduğu hanelerin verisini görür. Sistem admini de bu kurala tabidir. +- Hane içi roller: **sahip** (ayarlar, üyeler), **üye** (veri girer), **izleyici** (yalnızca görüntüler). +- Her sayfa, Server Action ve API, hane yetkisini `src/lib/dal.ts` üzerinden sunucuda doğrular; servisler de üyeliği ayrıca kontrol eder. + ## Marka dosyaları `public/brand/`: `logo.svg` (açık tema), `logo-dark.svg` (koyu tema), `icon.svg` (uygulama ikonu), `icon-maskable.svg` (Android/iOS, köşesiz). Yazı Noto Sans Bold (OFL) harf yollarıdır. İkonları değiştirdikten sonra `npm run icons` çalıştırın; favicon ve PWA ikonları bu kaynaklardan üretilir. diff --git a/docs/PROMPT.md b/docs/PROMPT.md index 5269766..91620a8 100644 --- a/docs/PROMPT.md +++ b/docs/PROMPT.md @@ -317,6 +317,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em - **Değerleme:** varlıklar ve aynî alacaklar **alış** fiyatıyla, aynî borçlar **satış** fiyatıyla değerlenir. Tek fiyat girilirse ikisine de yazılır. - **URL'ler:** ekran yolları Türkçe ASCII (`/islemler`, `/butce`); kod, koleksiyon adları ve `/api/v1` İngilizce. - **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı). +- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır. - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. ---