import Database from "better-sqlite3"; import path from "path"; import type { SiteContent, TeamMember, RichListItem, VictoryItem } from "@/types/content"; const DB_PATH = process.env.DATABASE_PATH || path.join(process.cwd(), "db", "blackheart.db"); let _db: Database.Database | null = null; function getDb(): Database.Database { if (!_db) { _db = new Database(DB_PATH); _db.pragma("journal_mode = WAL"); _db.pragma("foreign_keys = ON"); runMigrations(_db); } return _db; } // --- Migrations --- // Each migration has a unique version number and runs exactly once. // Add new migrations to the end of the array. Never modify existing ones. interface Migration { version: number; name: string; up: (db: Database.Database) => void; } const migrations: Migration[] = [ { version: 1, name: "create_sections_and_team_members", up: (db) => { db.exec(` CREATE TABLE IF NOT EXISTS sections ( key TEXT PRIMARY KEY, data TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS team_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, role TEXT NOT NULL, image TEXT NOT NULL, instagram TEXT, description TEXT, sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); `); }, }, { version: 2, name: "add_team_bio_columns", up: (db) => { const cols = db.prepare("PRAGMA table_info(team_members)").all() as { name: string }[]; const colNames = new Set(cols.map((c) => c.name)); if (!colNames.has("experience")) db.exec("ALTER TABLE team_members ADD COLUMN experience TEXT"); if (!colNames.has("victories")) db.exec("ALTER TABLE team_members ADD COLUMN victories TEXT"); if (!colNames.has("education")) db.exec("ALTER TABLE team_members ADD COLUMN education TEXT"); }, }, { version: 3, name: "create_mc_registrations", up: (db) => { db.exec(` CREATE TABLE IF NOT EXISTS mc_registrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, master_class_title TEXT NOT NULL, name TEXT NOT NULL, instagram TEXT NOT NULL, telegram TEXT, created_at TEXT DEFAULT (datetime('now')) ); `); }, }, ]; function runMigrations(db: Database.Database) { // Create migrations tracking table db.exec(` CREATE TABLE IF NOT EXISTS _migrations ( version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT DEFAULT (datetime('now')) ); `); const applied = new Set( (db.prepare("SELECT version FROM _migrations").all() as { version: number }[]) .map((r) => r.version) ); const pending = migrations.filter((m) => !applied.has(m.version)); if (pending.length === 0) return; const insertMigration = db.prepare( "INSERT INTO _migrations (version, name) VALUES (?, ?)" ); const tx = db.transaction(() => { for (const m of pending) { m.up(db); insertMigration.run(m.version, m.name); } }); tx(); } // --- Sections --- export function getSection(key: string): T | null { const db = getDb(); const row = db.prepare("SELECT data FROM sections WHERE key = ?").get(key) as | { data: string } | undefined; return row ? (JSON.parse(row.data) as T) : null; } export function setSection(key: string, data: unknown): void { const db = getDb(); db.prepare( `INSERT INTO sections (key, data, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at` ).run(key, JSON.stringify(data)); } // --- Team Members --- interface TeamMemberRow { id: number; name: string; role: string; image: string; instagram: string | null; description: string | null; experience: string | null; victories: string | null; education: string | null; sort_order: number; } function parseJsonArray(val: string | null): string[] | undefined { if (!val) return undefined; try { const arr = JSON.parse(val); return Array.isArray(arr) && arr.length > 0 ? arr : undefined; } catch { return undefined; } } function parseRichList(val: string | null): RichListItem[] | undefined { if (!val) return undefined; try { const arr = JSON.parse(val); if (!Array.isArray(arr) || arr.length === 0) return undefined; // Handle both old string[] and new RichListItem[] formats return arr.map((item: string | RichListItem) => typeof item === "string" ? { text: item } : item ); } catch { return undefined; } } function parseVictories(val: string | null): VictoryItem[] | undefined { if (!val) return undefined; try { const arr = JSON.parse(val); if (!Array.isArray(arr) || arr.length === 0) return undefined; // Handle old string[], old RichListItem[], and new VictoryItem[] formats return arr.map((item: string | Record) => { if (typeof item === "string") return { place: "", category: "", competition: item }; if ("text" in item && !("competition" in item)) return { place: "", category: "", competition: item.text as string, image: item.image as string | undefined, link: item.link as string | undefined }; return item as unknown as VictoryItem; }); } catch { return undefined; } } export function getTeamMembers(): (TeamMember & { id: number })[] { const db = getDb(); const rows = db .prepare("SELECT * FROM team_members ORDER BY sort_order ASC, id ASC") .all() as TeamMemberRow[]; return rows.map((r) => ({ id: r.id, name: r.name, role: r.role, image: r.image, instagram: r.instagram ?? undefined, description: r.description ?? undefined, experience: parseJsonArray(r.experience), victories: parseVictories(r.victories), education: parseRichList(r.education), })); } export function getTeamMember( id: number ): (TeamMember & { id: number }) | null { const db = getDb(); const r = db .prepare("SELECT * FROM team_members WHERE id = ?") .get(id) as TeamMemberRow | undefined; if (!r) return null; return { id: r.id, name: r.name, role: r.role, image: r.image, instagram: r.instagram ?? undefined, description: r.description ?? undefined, experience: parseJsonArray(r.experience), victories: parseVictories(r.victories), education: parseRichList(r.education), }; } export function createTeamMember( data: Omit ): number { const db = getDb(); const maxOrder = db .prepare("SELECT COALESCE(MAX(sort_order), -1) as max FROM team_members") .get() as { max: number }; const result = db .prepare( `INSERT INTO team_members (name, role, image, instagram, description, experience, victories, education, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( data.name, data.role, data.image, data.instagram ?? null, data.description ?? null, data.experience?.length ? JSON.stringify(data.experience) : null, data.victories?.length ? JSON.stringify(data.victories) : null, data.education?.length ? JSON.stringify(data.education) : null, maxOrder.max + 1 ); return result.lastInsertRowid as number; } export function updateTeamMember( id: number, data: Partial> ): void { const db = getDb(); const fields: string[] = []; const values: unknown[] = []; if (data.name !== undefined) { fields.push("name = ?"); values.push(data.name); } if (data.role !== undefined) { fields.push("role = ?"); values.push(data.role); } if (data.image !== undefined) { fields.push("image = ?"); values.push(data.image); } if (data.instagram !== undefined) { fields.push("instagram = ?"); values.push(data.instagram || null); } if (data.description !== undefined) { fields.push("description = ?"); values.push(data.description || null); } if (data.experience !== undefined) { fields.push("experience = ?"); values.push(data.experience?.length ? JSON.stringify(data.experience) : null); } if (data.victories !== undefined) { fields.push("victories = ?"); values.push(data.victories?.length ? JSON.stringify(data.victories) : null); } if (data.education !== undefined) { fields.push("education = ?"); values.push(data.education?.length ? JSON.stringify(data.education) : null); } if (fields.length === 0) return; fields.push("updated_at = datetime('now')"); values.push(id); db.prepare(`UPDATE team_members SET ${fields.join(", ")} WHERE id = ?`).run( ...values ); } export function deleteTeamMember(id: number): void { const db = getDb(); db.prepare("DELETE FROM team_members WHERE id = ?").run(id); } export function reorderTeamMembers(ids: number[]): void { const db = getDb(); const stmt = db.prepare( "UPDATE team_members SET sort_order = ? WHERE id = ?" ); const tx = db.transaction(() => { ids.forEach((id, index) => stmt.run(index, id)); }); tx(); } // --- Full site content --- const SECTION_KEYS = [ "meta", "hero", "about", "classes", "masterClasses", "faq", "pricing", "schedule", "news", "contact", ] as const; export function getSiteContent(): SiteContent | null { const db = getDb(); const rows = db.prepare("SELECT key, data FROM sections").all() as { key: string; data: string; }[]; if (rows.length === 0) return null; const sections: Record = {}; for (const row of rows) { sections[row.key] = JSON.parse(row.data); } // Merge team members from dedicated table const members = getTeamMembers(); const teamSection = (sections.team as { title?: string }) || {}; return { meta: sections.meta, hero: sections.hero, about: sections.about, classes: sections.classes, masterClasses: sections.masterClasses ?? { title: "Мастер-классы", items: [] }, faq: sections.faq, pricing: sections.pricing, schedule: sections.schedule, news: sections.news ?? { title: "Новости", items: [] }, contact: sections.contact, team: { title: teamSection.title || "", members: members.map(({ id, ...rest }) => rest), }, } as SiteContent; } export function isDatabaseSeeded(): boolean { const db = getDb(); const row = db .prepare("SELECT COUNT(*) as count FROM sections") .get() as { count: number }; return row.count > 0; } // --- MC Registrations --- interface McRegistrationRow { id: number; master_class_title: string; name: string; instagram: string; telegram: string | null; created_at: string; } export interface McRegistration { id: number; masterClassTitle: string; name: string; instagram: string; telegram?: string; createdAt: string; } export function addMcRegistration( masterClassTitle: string, name: string, instagram: string, telegram?: string ): number { const db = getDb(); const result = db .prepare( `INSERT INTO mc_registrations (master_class_title, name, instagram, telegram) VALUES (?, ?, ?, ?)` ) .run(masterClassTitle, name, instagram, telegram || null); return result.lastInsertRowid as number; } export function getMcRegistrations(masterClassTitle: string): McRegistration[] { const db = getDb(); const rows = db .prepare( "SELECT * FROM mc_registrations WHERE master_class_title = ? ORDER BY created_at DESC" ) .all(masterClassTitle) as McRegistrationRow[]; return rows.map((r) => ({ id: r.id, masterClassTitle: r.master_class_title, name: r.name, instagram: r.instagram, telegram: r.telegram ?? undefined, createdAt: r.created_at, })); } export function updateMcRegistration( id: number, name: string, instagram: string, telegram?: string ): void { const db = getDb(); db.prepare( "UPDATE mc_registrations SET name = ?, instagram = ?, telegram = ? WHERE id = ?" ).run(name, instagram, telegram || null, id); } export function deleteMcRegistration(id: number): void { const db = getDb(); db.prepare("DELETE FROM mc_registrations WHERE id = ?").run(id); } export { SECTION_KEYS };