a274d29728
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
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 { getStorage } from "@/lib/storage";
|
||
import { installDefaultCategories } from "@/features/categories/service";
|
||
import { AccountModel } from "@/models/account";
|
||
import { HouseholdModel } from "@/models/household";
|
||
import { HouseholdMemberModel } from "@/models/household-member";
|
||
import { storedEmail } from "@/shared/account-email";
|
||
import { runMigrations } from "../migrations";
|
||
import { seedContacts } from "./contacts";
|
||
import { seedAssets } from "./assets";
|
||
import { seedBudgets } from "./budgets";
|
||
import { seedInKind } from "./in-kind";
|
||
import { seedInstallments } from "./installments";
|
||
import { seedRecurring } from "./recurring";
|
||
import { seedTransactions } from "./transactions";
|
||
import {
|
||
ADMIN_USERNAME,
|
||
SEED_ACCOUNTS,
|
||
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;
|
||
categories: number;
|
||
accounts: number;
|
||
transactions: number;
|
||
recurringRules: number;
|
||
installmentPlans: number;
|
||
contacts: number;
|
||
inKind: number;
|
||
assets: number;
|
||
budgets: number;
|
||
};
|
||
accounts: SeedAccountRow[];
|
||
};
|
||
|
||
const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"];
|
||
const DOMAIN_COLLECTIONS = [
|
||
"households",
|
||
"householdMembers",
|
||
"auditLogs",
|
||
"categories",
|
||
"accounts",
|
||
"transactions",
|
||
"payees",
|
||
"attachments",
|
||
"reconciliations",
|
||
"recurringRules",
|
||
"statements",
|
||
"contacts",
|
||
"assetPrices",
|
||
"inKindDebts",
|
||
"assets",
|
||
"assetMovements",
|
||
"budgets",
|
||
];
|
||
|
||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||
|
||
async function findUserIdByUsername(username: string): Promise<string | null> {
|
||
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<Headers> {
|
||
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<SeedSummary> {
|
||
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,
|
||
categories: 0,
|
||
accounts: 0,
|
||
transactions: 0,
|
||
recurringRules: 0,
|
||
installmentPlans: 0,
|
||
contacts: 0,
|
||
inKind: 0,
|
||
assets: 0,
|
||
budgets: 0,
|
||
},
|
||
accounts: [],
|
||
};
|
||
|
||
if (reset) {
|
||
// Ek dosyaların diskteki kopyaları da silinsin (yetim dosya kalmasın).
|
||
const storage = getStorage();
|
||
for await (const a of db
|
||
.collection("attachments")
|
||
.find({}, { projection: { storageKey: 1, thumbKey: 1 } })) {
|
||
await storage.delete(String(a.storageKey));
|
||
if (a.thumbKey) await storage.delete(String(a.thumbKey));
|
||
}
|
||
for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) {
|
||
await db.collection(name).deleteMany({});
|
||
}
|
||
log("• Kimlik doğrulama ve hane koleksiyonları temizlendi.");
|
||
}
|
||
|
||
// Index'ler ve şema değişiklikleri (production'da Mongoose autoIndex kapalı).
|
||
await runMigrations(log);
|
||
|
||
// 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<string, string>([[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<string, string[]>();
|
||
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) Varsayılan kategoriler ve örnek hesaplar
|
||
const householdIds = new Map<string, Types.ObjectId>();
|
||
for (const h of SEED_HOUSEHOLDS) {
|
||
const doc = await HouseholdModel.findOne({ name: h.name }).select({ _id: 1 }).lean();
|
||
householdIds.set(h.name, doc!._id);
|
||
summary.created.categories += await installDefaultCategories(doc!._id, actor);
|
||
}
|
||
const openingDate = new Date(Date.now() - 183 * 24 * 60 * 60 * 1000);
|
||
for (const [order, a] of SEED_ACCOUNTS.entries()) {
|
||
const householdId = householdIds.get(a.household)!;
|
||
if (await AccountModel.exists({ householdId, name: a.name })) continue;
|
||
const payer = a.creditCard
|
||
? await AccountModel.findOne({ householdId, name: a.creditCard.paymentAccount })
|
||
.select({ _id: 1 })
|
||
.lean()
|
||
: null;
|
||
await AccountModel.create({
|
||
householdId,
|
||
name: a.name,
|
||
type: a.type,
|
||
institution: a.institution ?? "",
|
||
mealCardProvider: a.mealCardProvider ?? null,
|
||
openingBalanceMinor: a.openingBalanceMinor,
|
||
openingDate,
|
||
color: a.color,
|
||
icon: a.icon,
|
||
order,
|
||
ownerUserId: a.owner ? new Types.ObjectId(ids.get(a.owner)!) : null,
|
||
isPrivate: a.isPrivate ?? false,
|
||
overdraft: a.overdraft ? { enabled: true, ...a.overdraft } : null,
|
||
creditCard: a.creditCard
|
||
? {
|
||
limitMinor: a.creditCard.limitMinor,
|
||
statementDay: a.creditCard.statementDay,
|
||
dueRule: { kind: "daysAfter", value: a.creditCard.dueDays },
|
||
paymentAccountId: payer?._id ?? null,
|
||
}
|
||
: null,
|
||
createdBy: actor,
|
||
updatedBy: actor,
|
||
});
|
||
summary.created.accounts++;
|
||
}
|
||
if (summary.created.categories) log(`✓ Varsayılan kategoriler: ${summary.created.categories}`);
|
||
if (summary.created.accounts) log(`✓ Örnek hesaplar: ${summary.created.accounts}`);
|
||
|
||
// 6) Son 6 ayın örnek işlemleri (faker.seed(42), idempotent)
|
||
const userIdMap = new Map([...ids.entries()]);
|
||
summary.created.transactions = await seedTransactions(householdIds, userIdMap);
|
||
if (summary.created.transactions) log(`✓ Örnek işlemler: ${summary.created.transactions}`);
|
||
summary.created.recurringRules = await seedRecurring(householdIds, userIdMap);
|
||
if (summary.created.recurringRules)
|
||
log(`✓ Örnek düzenli işlemler: ${summary.created.recurringRules}`);
|
||
summary.created.installmentPlans = await seedInstallments(
|
||
householdIds.get("Çakır Ailesi")!,
|
||
userIdMap,
|
||
);
|
||
if (summary.created.installmentPlans)
|
||
log(`✓ Örnek taksitli alışverişler: ${summary.created.installmentPlans}`);
|
||
summary.created.contacts = await seedContacts(householdIds, userIdMap);
|
||
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
|
||
summary.created.inKind = await seedInKind(householdIds, userIdMap);
|
||
if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`);
|
||
summary.created.assets = await seedAssets(householdIds, userIdMap);
|
||
if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`);
|
||
summary.created.budgets = await seedBudgets(householdIds, userIdMap);
|
||
if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`);
|
||
|
||
// 7) 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();
|
||
}
|