feat: add reminders tab with status tracking (coming/pending/cancelled)
Auto-surfaces bookings for today and tomorrow. Admin sets status per person: coming, no answer, or cancelled. Summary stats per day. DB migration 8 adds reminder_status column to all booking tables. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
125
src/lib/db.ts
125
src/lib/db.ts
@@ -171,6 +171,19 @@ const migrations: Migration[] = [
|
||||
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`);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function runMigrations(db: Database.Database) {
|
||||
@@ -446,6 +459,7 @@ interface McRegistrationRow {
|
||||
created_at: string;
|
||||
notified_confirm: number;
|
||||
notified_reminder: number;
|
||||
reminder_status: string | null;
|
||||
}
|
||||
|
||||
export interface McRegistration {
|
||||
@@ -458,6 +472,7 @@ export interface McRegistration {
|
||||
createdAt: string;
|
||||
notifiedConfirm: boolean;
|
||||
notifiedReminder: boolean;
|
||||
reminderStatus?: string;
|
||||
}
|
||||
|
||||
export function addMcRegistration(
|
||||
@@ -506,6 +521,7 @@ function mapMcRow(r: McRegistrationRow): McRegistration {
|
||||
createdAt: r.created_at,
|
||||
notifiedConfirm: !!r.notified_confirm,
|
||||
notifiedReminder: !!r.notified_reminder,
|
||||
reminderStatus: r.reminder_status ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -548,6 +564,7 @@ interface GroupBookingRow {
|
||||
telegram: string | null;
|
||||
notified_confirm: number;
|
||||
notified_reminder: number;
|
||||
reminder_status: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -560,6 +577,7 @@ export interface GroupBooking {
|
||||
telegram?: string;
|
||||
notifiedConfirm: boolean;
|
||||
notifiedReminder: boolean;
|
||||
reminderStatus?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -593,6 +611,7 @@ export function getGroupBookings(): GroupBooking[] {
|
||||
telegram: r.telegram ?? undefined,
|
||||
notifiedConfirm: !!r.notified_confirm,
|
||||
notifiedReminder: !!r.notified_reminder,
|
||||
reminderStatus: r.reminder_status ?? undefined,
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
}
|
||||
@@ -635,6 +654,109 @@ export interface UnreadCounts {
|
||||
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 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { title, date, time } of upcomingTitles) {
|
||||
const rows = db.prepare(
|
||||
"SELECT * FROM mc_registrations WHERE master_class_title = ?"
|
||||
).all(title) as McRegistrationRow[];
|
||||
for (const r of rows) {
|
||||
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: `${title}${time ? ` · ${time}` : ""}`,
|
||||
eventDate: 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;
|
||||
@@ -713,6 +835,7 @@ interface OpenDayBookingRow {
|
||||
telegram: string | null;
|
||||
notified_confirm: number;
|
||||
notified_reminder: number;
|
||||
reminder_status: string | null;
|
||||
created_at: string;
|
||||
class_style?: string;
|
||||
class_trainer?: string;
|
||||
@@ -730,6 +853,7 @@ export interface OpenDayBooking {
|
||||
telegram?: string;
|
||||
notifiedConfirm: boolean;
|
||||
notifiedReminder: boolean;
|
||||
reminderStatus?: string;
|
||||
createdAt: string;
|
||||
classStyle?: string;
|
||||
classTrainer?: string;
|
||||
@@ -777,6 +901,7 @@ function mapBookingRow(r: OpenDayBookingRow): OpenDayBooking {
|
||||
telegram: r.telegram ?? undefined,
|
||||
notifiedConfirm: !!r.notified_confirm,
|
||||
notifiedReminder: !!r.notified_reminder,
|
||||
reminderStatus: r.reminder_status ?? undefined,
|
||||
createdAt: r.created_at,
|
||||
classStyle: r.class_style ?? undefined,
|
||||
classTrainer: r.class_trainer ?? undefined,
|
||||
|
||||
Reference in New Issue
Block a user