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; export 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')) ); `); }, }, { version: 4, name: "add_mc_notification_tracking", up: (db) => { const cols = db.prepare("PRAGMA table_info(mc_registrations)").all() as { name: string }[]; const colNames = new Set(cols.map((c) => c.name)); if (!colNames.has("notified_confirm")) db.exec("ALTER TABLE mc_registrations ADD COLUMN notified_confirm INTEGER NOT NULL DEFAULT 0"); if (!colNames.has("notified_reminder")) db.exec("ALTER TABLE mc_registrations ADD COLUMN notified_reminder INTEGER NOT NULL DEFAULT 0"); }, }, { version: 5, name: "create_group_bookings", up: (db) => { db.exec(` CREATE TABLE IF NOT EXISTS group_bookings ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone TEXT NOT NULL, group_info TEXT, notified_confirm INTEGER NOT NULL DEFAULT 0, notified_reminder INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) ); `); }, }, { version: 6, name: "create_open_day_tables", up: (db) => { db.exec(` CREATE TABLE IF NOT EXISTS open_day_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, title TEXT NOT NULL DEFAULT 'День открытых дверей', description TEXT, price_per_class INTEGER NOT NULL DEFAULT 30, discount_price INTEGER NOT NULL DEFAULT 20, discount_threshold INTEGER NOT NULL DEFAULT 3, min_bookings INTEGER NOT NULL DEFAULT 4, active INTEGER NOT NULL DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS open_day_classes ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER NOT NULL REFERENCES open_day_events(id) ON DELETE CASCADE, hall TEXT NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, trainer TEXT NOT NULL, style TEXT NOT NULL, cancelled INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, UNIQUE(event_id, hall, start_time) ); CREATE TABLE IF NOT EXISTS open_day_bookings ( id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER NOT NULL REFERENCES open_day_classes(id) ON DELETE CASCADE, event_id INTEGER NOT NULL REFERENCES open_day_events(id) ON DELETE CASCADE, name TEXT NOT NULL, phone TEXT NOT NULL, instagram TEXT, telegram TEXT, notified_confirm INTEGER NOT NULL DEFAULT 0, notified_reminder INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), UNIQUE(class_id, phone) ); `); }, }, { version: 7, name: "unify_booking_fields", up: (db) => { // Add phone to mc_registrations const mcCols = db.prepare("PRAGMA table_info(mc_registrations)").all() as { name: string }[]; const mcColNames = new Set(mcCols.map((c) => c.name)); if (!mcColNames.has("phone")) db.exec("ALTER TABLE mc_registrations ADD COLUMN phone TEXT"); // Add instagram/telegram to group_bookings const gbCols = db.prepare("PRAGMA table_info(group_bookings)").all() as { name: string }[]; const gbColNames = new Set(gbCols.map((c) => c.name)); if (!gbColNames.has("instagram")) db.exec("ALTER TABLE group_bookings ADD COLUMN instagram TEXT"); if (!gbColNames.has("telegram")) db.exec("ALTER TABLE group_bookings ADD COLUMN telegram TEXT"); }, }, { version: 8, name: "add_reminder_status", up: (db) => { // reminder_status: null = not reminded, 'pending' = reminded but no answer, 'coming' = confirmed, 'cancelled' = not coming for (const table of ["mc_registrations", "group_bookings", "open_day_bookings"]) { const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; if (!cols.some((c) => c.name === "reminder_status")) { db.exec(`ALTER TABLE ${table} ADD COLUMN reminder_status TEXT`); } } }, }, { version: 9, name: "add_performance_indexes", up: (db) => { db.exec(` CREATE INDEX IF NOT EXISTS idx_mc_registrations_title ON mc_registrations(master_class_title); CREATE INDEX IF NOT EXISTS idx_mc_registrations_created ON mc_registrations(created_at DESC); CREATE INDEX IF NOT EXISTS idx_group_bookings_created ON group_bookings(created_at DESC); CREATE INDEX IF NOT EXISTS idx_open_day_bookings_event ON open_day_bookings(event_id); CREATE INDEX IF NOT EXISTS idx_open_day_bookings_class ON open_day_bookings(class_id); CREATE INDEX IF NOT EXISTS idx_open_day_bookings_phone ON open_day_bookings(event_id, phone); CREATE INDEX IF NOT EXISTS idx_open_day_bookings_class_phone ON open_day_bookings(class_id, phone); CREATE INDEX IF NOT EXISTS idx_open_day_classes_event ON open_day_classes(event_id); `); }, }, { version: 10, name: "add_group_booking_status", up: (db) => { const cols = db.prepare("PRAGMA table_info(group_bookings)").all() as { name: string }[]; if (!cols.some((c) => c.name === "status")) { db.exec("ALTER TABLE group_bookings ADD COLUMN status TEXT NOT NULL DEFAULT 'new'"); } if (!cols.some((c) => c.name === "confirmed_date")) { db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_date TEXT"); } if (!cols.some((c) => c.name === "confirmed_group")) { db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_group TEXT"); } if (!cols.some((c) => c.name === "confirmed_comment")) { db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_comment TEXT"); } }, }, { version: 11, name: "add_team_short_description", up: (db) => { const cols = db.prepare("PRAGMA table_info(team_members)").all() as { name: string }[]; if (!cols.some((c) => c.name === "short_description")) { db.exec("ALTER TABLE team_members ADD COLUMN short_description TEXT"); } }, }, { version: 12, name: "add_status_to_mc_and_openday", up: (db) => { for (const table of ["mc_registrations", "open_day_bookings"]) { const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; const colNames = new Set(cols.map((c) => c.name)); if (!colNames.has("status")) { db.exec(`ALTER TABLE ${table} ADD COLUMN status TEXT NOT NULL DEFAULT 'new'`); } } }, }, { version: 13, name: "add_booking_notes", up: (db) => { for (const table of ["mc_registrations", "group_bookings", "open_day_bookings"]) { const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; if (!cols.some((c) => c.name === "notes")) { db.exec(`ALTER TABLE ${table} ADD COLUMN notes TEXT`); } } }, }, ]; 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; short_description: 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, shortDescription: r.short_description ?? 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, shortDescription: r.short_description ?? 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, short_description, description, experience, victories, education, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( data.name, data.role, data.image, data.instagram ?? null, data.shortDescription ?? 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.shortDescription !== undefined) { fields.push("short_description = ?"); values.push(data.shortDescription || 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; phone: string | null; telegram: string | null; created_at: string; notified_confirm: number; notified_reminder: number; reminder_status: string | null; status: string; notes: string | null; } export interface McRegistration { id: number; masterClassTitle: string; name: string; instagram: string; phone?: string; telegram?: string; createdAt: string; notifiedConfirm: boolean; notifiedReminder: boolean; reminderStatus?: string; status: string; notes?: string; } export function addMcRegistration( masterClassTitle: string, name: string, instagram: string, telegram?: string, phone?: string ): number { const db = getDb(); const result = db .prepare( `INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone) VALUES (?, ?, ?, ?, ?)` ) .run(masterClassTitle, name, instagram, telegram || null, phone || 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(mapMcRow); } export function getAllMcRegistrations(): McRegistration[] { const db = getDb(); const rows = db .prepare("SELECT * FROM mc_registrations ORDER BY created_at DESC") .all() as McRegistrationRow[]; return rows.map(mapMcRow); } function mapMcRow(r: McRegistrationRow): McRegistration { return { id: r.id, masterClassTitle: r.master_class_title, name: r.name, instagram: r.instagram, phone: r.phone ?? undefined, telegram: r.telegram ?? undefined, createdAt: r.created_at, notifiedConfirm: !!r.notified_confirm, notifiedReminder: !!r.notified_reminder, reminderStatus: r.reminder_status ?? undefined, status: r.status || "new", notes: r.notes ?? undefined, }; } export function setMcRegistrationStatus(id: number, status: string): void { const db = getDb(); db.prepare("UPDATE mc_registrations SET status = ?, notified_confirm = 1 WHERE id = ?").run(status, id); } 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 toggleMcNotification( id: number, field: "notified_confirm" | "notified_reminder", value: boolean ): void { const db = getDb(); db.prepare( `UPDATE mc_registrations SET ${field} = ? WHERE id = ?` ).run(value ? 1 : 0, id); } export function deleteMcRegistration(id: number): void { const db = getDb(); db.prepare("DELETE FROM mc_registrations WHERE id = ?").run(id); } // --- Group Bookings --- interface GroupBookingRow { id: number; name: string; phone: string; group_info: string | null; instagram: string | null; telegram: string | null; notified_confirm: number; notified_reminder: number; reminder_status: string | null; status: string; confirmed_date: string | null; confirmed_group: string | null; confirmed_comment: string | null; notes: string | null; created_at: string; } export type BookingStatus = "new" | "contacted" | "confirmed" | "declined"; export interface GroupBooking { id: number; name: string; phone: string; groupInfo?: string; instagram?: string; telegram?: string; notifiedConfirm: boolean; notifiedReminder: boolean; reminderStatus?: string; status: BookingStatus; confirmedDate?: string; confirmedGroup?: string; confirmedComment?: string; notes?: string; createdAt: string; } export function addGroupBooking( name: string, phone: string, groupInfo?: string, instagram?: string, telegram?: string ): number { const db = getDb(); const result = db .prepare( "INSERT INTO group_bookings (name, phone, group_info, instagram, telegram) VALUES (?, ?, ?, ?, ?)" ) .run(name, phone, groupInfo || null, instagram || null, telegram || null); return result.lastInsertRowid as number; } export function getGroupBookings(): GroupBooking[] { const db = getDb(); const rows = db .prepare("SELECT * FROM group_bookings ORDER BY created_at DESC") .all() as GroupBookingRow[]; return rows.map((r) => ({ id: r.id, name: r.name, phone: r.phone, groupInfo: r.group_info ?? undefined, instagram: r.instagram ?? undefined, telegram: r.telegram ?? undefined, notifiedConfirm: !!r.notified_confirm, notifiedReminder: !!r.notified_reminder, reminderStatus: r.reminder_status ?? undefined, status: (r.status || "new") as BookingStatus, confirmedDate: r.confirmed_date ?? undefined, confirmedGroup: r.confirmed_group ?? undefined, confirmedComment: r.confirmed_comment ?? undefined, notes: r.notes ?? undefined, createdAt: r.created_at, })); } export function setGroupBookingStatus( id: number, status: BookingStatus, confirmation?: { date: string; group: string; comment?: string } ): void { const db = getDb(); if (status === "confirmed" && confirmation) { db.prepare( "UPDATE group_bookings SET status = ?, confirmed_date = ?, confirmed_group = ?, confirmed_comment = ?, notified_confirm = 1 WHERE id = ?" ).run(status, confirmation.date, confirmation.group, confirmation.comment || null, id); } else { db.prepare( "UPDATE group_bookings SET status = ?, confirmed_date = NULL, confirmed_group = NULL, confirmed_comment = NULL, notified_confirm = 1 WHERE id = ?" ).run(status, id); } } export function updateGroupBooking( id: number, name: string, phone: string, groupInfo?: string ): void { const db = getDb(); db.prepare( "UPDATE group_bookings SET name = ?, phone = ?, group_info = ? WHERE id = ?" ).run(name, phone, groupInfo || null, id); } export function toggleGroupBookingNotification( id: number, field: "notified_confirm" | "notified_reminder", value: boolean ): void { const db = getDb(); db.prepare(`UPDATE group_bookings SET ${field} = ? WHERE id = ?`).run( value ? 1 : 0, id ); } export function deleteGroupBooking(id: number): void { const db = getDb(); db.prepare("DELETE FROM group_bookings WHERE id = ?").run(id); } // --- Unread booking counts (lightweight) --- export interface UnreadCounts { groupBookings: number; mcRegistrations: number; openDayBookings: number; total: number; } // --- Reminder status --- export type ReminderStatus = "pending" | "coming" | "cancelled"; export function setReminderStatus( table: "mc_registrations" | "group_bookings" | "open_day_bookings", id: number, status: ReminderStatus | null ): void { const db = getDb(); db.prepare(`UPDATE ${table} SET reminder_status = ? WHERE id = ?`).run(status, id); } export function updateBookingNotes( table: "mc_registrations" | "group_bookings" | "open_day_bookings", id: number, notes: string ): void { const db = getDb(); db.prepare(`UPDATE ${table} SET notes = ? WHERE id = ?`).run(notes || null, id); } export interface ReminderItem { id: number; type: "class" | "master-class" | "open-day"; table: "mc_registrations" | "group_bookings" | "open_day_bookings"; name: string; phone?: string; instagram?: string; telegram?: string; reminderStatus?: string; eventLabel: string; eventDate: string; } export function getUpcomingReminders(): ReminderItem[] { const db = getDb(); const items: ReminderItem[] = []; // Tomorrow and today dates const now = new Date(); const today = now.toISOString().split("T")[0]; const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString().split("T")[0]; // MC registrations — match slots from sections JSON try { const mcSection = db.prepare("SELECT data FROM sections WHERE key = 'masterClasses'").get() as { data: string } | undefined; if (mcSection) { const mcData = JSON.parse(mcSection.data) as { items: { title: string; slots: { date: string; startTime?: string }[] }[] }; // Find MC titles with slots today or tomorrow const upcomingTitles: { title: string; date: string; time?: string }[] = []; for (const mc of mcData.items || []) { for (const slot of mc.slots || []) { if (slot.date === today || slot.date === tomorrow) { upcomingTitles.push({ title: mc.title, date: slot.date, time: slot.startTime }); } } } if (upcomingTitles.length > 0) { const uniqueTitles = [...new Set(upcomingTitles.map((t) => t.title))]; const placeholders = uniqueTitles.map(() => "?").join(", "); const rows = db.prepare( `SELECT * FROM mc_registrations WHERE master_class_title IN (${placeholders})` ).all(...uniqueTitles) as McRegistrationRow[]; // Build a lookup: title → { date, time } const titleInfo = new Map(); for (const t of upcomingTitles) { titleInfo.set(t.title, { date: t.date, time: t.time }); } for (const r of rows) { const info = titleInfo.get(r.master_class_title); if (!info) continue; items.push({ id: r.id, type: "master-class", table: "mc_registrations", name: r.name, phone: r.phone ?? undefined, instagram: r.instagram ?? undefined, telegram: r.telegram ?? undefined, reminderStatus: r.reminder_status ?? undefined, eventLabel: `${r.master_class_title}${info.time ? ` · ${info.time}` : ""}`, eventDate: info.date, }); } } } } catch { /* ignore */ } // Group bookings — confirmed with date today/tomorrow try { const gbRows = db.prepare( "SELECT * FROM group_bookings WHERE status IN ('confirmed', 'contacted') AND confirmed_date IN (?, ?)" ).all(today, tomorrow) as GroupBookingRow[]; for (const r of gbRows) { items.push({ id: r.id, type: "class", table: "group_bookings", name: r.name, phone: r.phone ?? undefined, instagram: r.instagram ?? undefined, telegram: r.telegram ?? undefined, reminderStatus: r.reminder_status ?? undefined, eventLabel: r.confirmed_group || "Занятие", eventDate: r.confirmed_date!, }); } } catch { /* ignore */ } // Open Day bookings — check event date try { const events = db.prepare( "SELECT id, date, title FROM open_day_events WHERE date IN (?, ?) AND active = 1" ).all(today, tomorrow) as { id: number; date: string; title: string }[]; for (const ev of events) { const rows = db.prepare( `SELECT b.*, c.style as class_style, c.trainer as class_trainer, c.start_time as class_time, c.hall as class_hall FROM open_day_bookings b JOIN open_day_classes c ON c.id = b.class_id WHERE b.event_id = ? AND c.cancelled = 0` ).all(ev.id) as OpenDayBookingRow[]; for (const r of rows) { items.push({ id: r.id, type: "open-day", table: "open_day_bookings", name: r.name, phone: r.phone ?? undefined, instagram: r.instagram ?? undefined, telegram: r.telegram ?? undefined, reminderStatus: r.reminder_status ?? undefined, eventLabel: `${r.class_style} · ${r.class_trainer} · ${r.class_time} (${r.class_hall})`, eventDate: ev.date, }); } } } catch { /* ignore */ } return items; } export function getUnreadBookingCounts(): UnreadCounts { const db = getDb(); const gb = (db.prepare("SELECT COUNT(*) as c FROM group_bookings WHERE notified_confirm = 0").get() as { c: number }).c; const mc = (db.prepare("SELECT COUNT(*) as c FROM mc_registrations WHERE notified_confirm = 0").get() as { c: number }).c; let od = 0; // Check if open_day_bookings table exists (might not if no migration yet) try { od = (db.prepare("SELECT COUNT(*) as c FROM open_day_bookings WHERE notified_confirm = 0").get() as { c: number }).c; } catch { /* table doesn't exist yet */ } return { groupBookings: gb, mcRegistrations: mc, openDayBookings: od, total: gb + mc + od }; } // --- Open Day Events --- interface OpenDayEventRow { id: number; date: string; title: string; description: string | null; price_per_class: number; discount_price: number; discount_threshold: number; min_bookings: number; active: number; created_at: string; updated_at: string; } export interface OpenDayEvent { id: number; date: string; title: string; description?: string; pricePerClass: number; discountPrice: number; discountThreshold: number; minBookings: number; active: boolean; } interface OpenDayClassRow { id: number; event_id: number; hall: string; start_time: string; end_time: string; trainer: string; style: string; cancelled: number; sort_order: number; booking_count?: number; } export interface OpenDayClass { id: number; eventId: number; hall: string; startTime: string; endTime: string; trainer: string; style: string; cancelled: boolean; sortOrder: number; bookingCount: number; } interface OpenDayBookingRow { id: number; class_id: number; event_id: number; name: string; phone: string; instagram: string | null; telegram: string | null; notified_confirm: number; notified_reminder: number; reminder_status: string | null; status: string; created_at: string; class_style?: string; class_trainer?: string; class_time?: string; class_hall?: string; } export interface OpenDayBooking { id: number; classId: number; eventId: number; name: string; phone: string; instagram?: string; telegram?: string; notifiedConfirm: boolean; notifiedReminder: boolean; reminderStatus?: string; status: string; createdAt: string; classStyle?: string; classTrainer?: string; classTime?: string; classHall?: string; } function mapEventRow(r: OpenDayEventRow): OpenDayEvent { return { id: r.id, date: r.date, title: r.title, description: r.description ?? undefined, pricePerClass: r.price_per_class, discountPrice: r.discount_price, discountThreshold: r.discount_threshold, minBookings: r.min_bookings, active: !!r.active, }; } function mapClassRow(r: OpenDayClassRow): OpenDayClass { return { id: r.id, eventId: r.event_id, hall: r.hall, startTime: r.start_time, endTime: r.end_time, trainer: r.trainer, style: r.style, cancelled: !!r.cancelled, sortOrder: r.sort_order, bookingCount: r.booking_count ?? 0, }; } export function setOpenDayBookingStatus(id: number, status: string): void { const db = getDb(); db.prepare("UPDATE open_day_bookings SET status = ?, notified_confirm = 1 WHERE id = ?").run(status, id); } function mapBookingRow(r: OpenDayBookingRow): OpenDayBooking { return { id: r.id, classId: r.class_id, eventId: r.event_id, name: r.name, phone: r.phone, instagram: r.instagram ?? undefined, telegram: r.telegram ?? undefined, notifiedConfirm: !!r.notified_confirm, notifiedReminder: !!r.notified_reminder, reminderStatus: r.reminder_status ?? undefined, status: r.status || "new", createdAt: r.created_at, classStyle: r.class_style ?? undefined, classTrainer: r.class_trainer ?? undefined, classTime: r.class_time ?? undefined, classHall: r.class_hall ?? undefined, }; } export function createOpenDayEvent(data: { date: string; title?: string; description?: string; pricePerClass?: number; discountPrice?: number; discountThreshold?: number; minBookings?: number; }): number { const db = getDb(); const result = db .prepare( `INSERT INTO open_day_events (date, title, description, price_per_class, discount_price, discount_threshold, min_bookings) VALUES (?, ?, ?, ?, ?, ?, ?)` ) .run( data.date, data.title || "День открытых дверей", data.description || null, data.pricePerClass ?? 30, data.discountPrice ?? 20, data.discountThreshold ?? 3, data.minBookings ?? 4 ); return result.lastInsertRowid as number; } export function getOpenDayEvents(): OpenDayEvent[] { const db = getDb(); const rows = db .prepare("SELECT * FROM open_day_events ORDER BY date DESC") .all() as OpenDayEventRow[]; return rows.map(mapEventRow); } export function getOpenDayEvent(id: number): OpenDayEvent | null { const db = getDb(); const row = db .prepare("SELECT * FROM open_day_events WHERE id = ?") .get(id) as OpenDayEventRow | undefined; return row ? mapEventRow(row) : null; } export function getActiveOpenDayEvent(): OpenDayEvent | null { const db = getDb(); const row = db .prepare( "SELECT * FROM open_day_events WHERE active = 1 AND date >= date('now') ORDER BY date ASC LIMIT 1" ) .get() as OpenDayEventRow | undefined; return row ? mapEventRow(row) : null; } export function updateOpenDayEvent( id: number, data: Partial<{ date: string; title: string; description: string; pricePerClass: number; discountPrice: number; discountThreshold: number; minBookings: number; active: boolean; }> ): void { const db = getDb(); const sets: string[] = []; const vals: unknown[] = []; if (data.date !== undefined) { sets.push("date = ?"); vals.push(data.date); } if (data.title !== undefined) { sets.push("title = ?"); vals.push(data.title); } if (data.description !== undefined) { sets.push("description = ?"); vals.push(data.description || null); } if (data.pricePerClass !== undefined) { sets.push("price_per_class = ?"); vals.push(data.pricePerClass); } if (data.discountPrice !== undefined) { sets.push("discount_price = ?"); vals.push(data.discountPrice); } if (data.discountThreshold !== undefined) { sets.push("discount_threshold = ?"); vals.push(data.discountThreshold); } if (data.minBookings !== undefined) { sets.push("min_bookings = ?"); vals.push(data.minBookings); } if (data.active !== undefined) { sets.push("active = ?"); vals.push(data.active ? 1 : 0); } if (sets.length === 0) return; sets.push("updated_at = datetime('now')"); vals.push(id); db.prepare(`UPDATE open_day_events SET ${sets.join(", ")} WHERE id = ?`).run(...vals); } export function deleteOpenDayEvent(id: number): void { const db = getDb(); db.prepare("DELETE FROM open_day_events WHERE id = ?").run(id); } // --- Open Day Classes --- export function addOpenDayClass( eventId: number, data: { hall: string; startTime: string; endTime: string; trainer: string; style: string } ): number { const db = getDb(); const maxOrder = ( db.prepare("SELECT MAX(sort_order) as m FROM open_day_classes WHERE event_id = ?").get(eventId) as { m: number | null } ).m ?? -1; const result = db .prepare( `INSERT INTO open_day_classes (event_id, hall, start_time, end_time, trainer, style, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)` ) .run(eventId, data.hall, data.startTime, data.endTime, data.trainer, data.style, maxOrder + 1); return result.lastInsertRowid as number; } export function getOpenDayClasses(eventId: number): OpenDayClass[] { const db = getDb(); const rows = db .prepare( `SELECT c.*, COALESCE(b.cnt, 0) as booking_count FROM open_day_classes c LEFT JOIN (SELECT class_id, COUNT(*) as cnt FROM open_day_bookings GROUP BY class_id) b ON b.class_id = c.id WHERE c.event_id = ? ORDER BY c.hall, c.start_time` ) .all(eventId) as OpenDayClassRow[]; return rows.map(mapClassRow); } export function updateOpenDayClass( id: number, data: Partial<{ hall: string; startTime: string; endTime: string; trainer: string; style: string; cancelled: boolean; sortOrder: number }> ): void { const db = getDb(); const sets: string[] = []; const vals: unknown[] = []; if (data.hall !== undefined) { sets.push("hall = ?"); vals.push(data.hall); } if (data.startTime !== undefined) { sets.push("start_time = ?"); vals.push(data.startTime); } if (data.endTime !== undefined) { sets.push("end_time = ?"); vals.push(data.endTime); } if (data.trainer !== undefined) { sets.push("trainer = ?"); vals.push(data.trainer); } if (data.style !== undefined) { sets.push("style = ?"); vals.push(data.style); } if (data.cancelled !== undefined) { sets.push("cancelled = ?"); vals.push(data.cancelled ? 1 : 0); } if (data.sortOrder !== undefined) { sets.push("sort_order = ?"); vals.push(data.sortOrder); } if (sets.length === 0) return; vals.push(id); db.prepare(`UPDATE open_day_classes SET ${sets.join(", ")} WHERE id = ?`).run(...vals); } export function deleteOpenDayClass(id: number): void { const db = getDb(); db.prepare("DELETE FROM open_day_classes WHERE id = ?").run(id); } // --- Open Day Bookings --- export function addOpenDayBooking( classId: number, eventId: number, data: { name: string; phone: string; instagram?: string; telegram?: string } ): number { const db = getDb(); const result = db .prepare( `INSERT INTO open_day_bookings (class_id, event_id, name, phone, instagram, telegram) VALUES (?, ?, ?, ?, ?, ?)` ) .run(classId, eventId, data.name, data.phone, data.instagram || null, data.telegram || null); return result.lastInsertRowid as number; } export function getOpenDayBookings(eventId: number): OpenDayBooking[] { const db = getDb(); const rows = db .prepare( `SELECT b.*, c.style as class_style, c.trainer as class_trainer, c.start_time as class_time, c.hall as class_hall FROM open_day_bookings b JOIN open_day_classes c ON c.id = b.class_id WHERE b.event_id = ? ORDER BY b.created_at DESC` ) .all(eventId) as OpenDayBookingRow[]; return rows.map(mapBookingRow); } export function getOpenDayBookingCountsByClass(eventId: number): Record { const db = getDb(); const rows = db .prepare("SELECT class_id, COUNT(*) as cnt FROM open_day_bookings WHERE event_id = ? GROUP BY class_id") .all(eventId) as { class_id: number; cnt: number }[]; const result: Record = {}; for (const r of rows) result[r.class_id] = r.cnt; return result; } export function getPersonOpenDayBookings(eventId: number, phone: string): number { const db = getDb(); const row = db .prepare("SELECT COUNT(*) as cnt FROM open_day_bookings WHERE event_id = ? AND phone = ?") .get(eventId, phone) as { cnt: number }; return row.cnt; } export function toggleOpenDayNotification( id: number, field: "notified_confirm" | "notified_reminder", value: boolean ): void { const db = getDb(); db.prepare(`UPDATE open_day_bookings SET ${field} = ? WHERE id = ?`).run(value ? 1 : 0, id); } export function deleteOpenDayBooking(id: number): void { const db = getDb(); db.prepare("DELETE FROM open_day_bookings WHERE id = ?").run(id); } export function isOpenDayClassBookedByPhone(classId: number, phone: string): boolean { const db = getDb(); const row = db .prepare("SELECT id FROM open_day_bookings WHERE class_id = ? AND phone = ? LIMIT 1") .get(classId, phone) as { id: number } | undefined; return !!row; } export { SECTION_KEYS };