diff --git a/.env.example b/.env.example index 4b5488b..88fc94f 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,8 @@ BETTER_AUTH_URL="http://localhost:3000" # Faz 4 seed'i için ilk admin hesabı SEED_ADMIN_EMAIL="admin@butcem.local" SEED_ADMIN_PASSWORD="" +# Örnek aile kullanıcılarının ortak şifresi (yalnızca geliştirme; production'da örnek kullanıcı oluşturulmaz) +SEED_USER_PASSWORD="" # Ek dosyalar (canlıda Docker volume: /app/uploads) UPLOAD_DIR="./uploads" diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f1b47..dadf941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +## [0.4.0] - 2026-11-05 + +Faz 4: Örnek test verisi. + +### Eklendi + +- Seed (`npm run seed`): admin + 7 örnek kullanıcı (biri askıda, biri hanesi olmayan ikinci admin, ikisi e-postasız ve kullanıcı adıyla giriş yapan), 2 hane ("Çakır Ailesi", "Annemler") ve hane içi roller (owner / member / viewer; bir kullanıcı iki hanede farklı rollerle). +- Seed idempotenttir (kullanıcı adı, hane adı ve üyelik çiftine göre); kullanıcılar Better Auth API'siyle oluşturulur. Geliştirme hesapları konsolda tablo olarak gösterilir. +- `npm run seed:reset`: kimlik doğrulama ve hane koleksiyonlarını temizleyip yeniden doldurur; production'da reddedilir. Production'da seed yalnızca admini oluşturur. +- Mongoose modelleri: `households` (ayarlar: bütçe ayı başlangıcı, taksit ve günlük plan tercihleri, özel hesaplar, ek mağaza eşiği) ve `householdMembers` (benzersiz `{householdId, userId}` index'i). +- `SEED_USER_PASSWORD` ortam değişkeni (örnek kullanıcıların ortak geliştirme şifresi). +- Seed entegrasyon testleri (idempotentlik, roller, askı, e-postasız giriş, reset). + ## [0.3.0] - 2026-11-05 Faz 3: Backend — kimlik doğrulama, yetki katmanı ve güvenlik. @@ -81,7 +94,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.3.0...HEAD +[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...HEAD +[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 [0.1.0]: https://gitea.ta1sph.info/suphicakir/butcem/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 7f8b5dd..6653513 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,28 @@ cp .env.example .env.local # 3) MongoDB (tek düğümlü replica set rs0, host portu 27019) — sağlıklı olana kadar bekler npm run db:up -# 4) İlk admin (SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD; kullanıcı adı: admin) +# 4) Örnek veri: admin + aile kullanıcıları + 2 hane (tekrar çalıştırmak güvenli) npm run seed # 5) Uygulama npm run dev # http://localhost:3000 → /login ``` +### Geliştirme hesapları (`npm run seed`) + +| Kullanıcı adı | Rol | Hane(ler) | Not | +| ------------- | ----- | --------------------------------------- | ------------------------------- | +| `admin` | admin | Çakır Ailesi (owner) | şifre `SEED_ADMIN_PASSWORD` | +| `ayse` | user | Çakır Ailesi (owner), Annemler (viewer) | | +| `emre` | user | Çakır Ailesi (member) | e-postasız | +| `zeynep` | user | Çakır Ailesi (viewer) | e-postasız | +| `fatma` | user | Annemler (owner) | | +| `mehmet` | user | Annemler (member) | | +| `burak` | admin | — | hanesi yok → hane verisi görmez | +| `ozan` | user | Çakır Ailesi (member) | askıda, giriş yapamaz | + +Admin dışındaki hesapların şifresi `SEED_USER_PASSWORD` (`.env.local`). Production'da yalnızca admin oluşturulur. + Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kullanıcıları admin `/admin/kullanicilar` sayfasından oluşturur. ## Scriptler diff --git a/package.json b/package.json index ad71ec8..e229238 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.3.0", + "version": "0.4.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": { diff --git a/scripts/seed.ts b/scripts/seed.ts index 38aa3e8..901e8c0 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -1,62 +1,25 @@ /** - * Seed betiği: npm run seed | Sıfırlayıp doldurma: npm run seed:reset + * Seed: npm run seed | Sıfırlayıp doldurma: npm run seed:reset (production'da reddedilir) * - * Faz 3: yalnızca ilk admin (SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD, kullanıcı adı "admin"). - * Faz 4: örnek kullanıcılar, hane ve aile üyeleri eklenecek. - * - * İdempotent: kullanıcı e-postasına göre kontrol eder, tekrar çalıştırınca kopya oluşturmaz. - * Kullanıcılar Better Auth API'siyle oluşturulur (şifre doğru hashlenir; koleksiyona elle yazılmaz). + * Oluşturulanlar: admin (SEED_ADMIN_*), 7 örnek kullanıcı (biri askıda, biri ikinci admin), + * 2 hane ve hane içi rolleri. Ayrıntı: scripts/seed/fixtures.ts * `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır. */ -import { getAuth } from "@/lib/auth"; -import { getDb, getMongoClient } from "@/lib/db/mongo"; +import { getMongoClient } from "@/lib/db/mongo"; import { env } from "@/lib/env"; - -const reset = process.argv.includes("--reset"); +import { closeSeedConnections, runSeed } from "./seed/run"; async function main() { - const e = env(); - if (reset && e.NODE_ENV === "production") { - throw new Error("seed:reset production ortamında çalıştırılamaz."); - } - if (!e.SEED_ADMIN_EMAIL || !e.SEED_ADMIN_PASSWORD) { - throw new Error("SEED_ADMIN_EMAIL ve SEED_ADMIN_PASSWORD tanımlı olmalı (.env.local)."); - } + const summary = await runSeed({ reset: process.argv.includes("--reset") }); + const { users, households, memberships } = summary.created; + console.log(`\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik.`); - const db = getDb(); - if (reset) { - for (const name of ["user", "account", "session", "verification", "rateLimit"]) { - await db.collection(name).deleteMany({}); - } - console.log("• Kimlik doğrulama koleksiyonları temizlendi."); - } - - const email = e.SEED_ADMIN_EMAIL.toLowerCase(); - const existing = await db.collection("user").findOne({ email }); - if (existing) { - console.log(`• Admin zaten var: ${email}`); - } else { - await getAuth().api.createUser({ - body: { - email, - password: e.SEED_ADMIN_PASSWORD, - name: "Yönetici", - role: "admin", - data: { username: "admin" }, - }, - }); - console.log(`✓ Admin oluşturuldu: ${email} (kullanıcı adı: admin)`); - } - - if (e.NODE_ENV !== "production") { - console.table([ - { - rol: "admin", - kullaniciAdi: "admin", - eposta: email, - sifre: "SEED_ADMIN_PASSWORD (.env.local)", - }, - ]); + // Hesap tablosu yalnızca geliştirme ortamında gösterilir. + if (env().NODE_ENV !== "production" && summary.accounts.length) { + console.log( + "\nGeliştirme hesapları (şifreler: admin → SEED_ADMIN_PASSWORD, diğerleri → SEED_USER_PASSWORD):", + ); + console.table(summary.accounts); } } @@ -65,4 +28,7 @@ main() console.error(err instanceof Error ? err.message : err); process.exitCode = 1; }) - .finally(() => getMongoClient().close()); + .finally(async () => { + await closeSeedConnections(); + await getMongoClient().close(); + }); diff --git a/scripts/seed/fixtures.ts b/scripts/seed/fixtures.ts new file mode 100644 index 0000000..c46fa81 --- /dev/null +++ b/scripts/seed/fixtures.ts @@ -0,0 +1,58 @@ +import type { HouseholdRole } from "@/features/households/types"; +import type { SystemRole } from "@/shared/schemas/common"; + +/** Örnek (geliştirme) kullanıcıları. E-postası olmayanlar kullanıcı adıyla giriş yapar. */ +export type SeedUser = { + username: string; + name: string; + email?: string; + role: SystemRole; + banReason?: string; +}; + +export const SEED_USERS: SeedUser[] = [ + { username: "ayse", name: "Ayşe Çakır", email: "ayse@butcem.local", role: "user" }, + { username: "emre", name: "Emre Çakır", role: "user" }, + { username: "zeynep", name: "Zeynep Çakır", role: "user" }, + { username: "fatma", name: "Fatma Demir", email: "fatma@butcem.local", role: "user" }, + { username: "mehmet", name: "Mehmet Demir", email: "mehmet@butcem.local", role: "user" }, + // İkinci admin: hiçbir haneye üye değil → hane verisini göremez (Faz 0 kararı). + { username: "burak", name: "Burak Öztürk", email: "burak@butcem.local", role: "admin" }, + { + username: "ozan", + name: "Ozan Kaya", + email: "ozan@butcem.local", + role: "user", + banReason: "Ev arkadaşlığı sona erdi; erişim kaldırıldı.", + }, +]; + +/** Seed admin'in kullanıcı adı (SEED_ADMIN_EMAIL ile oluşturulur). */ +export const ADMIN_USERNAME = "admin"; + +export type SeedHousehold = { + name: string; + members: { username: string; role: HouseholdRole }[]; +}; + +export const SEED_HOUSEHOLDS: SeedHousehold[] = [ + { + name: "Çakır Ailesi", + members: [ + { username: ADMIN_USERNAME, role: "owner" }, + { username: "ayse", role: "owner" }, + { username: "emre", role: "member" }, + { username: "zeynep", role: "viewer" }, + { username: "ozan", role: "member" }, + ], + }, + { + name: "Annemler", + members: [ + { username: "fatma", role: "owner" }, + { username: "mehmet", role: "member" }, + // Bir kullanıcı birden çok haneye üye olabilir. + { username: "ayse", role: "viewer" }, + ], + }, +]; diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts new file mode 100644 index 0000000..187daf5 --- /dev/null +++ b/scripts/seed/run.int.test.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getAuth } from "@/lib/auth"; +import { authErrorCode } from "@/lib/auth-errors"; +import { getDb, getMongoClient } from "@/lib/db/mongo"; +import { HouseholdModel } from "@/models/household"; +import { HouseholdMemberModel } from "@/models/household-member"; +import { closeSeedConnections, runSeed } from "./run"; +import { SEED_HOUSEHOLDS, SEED_USERS } from "./fixtures"; + +const silent = () => {}; + +beforeAll(async () => { + expect(getDb().databaseName).toBe("butcem_test"); + await getDb().dropDatabase(); +}); + +afterAll(async () => { + await getDb().dropDatabase(); + await closeSeedConnections(); + await getMongoClient().close(); +}); + +describe("seed", () => { + it("ilk çalıştırmada admin, örnek kullanıcılar, haneler ve üyelikleri oluşturur", async () => { + const summary = await runSeed({ log: silent }); + const memberCount = SEED_HOUSEHOLDS.reduce((n, h) => n + h.members.length, 0); + expect(summary.created).toEqual({ + users: SEED_USERS.length + 1, + households: SEED_HOUSEHOLDS.length, + memberships: memberCount, + }); + expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); + expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); + }); + + it("idempotenttir: ikinci çalıştırma kopya oluşturmaz", async () => { + const summary = await runSeed({ log: silent }); + expect(summary.created).toEqual({ users: 0, households: 0, memberships: 0 }); + expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); + expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); + }); + + it("roller ve askı durumu doğru", async () => { + const users = await getDb() + .collection("user") + .find({}, { projection: { username: 1, role: 1, banned: 1 } }) + .toArray(); + const by = new Map(users.map((u) => [u.username as string, u])); + expect(by.get("admin")?.role).toBe("admin"); + expect(by.get("burak")?.role).toBe("admin"); + expect(by.get("ozan")?.banned).toBe(true); + expect(users.filter((u) => u.banned)).toHaveLength(1); + + // Bir kullanıcı birden çok hanede, farklı rollerle. + const ayse = by.get("ayse")!; + const roles = await HouseholdMemberModel.find({ userId: ayse._id }).lean(); + expect(roles.map((m) => m.role).sort()).toEqual(["owner", "viewer"]); + + // Hanesi olmayan admin. + expect(await HouseholdMemberModel.countDocuments({ userId: by.get("burak")!._id })).toBe(0); + }); + + it("e-postası olmayan kullanıcı kullanıcı adıyla girer; askıdaki kullanıcı giremez", async () => { + const api = getAuth().api; + const emre = await api.signInUsername({ + body: { username: "emre", password: "Test-User-12345" }, + }); + expect(emre.user.name).toBe("Emre Çakır"); + + const error = await api + .signInUsername({ body: { username: "ozan", password: "Test-User-12345" } }) + .then( + () => null, + (e: unknown) => e, + ); + expect(authErrorCode(error)).toBe("BANNED_USER"); + }); + + it("reset koleksiyonları temizleyip yeniden doldurur", async () => { + const summary = await runSeed({ reset: true, log: silent }); + expect(summary.created.users).toBe(SEED_USERS.length + 1); + expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); + }); +}); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts new file mode 100644 index 0000000..815964c --- /dev/null +++ b/scripts/seed/run.ts @@ -0,0 +1,201 @@ +import mongoose, { Types } from "mongoose"; +import { getAuth } from "@/lib/auth"; +import { getDb } from "@/lib/db/mongo"; +import { connectMongoose } from "@/lib/db/mongoose"; +import { env } from "@/lib/env"; +import { HouseholdModel } from "@/models/household"; +import { HouseholdMemberModel } from "@/models/household-member"; +import { storedEmail } from "@/shared/account-email"; +import { ADMIN_USERNAME, SEED_HOUSEHOLDS, SEED_USERS, type SeedUser } from "./fixtures"; + +/** + * İdempotent seed. Kullanıcılar Better Auth API'siyle oluşturulur (şifreler doğru hashlenir); + * tekrar çalıştırınca kopya oluşturmaz (kullanıcı adı / hane adı / üyelik çiftine göre). + * Production'da yalnızca admin oluşturulur; örnek kullanıcı ve hane oluşturulmaz. + */ + +export type SeedOptions = { reset?: boolean; log?: (line: string) => void }; + +export type SeedAccountRow = { + rol: string; + kullaniciAdi: string; + eposta: string; + durum: string; + haneler: string; +}; + +export type SeedSummary = { + created: { users: number; households: number; memberships: number }; + accounts: SeedAccountRow[]; +}; + +const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"]; +const DOMAIN_COLLECTIONS = ["households", "householdMembers"]; + +type AuthApi = ReturnType["api"]; + +async function findUserIdByUsername(username: string): Promise { + const doc = await getDb() + .collection("user") + .findOne({ username }, { projection: { _id: 1 } }); + return doc ? String(doc._id) : null; +} + +/** Admin olarak giriş yapıp oturum başlıklarını döner (ban gibi oturum isteyen admin API'leri için). */ +async function adminHeaders(api: AuthApi, password: string): Promise { + const { headers } = await api.signInUsername({ + body: { username: ADMIN_USERNAME, password }, + returnHeaders: true, + }); + const cookie = headers + .getSetCookie() + .map((c) => c.split(";")[0]) + .join("; "); + return new Headers({ cookie }); +} + +export async function runSeed({ + reset = false, + log = console.log, +}: SeedOptions = {}): Promise { + const e = env(); + const isProd = e.NODE_ENV === "production"; + if (reset && isProd) throw new Error("seed:reset production ortamında çalıştırılamaz."); + if (!e.SEED_ADMIN_EMAIL || !e.SEED_ADMIN_PASSWORD) { + throw new Error("SEED_ADMIN_EMAIL ve SEED_ADMIN_PASSWORD tanımlı olmalı."); + } + if (!isProd && !e.SEED_USER_PASSWORD) { + throw new Error("SEED_USER_PASSWORD tanımlı olmalı (örnek kullanıcıların ortak şifresi)."); + } + + const api = getAuth().api; + const db = getDb(); + await connectMongoose(); + const summary: SeedSummary = { + created: { users: 0, households: 0, memberships: 0 }, + accounts: [], + }; + + if (reset) { + for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) { + await db.collection(name).deleteMany({}); + } + 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(); + + // 1) Admin + const adminEmail = e.SEED_ADMIN_EMAIL.toLowerCase(); + let adminId = await findUserIdByUsername(ADMIN_USERNAME); + if (!adminId) { + const res = await api.createUser({ + body: { + email: adminEmail, + password: e.SEED_ADMIN_PASSWORD, + name: "Yönetici", + role: "admin", + data: { username: ADMIN_USERNAME }, + }, + }); + adminId = res.user.id; + summary.created.users++; + log(`✓ Admin oluşturuldu: ${adminEmail} (kullanıcı adı: ${ADMIN_USERNAME})`); + } else { + log(`• Admin zaten var: ${ADMIN_USERNAME}`); + } + + if (isProd) { + log("• Production: örnek kullanıcı ve hane oluşturulmadı."); + return summary; + } + + // 2) Örnek kullanıcılar + const ids = new Map([[ADMIN_USERNAME, adminId]]); + const toBan: SeedUser[] = []; + for (const u of SEED_USERS) { + let id = await findUserIdByUsername(u.username); + if (!id) { + const res = await api.createUser({ + body: { + email: storedEmail(u.username, u.email), + password: e.SEED_USER_PASSWORD!, + name: u.name, + role: u.role, + data: { username: u.username }, + }, + }); + id = res.user.id; + summary.created.users++; + log(`✓ Kullanıcı: ${u.name} (@${u.username})`); + } + ids.set(u.username, id); + if (u.banReason) toBan.push(u); + } + + // 3) Askıdaki kullanıcı(lar) — admin API'si oturum ister. + const pendingBans: { user: SeedUser; id: string }[] = []; + for (const u of toBan) { + const id = ids.get(u.username)!; + const doc = await db + .collection("user") + .findOne({ _id: new Types.ObjectId(id) }, { projection: { banned: 1 } }); + if (!doc?.banned) pendingBans.push({ user: u, id }); + } + if (pendingBans.length) { + const headers = await adminHeaders(api, e.SEED_ADMIN_PASSWORD); + for (const { user, id } of pendingBans) { + await api.banUser({ headers, body: { userId: id, banReason: user.banReason } }); + log(`✓ Askıya alındı: @${user.username}`); + } + } + + // 4) Haneler ve üyelikler + const memberships = new Map(); + const actor = new Types.ObjectId(adminId); + for (const h of SEED_HOUSEHOLDS) { + let household = await HouseholdModel.findOne({ name: h.name }); + if (!household) { + household = await HouseholdModel.create({ name: h.name, createdBy: actor, updatedBy: actor }); + summary.created.households++; + log(`✓ Hane: ${h.name}`); + } + for (const m of h.members) { + const userId = new Types.ObjectId(ids.get(m.username)!); + const result = await HouseholdMemberModel.updateOne( + { householdId: household._id, userId }, + { + $setOnInsert: { householdId: household._id, userId, createdBy: actor }, + $set: { role: m.role, updatedBy: actor }, + }, + { upsert: true }, + ); + if (result.upsertedCount) summary.created.memberships++; + memberships.set(m.username, [ + ...(memberships.get(m.username) ?? []), + `${h.name} (${m.role})`, + ]); + } + } + + // 5) Geliştirme hesapları tablosu + const all = [ + { username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const }, + ...SEED_USERS, + ]; + summary.accounts = all.map((u) => ({ + rol: u.role, + kullaniciAdi: u.username, + eposta: u.email ?? "—", + durum: "banReason" in u && u.banReason ? "askıda" : "aktif", + haneler: (memberships.get(u.username) ?? ["—"]).join(", "), + })); + return summary; +} + +/** Seed sonrası bağlantıları kapatır (CLI ve testler). */ +export async function closeSeedConnections() { + await mongoose.disconnect(); +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 0ecadac..0bed312 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -19,6 +19,8 @@ export const serverEnvSchema = z.object({ BETTER_AUTH_URL: z.url("BETTER_AUTH_URL geçerli bir URL olmalı"), SEED_ADMIN_EMAIL: z.email().optional(), SEED_ADMIN_PASSWORD: z.string().min(8).optional(), + /** Örnek (geliştirme) kullanıcılarının ortak şifresi; production'da kullanılmaz. */ + SEED_USER_PASSWORD: z.string().min(8).optional(), UPLOAD_DIR: z.string().min(1).default("./uploads"), MAX_UPLOAD_MB: z.coerce.number().int().positive().max(100).default(10), APP_VERSION: z.string().optional(), diff --git a/src/models/.gitkeep b/src/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/models/household-member.ts b/src/models/household-member.ts new file mode 100644 index 0000000..9aea0cc --- /dev/null +++ b/src/models/household-member.ts @@ -0,0 +1,26 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { HOUSEHOLD_ROLES } from "@/features/households/types"; + +/** Kullanıcının bir hanedeki üyeliği ve hane içi rolü (owner | member | viewer). */ +const householdMemberSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, ref: "Household", required: true }, + /** Better Auth `user` koleksiyonundaki _id. */ + userId: { type: Schema.Types.ObjectId, required: true }, + role: { type: String, enum: HOUSEHOLD_ROLES, required: true }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "householdMembers" }, +); + +// Bir kullanıcı bir hanede yalnızca bir kez üye olabilir. +householdMemberSchema.index({ householdId: 1, userId: 1 }, { unique: true }); +// Kullanıcının hanelerini hızlı listelemek için. +householdMemberSchema.index({ userId: 1, householdId: 1 }); + +export type HouseholdMember = InferSchemaType; + +export const HouseholdMemberModel: Model = + (models.HouseholdMember as Model | undefined) ?? + model("HouseholdMember", householdMemberSchema); diff --git a/src/models/household.ts b/src/models/household.ts new file mode 100644 index 0000000..68d0878 --- /dev/null +++ b/src/models/household.ts @@ -0,0 +1,52 @@ +import { + model, + models, + Schema, + type HydratedDocument, + type InferSchemaType, + type Model, +} from "mongoose"; + +/** Hane ayarları (Faz 0 kararları: bütçe ayı başlangıcı, taksit ve günlük plan tercihleri). */ +const householdSettingsSchema = new Schema( + { + /** Bütçe ayının başlangıç günü (1 = takvim ayı). */ + budgetMonthStartDay: { type: Number, min: 1, max: 28, default: 1 }, + /** Taksitlerin bütçe etkisi: her taksit kendi ayına | tamamı harcama ayına. */ + installmentBudgetMode: { + type: String, + enum: ["per_installment", "purchase_month"], + default: "per_installment", + }, + /** Günlük plan: değişken bütçe eşit dağıtılır | önceki ayın örüntüsüne göre. */ + dailyPlanMode: { type: String, enum: ["even", "previous_month_pattern"], default: "even" }, + /** "Yalnızca ben" hesapları hane toplamlarına dahil edilsin mi? */ + includePrivateAccountsInTotals: { type: Boolean, default: true }, + /** Akıllı mağaza önerisi: ek mağaza ancak tasarruf bu tutarı aşarsa (kuruş). */ + extraStoreSavingThresholdMinor: { type: Number, min: 0, default: 10_000 }, + }, + { _id: false }, +); + +const householdSchema = new Schema( + { + name: { type: String, required: true, trim: true, minlength: 2, maxlength: 80 }, + currency: { type: String, enum: ["TRY"], default: "TRY" }, + timezone: { type: String, default: "Europe/Istanbul" }, + settings: { type: householdSettingsSchema, default: () => ({}) }, + archivedAt: { type: Date, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "households" }, +); + +householdSchema.index({ name: 1 }); + +export type Household = InferSchemaType; +export type HouseholdDoc = HydratedDocument; + +/** HMR'de modelin yeniden derlenmesini önler. */ +export const HouseholdModel: Model = + (models.Household as Model | undefined) ?? + model("Household", householdSchema); diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 6b388c5..6c1a0ee 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -23,7 +23,7 @@ export default defineConfig({ }, test: { environment: "node", - include: ["src/**/*.int.test.ts"], + include: ["src/**/*.int.test.ts", "scripts/**/*.int.test.ts"], fileParallelism: false, testTimeout: 20_000, hookTimeout: 30_000, @@ -32,6 +32,9 @@ export default defineConfig({ MONGODB_URI: baseUri, BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-0123", BETTER_AUTH_URL: "http://localhost:3000", + SEED_ADMIN_EMAIL: "admin@test.local", + SEED_ADMIN_PASSWORD: "Test-Admin-12345", + SEED_USER_PASSWORD: "Test-User-12345", }, }, });