Files
blackheart-website/src/lib/db.ts
diana.dolgolyova 669c4a3023 fix: reminders include contacted bookings, confirmation details persist after status revert
- Reminders query now includes 'contacted' group bookings with confirmed_date,
  preventing people from being forgotten when admin hasn't clicked "Подтвердить"
- Confirmation details (group, date) remain visible regardless of booking status,
  so "Вернуть" no longer hides previously entered info
2026-03-24 15:26:05 +03:00

1299 lines
41 KiB
TypeScript

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<T = unknown>(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<string, unknown>) => {
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<TeamMember, "id">
): 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<Omit<TeamMember, "id">>
): 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<string, unknown> = {};
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<string, { date: string; time?: string }>();
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<number, number> {
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<number, number> = {};
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 };