studio-os 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,692 +1,728 @@
1
- import { Router } from 'express';
2
- import fs from 'node:fs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { requireAdmin, makeMagicToken, createUser } from '../services/auth.js';
6
- import { getSetting, setSetting, audit } from '../db/index.js';
7
- import { generateInstances, upcomingInstances } from '../services/schedule.js';
8
- import {
9
- book, cancelBooking, cancelClass, markAttendance, BookingError,
10
- } from '../services/booking.js';
11
- import { importClients, importPasses } from '../services/importer.js';
12
- import { emails } from '../services/mailer.js';
13
- import { localDateStr, zonedToUtc, addDays } from '../lib/time.js';
14
-
15
- export default function adminRoutes(services) {
16
- const { db, mailer } = services;
17
- const r = Router();
18
- r.use(requireAdmin);
19
-
20
- const who = (req) => (req.session.userId
21
- ? (db.prepare('SELECT email FROM users WHERE id = ?').get(req.session.userId) || {}).email || 'staff'
22
- : 'staff');
23
-
24
- // ---------------------------------------------------------------- dashboard
25
- r.get('/', (req, res) => {
26
- const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
27
- const today = localDateStr(tz);
28
- const [y, m, d] = today.split('-').map(Number);
29
- const dayStart = zonedToUtc(tz, y, m, d, 0, 0).toISOString();
30
- const dayEnd = zonedToUtc(tz, y, m, d, 23, 59).toISOString();
31
- const todayClasses = db.prepare(
32
- `SELECT ci.*, ct.name AS class_name, ct.color, i.name AS instructor_name,
33
- (SELECT COUNT(*) FROM bookings b WHERE b.class_instance_id = ci.id
34
- AND b.status IN ('booked','attended','no_show')) AS booked_count,
35
- (SELECT COUNT(*) FROM bookings b WHERE b.class_instance_id = ci.id
36
- AND b.status = 'waitlist') AS waitlist_count
37
- FROM class_instances ci JOIN class_types ct ON ct.id = ci.class_type_id
38
- LEFT JOIN instructors i ON i.id = ci.instructor_id
39
- WHERE ci.starts_at BETWEEN ? AND ? ORDER BY ci.starts_at`
40
- ).all(dayStart, dayEnd);
41
-
42
- const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString();
43
- const weekRevenue = db.prepare(
44
- "SELECT COALESCE(SUM(amount_cents),0) total FROM payments WHERE status='paid' AND created_at >= ?"
45
- ).get(weekAgo).total;
46
- const pendingPayments = db.prepare(
47
- `SELECT p.*, c.name AS client_name FROM payments p LEFT JOIN clients c ON c.id = p.client_id
48
- WHERE p.status = 'pending' ORDER BY p.created_at DESC LIMIT 20`
49
- ).all();
50
- const soon = addDays(today, 30);
51
- const expiringPasses = db.prepare(
52
- `SELECT p.*, c.name AS client_name FROM passes p JOIN clients c ON c.id = p.client_id
53
- WHERE p.credits_remaining > 0 AND p.expires_on IS NOT NULL AND p.expires_on BETWEEN ? AND ?
54
- ORDER BY p.expires_on LIMIT 20`
55
- ).all(today, soon);
56
- res.render('admin/dashboard', {
57
- title: 'Dashboard', todayClasses, weekRevenue, expiringPasses, pendingPayments,
58
- });
59
- });
60
-
61
- // ---------------------------------------------------------- class types CRUD
62
- r.get('/class-types', (req, res) => {
63
- const types = db.prepare('SELECT * FROM class_types ORDER BY active DESC, name').all();
64
- res.render('admin/class_types', { title: 'Class types', types });
65
- });
66
-
67
- r.post('/class-types', (req, res) => {
68
- const b = req.body;
69
- db.prepare(
70
- 'INSERT INTO class_types (name, description, duration_min, capacity, drop_in_price_cents, credits_required, color) VALUES (?, ?, ?, ?, ?, ?, ?)'
71
- ).run(b.name, b.description || '', Number(b.duration_min) || 60, Number(b.capacity) || 10,
72
- Math.round(Number(b.drop_in_price || 0) * 100), Number(b.credits_required) || 1, b.color || '#4f7cac');
73
- audit(db, who(req), 'class_type_create', b.name);
74
- res.redirect('/admin/class-types');
75
- });
76
-
77
- r.post('/class-types/:id', (req, res) => {
78
- const b = req.body;
79
- if (b.action === 'toggle') {
80
- db.prepare('UPDATE class_types SET active = 1 - active WHERE id = ?').run(req.params.id);
81
- } else {
82
- db.prepare(
83
- 'UPDATE class_types SET name=?, description=?, duration_min=?, capacity=?, drop_in_price_cents=?, credits_required=?, color=? WHERE id=?'
84
- ).run(b.name, b.description || '', Number(b.duration_min) || 60, Number(b.capacity) || 10,
85
- Math.round(Number(b.drop_in_price || 0) * 100), Number(b.credits_required) || 1,
86
- b.color || '#4f7cac', req.params.id);
87
- }
88
- audit(db, who(req), 'class_type_update', `class_type:${req.params.id}`);
89
- res.redirect('/admin/class-types');
90
- });
91
-
92
- // ---------------------------------------------------------- instructors CRUD
93
- r.get('/instructors', (req, res) => {
94
- const instructors = db.prepare('SELECT * FROM instructors ORDER BY active DESC, name').all();
95
- const accounts = db.prepare(
96
- `SELECT u.id, u.name, u.email, u.created_at,
97
- (SELECT COUNT(*) FROM instructor_class_assignments ica
98
- JOIN class_instances ci ON ci.id = ica.class_id
99
- WHERE ica.instructor_id = u.id AND ci.starts_at >= ?) AS upcoming_count
100
- FROM users u WHERE u.role = 'instructor' ORDER BY u.name, u.email`
101
- ).all(new Date().toISOString());
102
- res.render('admin/instructors', { title: 'Instructors', instructors, accounts });
103
- });
104
-
105
- r.post('/instructors', (req, res) => {
106
- db.prepare('INSERT INTO instructors (name, bio) VALUES (?, ?)').run(req.body.name, req.body.bio || '');
107
- audit(db, who(req), 'instructor_create', req.body.name);
108
- res.redirect('/admin/instructors');
109
- });
110
-
111
- // ------------------------------------------------------- instructor logins
112
- r.get('/instructors/new', (req, res) => {
113
- res.render('admin/instructor_new', { title: 'New instructor login', error: null, values: {} });
114
- });
115
-
116
- r.post('/instructors/new', (req, res) => {
117
- const email = String(req.body.email || '').trim().toLowerCase();
118
- const password = String(req.body.password || '');
119
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) || password.length < 8) {
120
- return res.status(400).render('admin/instructor_new', {
121
- title: 'New instructor login',
122
- error: 'A valid email and a password of at least 8 characters are required.',
123
- values: { name: req.body.name || '', email },
124
- });
125
- }
126
- try {
127
- const id = createUser(db, { email, password, name: req.body.name || '', role: 'instructor' });
128
- audit(db, who(req), 'instructor_account_create', email);
129
- req.session.flash = 'Instructor login created — now assign their classes.';
130
- res.redirect(`/admin/instructors/${id}/classes`);
131
- } catch (err) {
132
- res.status(400).render('admin/instructor_new', {
133
- title: 'New instructor login',
134
- error: err.message.includes('UNIQUE') ? 'That email already has an account.' : err.message,
135
- values: { name: req.body.name || '', email },
136
- });
137
- }
138
- });
139
-
140
- // Upcoming-class window shared by the assignment form's GET and POST so a
141
- // save only rewrites assignments for the instances that were displayed.
142
- const assignableInstances = () => db.prepare(
143
- `SELECT ci.id, ci.starts_at, ci.capacity, ct.name AS class_name, i.name AS instructor_name
144
- FROM class_instances ci JOIN class_types ct ON ct.id = ci.class_type_id
145
- LEFT JOIN instructors i ON i.id = ci.instructor_id
146
- WHERE ci.starts_at >= ? AND ci.status = 'scheduled' ORDER BY ci.starts_at`
147
- ).all(new Date().toISOString());
148
-
149
- const instructorAccount = (id) => db.prepare(
150
- "SELECT * FROM users WHERE id = ? AND role = 'instructor'"
151
- ).get(id);
152
-
153
- r.get('/instructors/:id/classes', (req, res) => {
154
- const account = instructorAccount(req.params.id);
155
- if (!account) return res.status(404).render('error', { title: 'Not found', message: 'Instructor login not found.' });
156
- const instances = assignableInstances();
157
- const assigned = new Set(db.prepare(
158
- 'SELECT class_id FROM instructor_class_assignments WHERE instructor_id = ?'
159
- ).all(account.id).map((r2) => r2.class_id));
160
- res.render('admin/instructor_classes', {
161
- title: `Assign classes — ${account.name || account.email}`, account, instances, assigned,
162
- });
163
- });
164
-
165
- r.post('/instructors/:id/classes', (req, res) => {
166
- const account = instructorAccount(req.params.id);
167
- if (!account) return res.status(404).render('error', { title: 'Not found', message: 'Instructor login not found.' });
168
- const checked = new Set([].concat(req.body.class_ids || []).map(Number).filter(Number.isInteger));
169
- const shown = assignableInstances();
170
- db.transaction(() => {
171
- const del = db.prepare('DELETE FROM instructor_class_assignments WHERE instructor_id = ? AND class_id = ?');
172
- const ins = db.prepare('INSERT OR IGNORE INTO instructor_class_assignments (instructor_id, class_id) VALUES (?, ?)');
173
- for (const inst of shown) {
174
- if (checked.has(inst.id)) ins.run(account.id, inst.id);
175
- else del.run(account.id, inst.id);
176
- }
177
- })();
178
- audit(db, who(req), 'instructor_assign', `user:${account.id} classes:${checked.size}`);
179
- req.session.flash = `Assignments saved — ${checked.size} upcoming class(es).`;
180
- res.redirect(`/admin/instructors/${account.id}/classes`);
181
- });
182
-
183
- r.post('/instructors/:id', (req, res) => {
184
- if (req.body.action === 'toggle') {
185
- db.prepare('UPDATE instructors SET active = 1 - active WHERE id = ?').run(req.params.id);
186
- } else {
187
- db.prepare('UPDATE instructors SET name = ?, bio = ? WHERE id = ?')
188
- .run(req.body.name, req.body.bio || '', req.params.id);
189
- }
190
- audit(db, who(req), 'instructor_update', `instructor:${req.params.id}`);
191
- res.redirect('/admin/instructors');
192
- });
193
-
194
- // ---------------------------------------------------------- weekly rules CRUD
195
- r.get('/rules', (req, res) => {
196
- const rules = db.prepare(
197
- `SELECT sr.*, ct.name AS class_name, i.name AS instructor_name
198
- FROM schedule_rules sr JOIN class_types ct ON ct.id = sr.class_type_id
199
- LEFT JOIN instructors i ON i.id = sr.instructor_id
200
- ORDER BY sr.weekday, sr.start_time`
201
- ).all();
202
- const types = db.prepare('SELECT * FROM class_types WHERE active = 1 ORDER BY name').all();
203
- const instructors = db.prepare('SELECT * FROM instructors WHERE active = 1 ORDER BY name').all();
204
- res.render('admin/rules', { title: 'Weekly schedule rules', rules, types, instructors });
205
- });
206
-
207
- r.post('/rules', (req, res) => {
208
- const b = req.body;
209
- if (!/^\d{2}:\d{2}$/.test(b.start_time || '')) {
210
- req.session.flash = 'Start time must be HH:MM.';
211
- return res.redirect('/admin/rules');
212
- }
213
- db.prepare(
214
- 'INSERT INTO schedule_rules (class_type_id, instructor_id, weekday, start_time, capacity_override, active_from, active_until) VALUES (?, ?, ?, ?, ?, ?, ?)'
215
- ).run(Number(b.class_type_id), Number(b.instructor_id) || null, Number(b.weekday),
216
- b.start_time, Number(b.capacity_override) || null, b.active_from || null, b.active_until || null);
217
- const created = generateInstances(db);
218
- audit(db, who(req), 'rule_create', `generated:${created}`);
219
- req.session.flash = `Rule created — ${created} class instances materialized.`;
220
- res.redirect('/admin/rules');
221
- });
222
-
223
- r.post('/rules/:id', (req, res) => {
224
- const b = req.body;
225
- if (b.action === 'delete') {
226
- // keep past instances; drop future not-yet-booked ones from this rule
227
- db.transaction(() => {
228
- db.prepare('UPDATE schedule_rules SET active = 0 WHERE id = ?').run(req.params.id);
229
- db.prepare(
230
- `DELETE FROM class_instances WHERE rule_id = ? AND starts_at > ? AND status = 'scheduled'
231
- AND NOT EXISTS (SELECT 1 FROM bookings b WHERE b.class_instance_id = class_instances.id AND b.status != 'cancelled')`
232
- ).run(req.params.id, new Date().toISOString());
233
- })();
234
- req.session.flash = 'Rule deactivated. Future empty instances removed; booked ones kept.';
235
- } else {
236
- db.prepare(
237
- 'UPDATE schedule_rules SET class_type_id=?, instructor_id=?, weekday=?, start_time=?, capacity_override=?, active_from=?, active_until=? WHERE id=?'
238
- ).run(Number(b.class_type_id), Number(b.instructor_id) || null, Number(b.weekday),
239
- b.start_time, Number(b.capacity_override) || null, b.active_from || null,
240
- b.active_until || null, req.params.id);
241
- generateInstances(db);
242
- req.session.flash = 'Rule updated and schedule regenerated.';
243
- }
244
- audit(db, who(req), 'rule_update', `rule:${req.params.id}`);
245
- res.redirect('/admin/rules');
246
- });
247
-
248
- // ---------------------------------------------------------------- schedule
249
- r.get('/schedule', (req, res) => {
250
- const instances = upcomingInstances(db, { days: 14, includeCancelled: true });
251
- const types = db.prepare('SELECT * FROM class_types WHERE active = 1 ORDER BY name').all();
252
- const instructors = db.prepare('SELECT * FROM instructors WHERE active = 1 ORDER BY name').all();
253
- res.render('admin/schedule', { title: 'Schedule', instances, types, instructors });
254
- });
255
-
256
- // one-off class instance
257
- r.post('/schedule', (req, res) => {
258
- const b = req.body;
259
- const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
260
- const type = db.prepare('SELECT * FROM class_types WHERE id = ?').get(Number(b.class_type_id));
261
- if (!type || !/^\d{4}-\d{2}-\d{2}$/.test(b.date || '') || !/^\d{2}:\d{2}$/.test(b.time || '')) {
262
- req.session.flash = 'Class type, date (YYYY-MM-DD) and time (HH:MM) are required.';
263
- return res.redirect('/admin/schedule');
264
- }
265
- const [y, m, d] = b.date.split('-').map(Number);
266
- const [hh, mm] = b.time.split(':').map(Number);
267
- const startsAt = zonedToUtc(tz, y, m, d, hh, mm).toISOString().replace(/\.\d{3}Z$/, 'Z');
268
- db.prepare(
269
- "INSERT INTO class_instances (class_type_id, instructor_id, starts_at, duration_min, capacity, status, notes) VALUES (?, ?, ?, ?, ?, 'scheduled', ?)"
270
- ).run(type.id, Number(b.instructor_id) || null, startsAt, type.duration_min,
271
- Number(b.capacity) || type.capacity, b.notes || '');
272
- audit(db, who(req), 'instance_create', startsAt);
273
- req.session.flash = 'One-off class created.';
274
- res.redirect('/admin/schedule');
275
- });
276
-
277
- r.post('/instances/:id/cancel', (req, res) => {
278
- try {
279
- const { instance, affected } = cancelClass(db, Number(req.params.id));
280
- audit(db, who(req), 'class_cancel', `instance:${instance.id} bookings:${affected.length}`);
281
- const studio = getSetting(db, 'studio_name', 'the studio');
282
- const when = res.locals.fmtDt(instance.starts_at);
283
- for (const bk of affected) {
284
- const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(bk.client_id);
285
- if (client) {
286
- mailer.send({
287
- to: client.email,
288
- ...emails.classCancelled({ studio, clientName: client.name, className: instance.class_name, when }),
289
- });
290
- }
291
- }
292
- req.session.flash = `Class cancelled. ${affected.length} booking(s) refunded and notified.`;
293
- } catch (err) {
294
- if (!(err instanceof BookingError)) throw err;
295
- req.session.flash = err.message;
296
- }
297
- res.redirect('/admin/schedule');
298
- });
299
-
300
- // ------------------------------------------------------------------ roster
301
- r.get('/instances/:id', (req, res) => {
302
- const inst = db.prepare(
303
- `SELECT ci.*, ct.name AS class_name, ct.credits_required, i.name AS instructor_name
304
- FROM class_instances ci JOIN class_types ct ON ct.id = ci.class_type_id
305
- LEFT JOIN instructors i ON i.id = ci.instructor_id WHERE ci.id = ?`
306
- ).get(req.params.id);
307
- if (!inst) return res.status(404).render('error', { title: 'Not found', message: 'Class not found.' });
308
- const roster = db.prepare(
309
- `SELECT b.*, c.name AS client_name, c.email FROM bookings b JOIN clients c ON c.id = b.client_id
310
- WHERE b.class_instance_id = ? AND b.status IN ('booked','attended','no_show')
311
- ORDER BY b.created_at`
312
- ).all(inst.id);
313
- const waitlist = db.prepare(
314
- `SELECT b.*, c.name AS client_name, c.email FROM bookings b JOIN clients c ON c.id = b.client_id
315
- WHERE b.class_instance_id = ? AND b.status = 'waitlist' ORDER BY b.created_at`
316
- ).all(inst.id);
317
- res.render('admin/roster', { title: `Roster — ${inst.class_name}`, inst, roster, waitlist });
318
- });
319
-
320
- r.post('/bookings/:id/status', (req, res) => {
321
- const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.id);
322
- if (!booking) return res.status(404).render('error', { title: 'Not found', message: 'Booking not found.' });
323
- const status = req.body.status;
324
- try {
325
- if (status === 'cancelled') {
326
- const result = cancelBooking(db, booking.id, { forceRefund: req.body.refund === '1' });
327
- if (result.promoted) {
328
- const c = db.prepare('SELECT * FROM clients WHERE id = ?').get(result.promoted.client_id);
329
- const inst = result.instance;
330
- const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, c.id)}`;
331
- mailer.send({
332
- to: c.email,
333
- ...emails.waitlistPromotion({
334
- studio: getSetting(db, 'studio_name', ''), clientName: c.name,
335
- className: inst.class_name, when: res.locals.fmtDt(inst.starts_at), magicUrl,
336
- }),
337
- });
338
- }
339
- } else {
340
- markAttendance(db, booking.id, status);
341
- }
342
- audit(db, who(req), `booking_${status}`, `booking:${booking.id}`);
343
- } catch (err) {
344
- if (!(err instanceof BookingError)) throw err;
345
- req.session.flash = err.message;
346
- }
347
- res.redirect(`/admin/instances/${booking.class_instance_id}`);
348
- });
349
-
350
- r.post('/instances/:id/walkin', (req, res) => {
351
- const email = String(req.body.email || '').trim().toLowerCase();
352
- const name = String(req.body.name || '').trim();
353
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
354
- req.session.flash = 'A valid email is required for walk-ins.';
355
- return res.redirect(`/admin/instances/${req.params.id}`);
356
- }
357
- try {
358
- db.transaction(() => {
359
- let client = db.prepare('SELECT * FROM clients WHERE email = ?').get(email);
360
- if (!client) {
361
- const info = db.prepare(
362
- "INSERT INTO clients (name, email, source) VALUES (?, ?, 'manual')"
363
- ).run(name || email.split('@')[0], email);
364
- client = { id: info.lastInsertRowid };
365
- }
366
- const paidWith = req.body.paid_with === 'comp' ? 'comp'
367
- : (req.body.paid_with === 'auto' ? 'auto' : 'drop_in_manual');
368
- book(db, client.id, Number(req.params.id), { paidWith, allowPast: true });
369
- })();
370
- req.session.flash = 'Walk-in added.';
371
- audit(db, who(req), 'walkin_add', `instance:${req.params.id} ${email}`);
372
- } catch (err) {
373
- if (!(err instanceof BookingError)) throw err;
374
- req.session.flash = err.message;
375
- }
376
- res.redirect(`/admin/instances/${req.params.id}`);
377
- });
378
-
379
- // ------------------------------------------------------------------ clients
380
- r.get('/clients', (req, res) => {
381
- const q = String(req.query.q || '').trim();
382
- const clients = q
383
- ? db.prepare(
384
- `SELECT * FROM clients WHERE name LIKE ? OR email LIKE ? OR phone LIKE ? ORDER BY name LIMIT 200`
385
- ).all(`%${q}%`, `%${q}%`, `%${q}%`)
386
- : db.prepare('SELECT * FROM clients ORDER BY created_at DESC LIMIT 200').all();
387
- res.render('admin/clients', { title: 'Clients', clients, q });
388
- });
389
-
390
- r.post('/clients', (req, res) => {
391
- const email = String(req.body.email || '').trim().toLowerCase();
392
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
393
- req.session.flash = 'A valid email is required.';
394
- return res.redirect('/admin/clients');
395
- }
396
- try {
397
- const info = db.prepare(
398
- "INSERT INTO clients (name, email, phone, source) VALUES (?, ?, ?, 'manual')"
399
- ).run(req.body.name || email.split('@')[0], email, req.body.phone || '');
400
- audit(db, who(req), 'client_create', email);
401
- res.redirect(`/admin/clients/${info.lastInsertRowid}`);
402
- } catch (err) {
403
- req.session.flash = err.message.includes('UNIQUE') ? 'A client with that email already exists.' : err.message;
404
- res.redirect('/admin/clients');
405
- }
406
- });
407
-
408
- r.get('/clients/:id', (req, res) => {
409
- const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(req.params.id);
410
- if (!client) return res.status(404).render('error', { title: 'Not found', message: 'Client not found.' });
411
- const bookings = db.prepare(
412
- `SELECT b.*, ci.starts_at, ct.name AS class_name FROM bookings b
413
- JOIN class_instances ci ON ci.id = b.class_instance_id
414
- JOIN class_types ct ON ct.id = ci.class_type_id
415
- WHERE b.client_id = ? ORDER BY ci.starts_at DESC LIMIT 100`
416
- ).all(client.id);
417
- const passes = db.prepare('SELECT * FROM passes WHERE client_id = ? ORDER BY created_at DESC').all(client.id);
418
- const memberships = db.prepare('SELECT * FROM memberships WHERE client_id = ? ORDER BY id DESC').all(client.id);
419
- const payments = db.prepare('SELECT * FROM payments WHERE client_id = ? ORDER BY created_at DESC LIMIT 100').all(client.id);
420
- const plans = db.prepare('SELECT * FROM membership_plans WHERE active = 1').all();
421
- const packProducts = db.prepare('SELECT * FROM pack_products WHERE active = 1').all();
422
- const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, client.id)}`;
423
- // `client` is a reserved EJS option name — pass as clientInfo.
424
- res.render('admin/client', {
425
- title: client.name, clientInfo: client, bookings, passes, memberships, payments, plans, packProducts, magicUrl,
426
- });
427
- });
428
-
429
- r.post('/clients/:id', (req, res) => {
430
- const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(req.params.id);
431
- if (!client) return res.status(404).render('error', { title: 'Not found', message: 'Client not found.' });
432
- if (req.body.action === 'waiver_toggle') {
433
- db.prepare('UPDATE clients SET waiver_signed_at = ? WHERE id = ?')
434
- .run(client.waiver_signed_at ? null : new Date().toISOString(), client.id);
435
- } else {
436
- db.prepare('UPDATE clients SET name = ?, phone = ?, notes = ? WHERE id = ?')
437
- .run(req.body.name || client.name, req.body.phone ?? client.phone, req.body.notes ?? client.notes, client.id);
438
- }
439
- audit(db, who(req), 'client_update', `client:${client.id}`);
440
- res.redirect(`/admin/clients/${client.id}`);
441
- });
442
-
443
- r.post('/clients/:id/passes', (req, res) => {
444
- const b = req.body;
445
- const credits = Number(b.credits) || 10;
446
- db.prepare(
447
- `INSERT INTO passes (client_id, name, credits_total, credits_remaining, expires_on, price_paid_cents, source)
448
- VALUES (?, ?, ?, ?, ?, ?, 'manual')`
449
- ).run(req.params.id, b.name || `${credits}-class pack`, credits, credits,
450
- b.expires_on || null, Math.round(Number(b.price || 0) * 100));
451
- if (b.record_payment === '1' && Number(b.price) > 0) {
452
- db.prepare(
453
- `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
454
- VALUES (?, ?, ?, ?, ?, 'pack', 'paid')`
455
- ).run(req.params.id, Math.round(Number(b.price) * 100), getSetting(db, 'currency', 'HKD'),
456
- b.method || 'cash', b.name || 'pack');
457
- }
458
- audit(db, who(req), 'pass_add', `client:${req.params.id}`);
459
- res.redirect(`/admin/clients/${req.params.id}`);
460
- });
461
-
462
- r.post('/clients/:id/memberships', (req, res) => {
463
- const b = req.body;
464
- if (b.action === 'status' && b.membership_id) {
465
- db.prepare('UPDATE memberships SET status = ? WHERE id = ? AND client_id = ?')
466
- .run(b.status, b.membership_id, req.params.id);
467
- audit(db, who(req), 'membership_status', `membership:${b.membership_id} → ${b.status}`);
468
- return res.redirect(`/admin/clients/${req.params.id}`);
469
- }
470
- const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
471
- const today = localDateStr(tz);
472
- const plan = b.plan_id ? db.prepare('SELECT * FROM membership_plans WHERE id = ?').get(b.plan_id) : null;
473
- db.prepare(
474
- `INSERT INTO memberships (client_id, plan_id, plan_name, status, started_on, unlimited, credits_per_month, cycle_started_on)
475
- VALUES (?, ?, ?, 'active', ?, ?, ?, ?)`
476
- ).run(req.params.id, plan ? plan.id : null,
477
- plan ? plan.name : (b.plan_name || 'Membership'), today,
478
- plan ? plan.unlimited : (b.unlimited === '1' ? 1 : 0),
479
- plan ? plan.credits_per_month : (Number(b.credits_per_month) || null), today);
480
- if (plan && b.record_payment === '1') {
481
- db.prepare(
482
- `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
483
- VALUES (?, ?, ?, ?, ?, 'membership', 'paid')`
484
- ).run(req.params.id, plan.price_cents, getSetting(db, 'currency', 'HKD'), b.method || 'cash', plan.name);
485
- }
486
- audit(db, who(req), 'membership_add', `client:${req.params.id}`);
487
- res.redirect(`/admin/clients/${req.params.id}`);
488
- });
489
-
490
- r.post('/clients/:id/payments', (req, res) => {
491
- const b = req.body;
492
- db.prepare(
493
- `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
494
- VALUES (?, ?, ?, ?, ?, ?, 'paid')`
495
- ).run(req.params.id, Math.round(Number(b.amount || 0) * 100), getSetting(db, 'currency', 'HKD'),
496
- b.method || 'cash', b.reference || '', b.what || 'drop_in');
497
- audit(db, who(req), 'payment_record', `client:${req.params.id}`);
498
- res.redirect(`/admin/clients/${req.params.id}`);
499
- });
500
-
501
- r.post('/payments/:id/paid', (req, res) => {
502
- db.prepare("UPDATE payments SET status = 'paid', method = ? WHERE id = ? AND status = 'pending'")
503
- .run(req.body.method || 'cash', req.params.id);
504
- audit(db, who(req), 'payment_mark_paid', `payment:${req.params.id}`);
505
- res.redirect(req.get('referer') || '/admin');
506
- });
507
-
508
- // ---------------------------------------------------------------- products
509
- r.get('/products', (req, res) => {
510
- const packs = db.prepare('SELECT * FROM pack_products ORDER BY active DESC, price_cents').all();
511
- const plans = db.prepare('SELECT * FROM membership_plans ORDER BY active DESC, price_cents').all();
512
- res.render('admin/products', { title: 'Products', packs, plans });
513
- });
514
-
515
- r.post('/products/pack', (req, res) => {
516
- const b = req.body;
517
- db.prepare(
518
- 'INSERT INTO pack_products (name, credits, price_cents, validity_days, stripe_price_id) VALUES (?, ?, ?, ?, ?)'
519
- ).run(b.name, Number(b.credits) || 10, Math.round(Number(b.price || 0) * 100),
520
- Number(b.validity_days) || null, b.stripe_price_id || null);
521
- audit(db, who(req), 'pack_product_create', b.name);
522
- res.redirect('/admin/products');
523
- });
524
-
525
- r.post('/products/pack/:id', (req, res) => {
526
- const b = req.body;
527
- if (b.action === 'toggle') {
528
- db.prepare('UPDATE pack_products SET active = 1 - active WHERE id = ?').run(req.params.id);
529
- } else {
530
- db.prepare(
531
- 'UPDATE pack_products SET name=?, credits=?, price_cents=?, validity_days=?, stripe_price_id=? WHERE id=?'
532
- ).run(b.name, Number(b.credits) || 10, Math.round(Number(b.price || 0) * 100),
533
- Number(b.validity_days) || null, b.stripe_price_id || null, req.params.id);
534
- }
535
- res.redirect('/admin/products');
536
- });
537
-
538
- r.post('/products/plan', (req, res) => {
539
- const b = req.body;
540
- db.prepare(
541
- 'INSERT INTO membership_plans (name, price_cents, unlimited, credits_per_month, stripe_price_id) VALUES (?, ?, ?, ?, ?)'
542
- ).run(b.name, Math.round(Number(b.price || 0) * 100), b.unlimited === '1' ? 1 : 0,
543
- b.unlimited === '1' ? null : Number(b.credits_per_month) || 8, b.stripe_price_id || null);
544
- audit(db, who(req), 'plan_create', b.name);
545
- res.redirect('/admin/products');
546
- });
547
-
548
- r.post('/products/plan/:id', (req, res) => {
549
- const b = req.body;
550
- if (b.action === 'toggle') {
551
- db.prepare('UPDATE membership_plans SET active = 1 - active WHERE id = ?').run(req.params.id);
552
- } else {
553
- db.prepare(
554
- 'UPDATE membership_plans SET name=?, price_cents=?, unlimited=?, credits_per_month=?, stripe_price_id=? WHERE id=?'
555
- ).run(b.name, Math.round(Number(b.price || 0) * 100), b.unlimited === '1' ? 1 : 0,
556
- b.unlimited === '1' ? null : Number(b.credits_per_month) || 8, b.stripe_price_id || null, req.params.id);
557
- }
558
- res.redirect('/admin/products');
559
- });
560
-
561
- // ----------------------------------------------------------------- reports
562
- function revenueByMonth() {
563
- const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
564
- const rows = db.prepare(
565
- "SELECT created_at, amount_cents, what FROM payments WHERE status = 'paid'"
566
- ).all();
567
- const byMonth = new Map();
568
- for (const p of rows) {
569
- const month = localDateStr(tz, p.created_at).slice(0, 7);
570
- if (!byMonth.has(month)) byMonth.set(month, { month, total: 0, drop_in: 0, pack: 0, membership: 0, count: 0 });
571
- const m = byMonth.get(month);
572
- m.total += p.amount_cents;
573
- m[p.what] += p.amount_cents;
574
- m.count++;
575
- }
576
- return [...byMonth.values()].sort((a, b) => b.month.localeCompare(a.month));
577
- }
578
-
579
- function attendanceByType() {
580
- return db.prepare(
581
- `SELECT ct.name AS class_type,
582
- SUM(CASE WHEN b.status = 'attended' THEN 1 ELSE 0 END) AS attended,
583
- SUM(CASE WHEN b.status = 'no_show' THEN 1 ELSE 0 END) AS no_shows,
584
- SUM(CASE WHEN b.status = 'booked' THEN 1 ELSE 0 END) AS booked,
585
- COUNT(*) AS total
586
- FROM bookings b
587
- JOIN class_instances ci ON ci.id = b.class_instance_id
588
- JOIN class_types ct ON ct.id = ci.class_type_id
589
- WHERE b.status IN ('attended','no_show','booked')
590
- GROUP BY ct.id ORDER BY total DESC`
591
- ).all();
592
- }
593
-
594
- r.get('/reports', (req, res) => {
595
- res.render('admin/reports', {
596
- title: 'Reports', revenue: revenueByMonth(), attendance: attendanceByType(),
597
- });
598
- });
599
-
600
- r.get('/reports.csv', (req, res) => {
601
- const report = req.query.report === 'attendance' ? 'attendance' : 'revenue';
602
- const rows = report === 'attendance' ? attendanceByType() : revenueByMonth();
603
- if (!rows.length) return res.type('text/csv').send('');
604
- const headers = Object.keys(rows[0]);
605
- const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
606
- const csv = [headers.join(','), ...rows.map((r2) => headers.map((h) => esc(r2[h])).join(','))].join('\r\n');
607
- res.setHeader('Content-Disposition', `attachment; filename="${report}.csv"`);
608
- res.type('text/csv').send(csv);
609
- });
610
-
611
- // ---------------------------------------------------------------- settings
612
- r.get('/settings', (req, res) => {
613
- res.render('admin/settings', { title: 'Settings' });
614
- });
615
-
616
- r.post('/settings', (req, res) => {
617
- const b = req.body;
618
- if (b.studio_name) setSetting(db, 'studio_name', b.studio_name.trim());
619
- if (b.currency) setSetting(db, 'currency', b.currency.toUpperCase().slice(0, 3));
620
- if (b.timezone) {
621
- try {
622
- new Intl.DateTimeFormat('en', { timeZone: b.timezone });
623
- setSetting(db, 'timezone', b.timezone);
624
- } catch { req.session.flash = 'Invalid timezone — not saved.'; }
625
- }
626
- if (b.cancellation_window_hours !== undefined) {
627
- setSetting(db, 'cancellation_window_hours', String(Math.max(0, Number(b.cancellation_window_hours) || 0)));
628
- }
629
- if (b.late_cancel_policy) {
630
- setSetting(db, 'late_cancel_policy', b.late_cancel_policy === 'refund' ? 'refund' : 'forfeit');
631
- }
632
- if (b.waiver_markdown !== undefined) setSetting(db, 'waiver_markdown', b.waiver_markdown);
633
- audit(db, who(req), 'settings_update', 'settings');
634
- if (!req.session.flash) req.session.flash = 'Settings saved.';
635
- res.redirect('/admin/settings');
636
- });
637
-
638
- r.get('/backup', (req, res, next) => {
639
- try {
640
- const tmp = path.join(os.tmpdir(), `studio-backup-${Date.now()}.db`);
641
- db.prepare('VACUUM INTO ?').run(tmp);
642
- audit(db, who(req), 'backup_download', 'studio.db');
643
- res.download(tmp, `studio-backup-${localDateStr(getSetting(db, 'timezone', 'UTC'))}.db`, (err) => {
644
- fs.unlink(tmp, () => {});
645
- if (err && !res.headersSent) next(err);
646
- });
647
- } catch (err) { next(err); }
648
- });
649
-
650
- // ------------------------------------------------------------------- staff
651
- r.post('/staff', (req, res) => {
652
- const me = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.userId);
653
- if (!me || me.role !== 'owner') {
654
- req.session.flash = 'Only the owner can add staff.';
655
- return res.redirect('/admin/settings');
656
- }
657
- try {
658
- createUser(db, {
659
- email: req.body.email, password: req.body.password,
660
- name: req.body.name || '', role: 'staff',
661
- });
662
- req.session.flash = 'Staff account created.';
663
- audit(db, who(req), 'staff_create', req.body.email);
664
- } catch (err) {
665
- req.session.flash = err.message.includes('UNIQUE') ? 'That email already has an account.' : err.message;
666
- }
667
- res.redirect('/admin/settings');
668
- });
669
-
670
- // ------------------------------------------------------------------ import
671
- r.get('/import', (req, res) => {
672
- res.render('admin/import', { title: 'Mindbody import', result: null, kind: null, dryRun: true });
673
- });
674
-
675
- r.post('/import', (req, res) => {
676
- const kind = req.body.kind === 'passes' ? 'passes' : 'clients';
677
- const dryRun = req.body.dry_run === '1';
678
- const csv = String(req.body.csv || '');
679
- if (!csv.trim()) {
680
- req.session.flash = 'Paste CSV content first.';
681
- return res.redirect('/admin/import');
682
- }
683
- const result = kind === 'passes'
684
- ? importPasses(db, csv, { dryRun })
685
- : importClients(db, csv, { dryRun });
686
- audit(db, who(req), `import_${kind}${dryRun ? '_dryrun' : ''}`,
687
- `created:${result.created} updated:${result.updated} skipped:${result.skipped}`);
688
- res.render('admin/import', { title: 'Mindbody import', result, kind, dryRun });
689
- });
690
-
691
- return r;
692
- }
1
+ import { Router } from 'express';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { requireAdmin, makeMagicToken, createUser } from '../services/auth.js';
6
+ import { getSetting, setSetting, audit } from '../db/index.js';
7
+ import { generateInstances, upcomingInstances } from '../services/schedule.js';
8
+ import {
9
+ book, cancelBooking, cancelClass, markAttendance, updateInstance, BookingError,
10
+ } from '../services/booking.js';
11
+ import { importClients, importPasses } from '../services/importer.js';
12
+ import { emails } from '../services/mailer.js';
13
+ import { notifyPromoted } from '../services/notify.js';
14
+ import { localDateStr, localTimeStr, zonedToUtc, addDays, weekdayOf } from '../lib/time.js';
15
+
16
+ export default function adminRoutes(services) {
17
+ const { db, mailer } = services;
18
+ const r = Router();
19
+ r.use(requireAdmin);
20
+
21
+ const who = (req) => (req.session.userId
22
+ ? (db.prepare('SELECT email FROM users WHERE id = ?').get(req.session.userId) || {}).email || 'staff'
23
+ : 'staff');
24
+
25
+ // ---------------------------------------------------------------- dashboard
26
+ r.get('/', (req, res) => {
27
+ const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
28
+ const today = localDateStr(tz);
29
+ const [y, m, d] = today.split('-').map(Number);
30
+ const dayStart = zonedToUtc(tz, y, m, d, 0, 0).toISOString();
31
+ const dayEnd = zonedToUtc(tz, y, m, d, 23, 59).toISOString();
32
+ const todayClasses = db.prepare(
33
+ `SELECT ci.*, ct.name AS class_name, ct.color, i.name AS instructor_name,
34
+ (SELECT COUNT(*) FROM bookings b WHERE b.class_instance_id = ci.id
35
+ AND b.status IN ('booked','attended','no_show')) AS booked_count,
36
+ (SELECT COUNT(*) FROM bookings b WHERE b.class_instance_id = ci.id
37
+ AND b.status = 'waitlist') AS waitlist_count
38
+ FROM class_instances ci JOIN class_types ct ON ct.id = ci.class_type_id
39
+ LEFT JOIN instructors i ON i.id = ci.instructor_id
40
+ WHERE ci.starts_at BETWEEN ? AND ? ORDER BY ci.starts_at`
41
+ ).all(dayStart, dayEnd);
42
+
43
+ const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString();
44
+ const weekRevenue = db.prepare(
45
+ "SELECT COALESCE(SUM(amount_cents),0) total FROM payments WHERE status='paid' AND created_at >= ?"
46
+ ).get(weekAgo).total;
47
+ const pendingPayments = db.prepare(
48
+ `SELECT p.*, c.name AS client_name FROM payments p LEFT JOIN clients c ON c.id = p.client_id
49
+ WHERE p.status = 'pending' ORDER BY p.created_at DESC LIMIT 20`
50
+ ).all();
51
+ const soon = addDays(today, 30);
52
+ const expiringPasses = db.prepare(
53
+ `SELECT p.*, c.name AS client_name FROM passes p JOIN clients c ON c.id = p.client_id
54
+ WHERE p.credits_remaining > 0 AND p.expires_on IS NOT NULL AND p.expires_on BETWEEN ? AND ?
55
+ ORDER BY p.expires_on LIMIT 20`
56
+ ).all(today, soon);
57
+ res.render('admin/dashboard', {
58
+ title: 'Dashboard', todayClasses, weekRevenue, expiringPasses, pendingPayments,
59
+ });
60
+ });
61
+
62
+ // ---------------------------------------------------------- class types CRUD
63
+ r.get('/class-types', (req, res) => {
64
+ const types = db.prepare('SELECT * FROM class_types ORDER BY active DESC, name').all();
65
+ res.render('admin/class_types', { title: 'Class types', types });
66
+ });
67
+
68
+ r.post('/class-types', (req, res) => {
69
+ const b = req.body;
70
+ db.prepare(
71
+ 'INSERT INTO class_types (name, description, duration_min, capacity, drop_in_price_cents, credits_required, color) VALUES (?, ?, ?, ?, ?, ?, ?)'
72
+ ).run(b.name, b.description || '', Number(b.duration_min) || 60, Number(b.capacity) || 10,
73
+ Math.round(Number(b.drop_in_price || 0) * 100), Number(b.credits_required) || 1, b.color || '#4f7cac');
74
+ audit(db, who(req), 'class_type_create', b.name);
75
+ res.redirect('/admin/class-types');
76
+ });
77
+
78
+ r.post('/class-types/:id', (req, res) => {
79
+ const b = req.body;
80
+ if (b.action === 'toggle') {
81
+ db.prepare('UPDATE class_types SET active = 1 - active WHERE id = ?').run(req.params.id);
82
+ } else {
83
+ db.prepare(
84
+ 'UPDATE class_types SET name=?, description=?, duration_min=?, capacity=?, drop_in_price_cents=?, credits_required=?, color=? WHERE id=?'
85
+ ).run(b.name, b.description || '', Number(b.duration_min) || 60, Number(b.capacity) || 10,
86
+ Math.round(Number(b.drop_in_price || 0) * 100), Number(b.credits_required) || 1,
87
+ b.color || '#4f7cac', req.params.id);
88
+ }
89
+ audit(db, who(req), 'class_type_update', `class_type:${req.params.id}`);
90
+ res.redirect('/admin/class-types');
91
+ });
92
+
93
+ // ---------------------------------------------------------- instructors CRUD
94
+ r.get('/instructors', (req, res) => {
95
+ const instructors = db.prepare('SELECT * FROM instructors ORDER BY active DESC, name').all();
96
+ const accounts = db.prepare(
97
+ `SELECT u.id, u.name, u.email, u.created_at,
98
+ (SELECT COUNT(*) FROM instructor_class_assignments ica
99
+ JOIN class_instances ci ON ci.id = ica.class_id
100
+ WHERE ica.instructor_id = u.id AND ci.starts_at >= ?) AS upcoming_count
101
+ FROM users u WHERE u.role = 'instructor' ORDER BY u.name, u.email`
102
+ ).all(new Date().toISOString());
103
+ res.render('admin/instructors', { title: 'Instructors', instructors, accounts });
104
+ });
105
+
106
+ r.post('/instructors', (req, res) => {
107
+ db.prepare('INSERT INTO instructors (name, bio) VALUES (?, ?)').run(req.body.name, req.body.bio || '');
108
+ audit(db, who(req), 'instructor_create', req.body.name);
109
+ res.redirect('/admin/instructors');
110
+ });
111
+
112
+ // ------------------------------------------------------- instructor logins
113
+ r.get('/instructors/new', (req, res) => {
114
+ res.render('admin/instructor_new', { title: 'New instructor login', error: null, values: {} });
115
+ });
116
+
117
+ r.post('/instructors/new', (req, res) => {
118
+ const email = String(req.body.email || '').trim().toLowerCase();
119
+ const password = String(req.body.password || '');
120
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) || password.length < 8) {
121
+ return res.status(400).render('admin/instructor_new', {
122
+ title: 'New instructor login',
123
+ error: 'A valid email and a password of at least 8 characters are required.',
124
+ values: { name: req.body.name || '', email },
125
+ });
126
+ }
127
+ try {
128
+ const id = createUser(db, { email, password, name: req.body.name || '', role: 'instructor' });
129
+ audit(db, who(req), 'instructor_account_create', email);
130
+ req.session.flash = 'Instructor login created — now assign their classes.';
131
+ res.redirect(`/admin/instructors/${id}/classes`);
132
+ } catch (err) {
133
+ res.status(400).render('admin/instructor_new', {
134
+ title: 'New instructor login',
135
+ error: err.message.includes('UNIQUE') ? 'That email already has an account.' : err.message,
136
+ values: { name: req.body.name || '', email },
137
+ });
138
+ }
139
+ });
140
+
141
+ // Upcoming-class window shared by the assignment form's GET and POST so a
142
+ // save only rewrites assignments for the instances that were displayed.
143
+ const assignableInstances = () => db.prepare(
144
+ `SELECT ci.id, ci.starts_at, ci.capacity, ct.name AS class_name, i.name AS instructor_name
145
+ FROM class_instances ci JOIN class_types ct ON ct.id = ci.class_type_id
146
+ LEFT JOIN instructors i ON i.id = ci.instructor_id
147
+ WHERE ci.starts_at >= ? AND ci.status = 'scheduled' ORDER BY ci.starts_at`
148
+ ).all(new Date().toISOString());
149
+
150
+ const instructorAccount = (id) => db.prepare(
151
+ "SELECT * FROM users WHERE id = ? AND role = 'instructor'"
152
+ ).get(id);
153
+
154
+ r.get('/instructors/:id/classes', (req, res) => {
155
+ const account = instructorAccount(req.params.id);
156
+ if (!account) return res.status(404).render('error', { title: 'Not found', message: 'Instructor login not found.' });
157
+ const instances = assignableInstances();
158
+ const assigned = new Set(db.prepare(
159
+ 'SELECT class_id FROM instructor_class_assignments WHERE instructor_id = ?'
160
+ ).all(account.id).map((r2) => r2.class_id));
161
+ res.render('admin/instructor_classes', {
162
+ title: `Assign classes — ${account.name || account.email}`, account, instances, assigned,
163
+ });
164
+ });
165
+
166
+ r.post('/instructors/:id/classes', (req, res) => {
167
+ const account = instructorAccount(req.params.id);
168
+ if (!account) return res.status(404).render('error', { title: 'Not found', message: 'Instructor login not found.' });
169
+ const checked = new Set([].concat(req.body.class_ids || []).map(Number).filter(Number.isInteger));
170
+ const shown = assignableInstances();
171
+ db.transaction(() => {
172
+ const del = db.prepare('DELETE FROM instructor_class_assignments WHERE instructor_id = ? AND class_id = ?');
173
+ const ins = db.prepare('INSERT OR IGNORE INTO instructor_class_assignments (instructor_id, class_id) VALUES (?, ?)');
174
+ for (const inst of shown) {
175
+ if (checked.has(inst.id)) ins.run(account.id, inst.id);
176
+ else del.run(account.id, inst.id);
177
+ }
178
+ })();
179
+ audit(db, who(req), 'instructor_assign', `user:${account.id} classes:${checked.size}`);
180
+ req.session.flash = `Assignments saved — ${checked.size} upcoming class(es).`;
181
+ res.redirect(`/admin/instructors/${account.id}/classes`);
182
+ });
183
+
184
+ r.post('/instructors/:id', (req, res) => {
185
+ if (req.body.action === 'toggle') {
186
+ db.prepare('UPDATE instructors SET active = 1 - active WHERE id = ?').run(req.params.id);
187
+ } else {
188
+ db.prepare('UPDATE instructors SET name = ?, bio = ? WHERE id = ?')
189
+ .run(req.body.name, req.body.bio || '', req.params.id);
190
+ }
191
+ audit(db, who(req), 'instructor_update', `instructor:${req.params.id}`);
192
+ res.redirect('/admin/instructors');
193
+ });
194
+
195
+ // ---------------------------------------------------------- weekly rules CRUD
196
+ r.get('/rules', (req, res) => {
197
+ const rules = db.prepare(
198
+ `SELECT sr.*, ct.name AS class_name, i.name AS instructor_name
199
+ FROM schedule_rules sr JOIN class_types ct ON ct.id = sr.class_type_id
200
+ LEFT JOIN instructors i ON i.id = sr.instructor_id
201
+ ORDER BY sr.weekday, sr.start_time`
202
+ ).all();
203
+ const types = db.prepare('SELECT * FROM class_types WHERE active = 1 ORDER BY name').all();
204
+ const instructors = db.prepare('SELECT * FROM instructors WHERE active = 1 ORDER BY name').all();
205
+ res.render('admin/rules', { title: 'Weekly schedule rules', rules, types, instructors });
206
+ });
207
+
208
+ r.post('/rules', (req, res) => {
209
+ const b = req.body;
210
+ if (!/^\d{2}:\d{2}$/.test(b.start_time || '')) {
211
+ req.session.flash = 'Start time must be HH:MM.';
212
+ return res.redirect('/admin/rules');
213
+ }
214
+ db.prepare(
215
+ 'INSERT INTO schedule_rules (class_type_id, instructor_id, weekday, start_time, capacity_override, active_from, active_until) VALUES (?, ?, ?, ?, ?, ?, ?)'
216
+ ).run(Number(b.class_type_id), Number(b.instructor_id) || null, Number(b.weekday),
217
+ b.start_time, Number(b.capacity_override) || null, b.active_from || null, b.active_until || null);
218
+ const created = generateInstances(db);
219
+ audit(db, who(req), 'rule_create', `generated:${created}`);
220
+ req.session.flash = `Rule created — ${created} class instances materialized.`;
221
+ res.redirect('/admin/rules');
222
+ });
223
+
224
+ // Future classes from a rule that nobody has booked are safe to drop, and
225
+ // have to go when the rule changes or the old slot stays alongside the new.
226
+ const dropUnbookedFutureInstances = (ruleId) => db.prepare(
227
+ `DELETE FROM class_instances WHERE rule_id = ? AND starts_at > ? AND status = 'scheduled'
228
+ AND NOT EXISTS (SELECT 1 FROM bookings b WHERE b.class_instance_id = class_instances.id AND b.status != 'cancelled')`
229
+ ).run(ruleId, new Date().toISOString());
230
+
231
+ r.post('/rules/:id', (req, res) => {
232
+ const b = req.body;
233
+ if (b.action === 'delete') {
234
+ // keep past instances; drop future not-yet-booked ones from this rule
235
+ db.transaction(() => {
236
+ db.prepare('UPDATE schedule_rules SET active = 0 WHERE id = ?').run(req.params.id);
237
+ dropUnbookedFutureInstances(req.params.id);
238
+ })();
239
+ req.session.flash = 'Rule deactivated. Future empty instances removed; booked ones kept.';
240
+ } else {
241
+ if (!/^\d{2}:\d{2}$/.test(b.start_time || '')) {
242
+ req.session.flash = 'Start time must be HH:MM.';
243
+ return res.redirect('/admin/rules');
244
+ }
245
+ db.transaction(() => {
246
+ db.prepare(
247
+ 'UPDATE schedule_rules SET class_type_id=?, instructor_id=?, weekday=?, start_time=?, capacity_override=?, active_from=?, active_until=? WHERE id=?'
248
+ ).run(Number(b.class_type_id), Number(b.instructor_id) || null, Number(b.weekday),
249
+ b.start_time, Number(b.capacity_override) || null, b.active_from || null,
250
+ b.active_until || null, req.params.id);
251
+ dropUnbookedFutureInstances(req.params.id);
252
+ generateInstances(db);
253
+ })();
254
+ // Booked classes stay where they were, so say if any are now off-pattern.
255
+ const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
256
+ const stranded = db.prepare(
257
+ "SELECT starts_at FROM class_instances WHERE rule_id = ? AND starts_at > ? AND status = 'scheduled'"
258
+ ).all(req.params.id, new Date().toISOString()).filter((ci) => (
259
+ weekdayOf(localDateStr(tz, ci.starts_at)) !== Number(b.weekday)
260
+ || localTimeStr(tz, ci.starts_at) !== b.start_time
261
+ )).length;
262
+ req.session.flash = stranded
263
+ ? `Rule updated. ${stranded} booked class(es) are still at the old time; cancel them from the schedule if they've moved.`
264
+ : 'Rule updated and schedule regenerated.';
265
+ }
266
+ audit(db, who(req), 'rule_update', `rule:${req.params.id}`);
267
+ res.redirect('/admin/rules');
268
+ });
269
+
270
+ // ---------------------------------------------------------------- schedule
271
+ r.get('/schedule', (req, res) => {
272
+ const instances = upcomingInstances(db, { days: 14, includeCancelled: true });
273
+ const types = db.prepare('SELECT * FROM class_types WHERE active = 1 ORDER BY name').all();
274
+ const instructors = db.prepare('SELECT * FROM instructors WHERE active = 1 ORDER BY name').all();
275
+ res.render('admin/schedule', { title: 'Schedule', instances, types, instructors });
276
+ });
277
+
278
+ // one-off class instance
279
+ r.post('/schedule', (req, res) => {
280
+ const b = req.body;
281
+ const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
282
+ const type = db.prepare('SELECT * FROM class_types WHERE id = ?').get(Number(b.class_type_id));
283
+ if (!type || !/^\d{4}-\d{2}-\d{2}$/.test(b.date || '') || !/^\d{2}:\d{2}$/.test(b.time || '')) {
284
+ req.session.flash = 'Class type, date (YYYY-MM-DD) and time (HH:MM) are required.';
285
+ return res.redirect('/admin/schedule');
286
+ }
287
+ const [y, m, d] = b.date.split('-').map(Number);
288
+ const [hh, mm] = b.time.split(':').map(Number);
289
+ const startsAt = zonedToUtc(tz, y, m, d, hh, mm).toISOString().replace(/\.\d{3}Z$/, 'Z');
290
+ db.prepare(
291
+ "INSERT INTO class_instances (class_type_id, instructor_id, starts_at, duration_min, capacity, status, notes) VALUES (?, ?, ?, ?, ?, 'scheduled', ?)"
292
+ ).run(type.id, Number(b.instructor_id) || null, startsAt, type.duration_min,
293
+ Number(b.capacity) || type.capacity, b.notes || '');
294
+ audit(db, who(req), 'instance_create', startsAt);
295
+ req.session.flash = 'One-off class created.';
296
+ res.redirect('/admin/schedule');
297
+ });
298
+
299
+ r.post('/instances/:id/cancel', (req, res) => {
300
+ try {
301
+ const { instance, affected } = cancelClass(db, Number(req.params.id));
302
+ audit(db, who(req), 'class_cancel', `instance:${instance.id} bookings:${affected.length}`);
303
+ const studio = getSetting(db, 'studio_name', 'the studio');
304
+ const when = res.locals.fmtDt(instance.starts_at);
305
+ for (const bk of affected) {
306
+ const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(bk.client_id);
307
+ if (client) {
308
+ mailer.send({
309
+ to: client.email,
310
+ ...emails.classCancelled({ studio, clientName: client.name, className: instance.class_name, when }),
311
+ });
312
+ }
313
+ }
314
+ req.session.flash = `Class cancelled. ${affected.length} booking(s) refunded and notified.`;
315
+ } catch (err) {
316
+ if (!(err instanceof BookingError)) throw err;
317
+ req.session.flash = err.message;
318
+ }
319
+ res.redirect('/admin/schedule');
320
+ });
321
+
322
+ // ------------------------------------------------------------------ roster
323
+ r.get('/instances/:id', (req, res) => {
324
+ const inst = db.prepare(
325
+ `SELECT ci.*, ct.name AS class_name, ct.credits_required, i.name AS instructor_name
326
+ FROM class_instances ci JOIN class_types ct ON ct.id = ci.class_type_id
327
+ LEFT JOIN instructors i ON i.id = ci.instructor_id WHERE ci.id = ?`
328
+ ).get(req.params.id);
329
+ if (!inst) return res.status(404).render('error', { title: 'Not found', message: 'Class not found.' });
330
+ const roster = db.prepare(
331
+ `SELECT b.*, c.name AS client_name, c.email FROM bookings b JOIN clients c ON c.id = b.client_id
332
+ WHERE b.class_instance_id = ? AND b.status IN ('booked','attended','no_show')
333
+ ORDER BY b.created_at`
334
+ ).all(inst.id);
335
+ const waitlist = db.prepare(
336
+ `SELECT b.*, c.name AS client_name, c.email FROM bookings b JOIN clients c ON c.id = b.client_id
337
+ WHERE b.class_instance_id = ? AND b.status = 'waitlist' ORDER BY b.created_at`
338
+ ).all(inst.id);
339
+ // A retired instructor still shows as the current one, so saving doesn't unassign them.
340
+ const instructors = db.prepare('SELECT * FROM instructors WHERE active = 1 OR id = ? ORDER BY name')
341
+ .all(inst.instructor_id);
342
+ res.render('admin/roster', { title: `Roster — ${inst.class_name}`, inst, roster, waitlist, instructors });
343
+ });
344
+
345
+ r.post('/bookings/:id/status', (req, res) => {
346
+ const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.id);
347
+ if (!booking) return res.status(404).render('error', { title: 'Not found', message: 'Booking not found.' });
348
+ const status = req.body.status;
349
+ try {
350
+ if (status === 'cancelled') {
351
+ const result = cancelBooking(db, booking.id, { forceRefund: req.body.refund === '1', allowPast: true });
352
+ if (result.promoted) notifyPromoted(services, req, res, result.promoted, result.instance);
353
+ } else {
354
+ markAttendance(db, booking.id, status);
355
+ }
356
+ audit(db, who(req), `booking_${status}`, `booking:${booking.id}`);
357
+ } catch (err) {
358
+ if (!(err instanceof BookingError)) throw err;
359
+ req.session.flash = err.message;
360
+ }
361
+ res.redirect(`/admin/instances/${booking.class_instance_id}`);
362
+ });
363
+
364
+ r.post('/instances/:id/edit', (req, res) => {
365
+ const b = req.body;
366
+ const instructorId = Number(b.instructor_id) || null;
367
+ try {
368
+ if (instructorId && !db.prepare('SELECT id FROM instructors WHERE id = ?').get(instructorId)) {
369
+ throw new BookingError('bad_instructor', 'Unknown instructor.');
370
+ }
371
+ const { instance, promoted } = updateInstance(db, Number(req.params.id), {
372
+ capacity: Number(b.capacity), instructor_id: instructorId, notes: String(b.notes || '').trim(),
373
+ });
374
+ for (const p of promoted) notifyPromoted(services, req, res, p, instance);
375
+ audit(db, who(req), 'instance_update', `instance:${instance.id} capacity:${instance.capacity}`);
376
+ req.session.flash = promoted.length
377
+ ? `Class updated. ${promoted.length} moved up from the waitlist and emailed.`
378
+ : 'Class updated.';
379
+ } catch (err) {
380
+ if (!(err instanceof BookingError)) throw err;
381
+ req.session.flash = err.message;
382
+ }
383
+ res.redirect(`/admin/instances/${req.params.id}`);
384
+ });
385
+
386
+ r.post('/instances/:id/walkin', (req, res) => {
387
+ const email = String(req.body.email || '').trim().toLowerCase();
388
+ const name = String(req.body.name || '').trim();
389
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
390
+ req.session.flash = 'A valid email is required for walk-ins.';
391
+ return res.redirect(`/admin/instances/${req.params.id}`);
392
+ }
393
+ try {
394
+ db.transaction(() => {
395
+ let client = db.prepare('SELECT * FROM clients WHERE email = ?').get(email);
396
+ if (!client) {
397
+ const info = db.prepare(
398
+ "INSERT INTO clients (name, email, source) VALUES (?, ?, 'manual')"
399
+ ).run(name || email.split('@')[0], email);
400
+ client = { id: info.lastInsertRowid };
401
+ }
402
+ const paidWith = req.body.paid_with === 'comp' ? 'comp'
403
+ : (req.body.paid_with === 'auto' ? 'auto' : 'drop_in_manual');
404
+ book(db, client.id, Number(req.params.id), { paidWith, allowPast: true });
405
+ })();
406
+ req.session.flash = 'Walk-in added.';
407
+ audit(db, who(req), 'walkin_add', `instance:${req.params.id} ${email}`);
408
+ } catch (err) {
409
+ if (!(err instanceof BookingError)) throw err;
410
+ req.session.flash = err.message;
411
+ }
412
+ res.redirect(`/admin/instances/${req.params.id}`);
413
+ });
414
+
415
+ // ------------------------------------------------------------------ clients
416
+ r.get('/clients', (req, res) => {
417
+ const q = String(req.query.q || '').trim();
418
+ const clients = q
419
+ ? db.prepare(
420
+ `SELECT * FROM clients WHERE name LIKE ? OR email LIKE ? OR phone LIKE ? ORDER BY name LIMIT 200`
421
+ ).all(`%${q}%`, `%${q}%`, `%${q}%`)
422
+ : db.prepare('SELECT * FROM clients ORDER BY created_at DESC LIMIT 200').all();
423
+ res.render('admin/clients', { title: 'Clients', clients, q });
424
+ });
425
+
426
+ r.post('/clients', (req, res) => {
427
+ const email = String(req.body.email || '').trim().toLowerCase();
428
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
429
+ req.session.flash = 'A valid email is required.';
430
+ return res.redirect('/admin/clients');
431
+ }
432
+ try {
433
+ const info = db.prepare(
434
+ "INSERT INTO clients (name, email, phone, source) VALUES (?, ?, ?, 'manual')"
435
+ ).run(req.body.name || email.split('@')[0], email, req.body.phone || '');
436
+ audit(db, who(req), 'client_create', email);
437
+ res.redirect(`/admin/clients/${info.lastInsertRowid}`);
438
+ } catch (err) {
439
+ req.session.flash = err.message.includes('UNIQUE') ? 'A client with that email already exists.' : err.message;
440
+ res.redirect('/admin/clients');
441
+ }
442
+ });
443
+
444
+ r.get('/clients/:id', (req, res) => {
445
+ const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(req.params.id);
446
+ if (!client) return res.status(404).render('error', { title: 'Not found', message: 'Client not found.' });
447
+ const bookings = db.prepare(
448
+ `SELECT b.*, ci.starts_at, ct.name AS class_name FROM bookings b
449
+ JOIN class_instances ci ON ci.id = b.class_instance_id
450
+ JOIN class_types ct ON ct.id = ci.class_type_id
451
+ WHERE b.client_id = ? ORDER BY ci.starts_at DESC LIMIT 100`
452
+ ).all(client.id);
453
+ const passes = db.prepare('SELECT * FROM passes WHERE client_id = ? ORDER BY created_at DESC').all(client.id);
454
+ const memberships = db.prepare('SELECT * FROM memberships WHERE client_id = ? ORDER BY id DESC').all(client.id);
455
+ const payments = db.prepare('SELECT * FROM payments WHERE client_id = ? ORDER BY created_at DESC LIMIT 100').all(client.id);
456
+ const plans = db.prepare('SELECT * FROM membership_plans WHERE active = 1').all();
457
+ const packProducts = db.prepare('SELECT * FROM pack_products WHERE active = 1').all();
458
+ const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, client.id)}`;
459
+ // `client` is a reserved EJS option name — pass as clientInfo.
460
+ res.render('admin/client', {
461
+ title: client.name, clientInfo: client, bookings, passes, memberships, payments, plans, packProducts, magicUrl,
462
+ });
463
+ });
464
+
465
+ r.post('/clients/:id', (req, res) => {
466
+ const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(req.params.id);
467
+ if (!client) return res.status(404).render('error', { title: 'Not found', message: 'Client not found.' });
468
+ if (req.body.action === 'waiver_toggle') {
469
+ db.prepare('UPDATE clients SET waiver_signed_at = ? WHERE id = ?')
470
+ .run(client.waiver_signed_at ? null : new Date().toISOString(), client.id);
471
+ } else {
472
+ db.prepare('UPDATE clients SET name = ?, phone = ?, notes = ? WHERE id = ?')
473
+ .run(req.body.name || client.name, req.body.phone ?? client.phone, req.body.notes ?? client.notes, client.id);
474
+ }
475
+ audit(db, who(req), 'client_update', `client:${client.id}`);
476
+ res.redirect(`/admin/clients/${client.id}`);
477
+ });
478
+
479
+ r.post('/clients/:id/passes', (req, res) => {
480
+ const b = req.body;
481
+ const credits = Number(b.credits) || 10;
482
+ db.prepare(
483
+ `INSERT INTO passes (client_id, name, credits_total, credits_remaining, expires_on, price_paid_cents, source)
484
+ VALUES (?, ?, ?, ?, ?, ?, 'manual')`
485
+ ).run(req.params.id, b.name || `${credits}-class pack`, credits, credits,
486
+ b.expires_on || null, Math.round(Number(b.price || 0) * 100));
487
+ if (b.record_payment === '1' && Number(b.price) > 0) {
488
+ db.prepare(
489
+ `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
490
+ VALUES (?, ?, ?, ?, ?, 'pack', 'paid')`
491
+ ).run(req.params.id, Math.round(Number(b.price) * 100), getSetting(db, 'currency', 'HKD'),
492
+ b.method || 'cash', b.name || 'pack');
493
+ }
494
+ audit(db, who(req), 'pass_add', `client:${req.params.id}`);
495
+ res.redirect(`/admin/clients/${req.params.id}`);
496
+ });
497
+
498
+ r.post('/clients/:id/memberships', (req, res) => {
499
+ const b = req.body;
500
+ if (b.action === 'status' && b.membership_id) {
501
+ db.prepare('UPDATE memberships SET status = ? WHERE id = ? AND client_id = ?')
502
+ .run(b.status, b.membership_id, req.params.id);
503
+ audit(db, who(req), 'membership_status', `membership:${b.membership_id} → ${b.status}`);
504
+ return res.redirect(`/admin/clients/${req.params.id}`);
505
+ }
506
+ const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
507
+ const today = localDateStr(tz);
508
+ const plan = b.plan_id ? db.prepare('SELECT * FROM membership_plans WHERE id = ?').get(b.plan_id) : null;
509
+ db.prepare(
510
+ `INSERT INTO memberships (client_id, plan_id, plan_name, status, started_on, unlimited, credits_per_month, cycle_started_on)
511
+ VALUES (?, ?, ?, 'active', ?, ?, ?, ?)`
512
+ ).run(req.params.id, plan ? plan.id : null,
513
+ plan ? plan.name : (b.plan_name || 'Membership'), today,
514
+ plan ? plan.unlimited : (b.unlimited === '1' ? 1 : 0),
515
+ plan ? plan.credits_per_month : (Number(b.credits_per_month) || null), today);
516
+ if (plan && b.record_payment === '1') {
517
+ db.prepare(
518
+ `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
519
+ VALUES (?, ?, ?, ?, ?, 'membership', 'paid')`
520
+ ).run(req.params.id, plan.price_cents, getSetting(db, 'currency', 'HKD'), b.method || 'cash', plan.name);
521
+ }
522
+ audit(db, who(req), 'membership_add', `client:${req.params.id}`);
523
+ res.redirect(`/admin/clients/${req.params.id}`);
524
+ });
525
+
526
+ r.post('/clients/:id/payments', (req, res) => {
527
+ const b = req.body;
528
+ db.prepare(
529
+ `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
530
+ VALUES (?, ?, ?, ?, ?, ?, 'paid')`
531
+ ).run(req.params.id, Math.round(Number(b.amount || 0) * 100), getSetting(db, 'currency', 'HKD'),
532
+ b.method || 'cash', b.reference || '', b.what || 'drop_in');
533
+ audit(db, who(req), 'payment_record', `client:${req.params.id}`);
534
+ res.redirect(`/admin/clients/${req.params.id}`);
535
+ });
536
+
537
+ r.post('/payments/:id/paid', (req, res) => {
538
+ db.prepare("UPDATE payments SET status = 'paid', method = ? WHERE id = ? AND status = 'pending'")
539
+ .run(req.body.method || 'cash', req.params.id);
540
+ audit(db, who(req), 'payment_mark_paid', `payment:${req.params.id}`);
541
+ res.redirect(req.get('referer') || '/admin');
542
+ });
543
+
544
+ // ---------------------------------------------------------------- products
545
+ r.get('/products', (req, res) => {
546
+ const packs = db.prepare('SELECT * FROM pack_products ORDER BY active DESC, price_cents').all();
547
+ const plans = db.prepare('SELECT * FROM membership_plans ORDER BY active DESC, price_cents').all();
548
+ res.render('admin/products', { title: 'Products', packs, plans });
549
+ });
550
+
551
+ r.post('/products/pack', (req, res) => {
552
+ const b = req.body;
553
+ db.prepare(
554
+ 'INSERT INTO pack_products (name, credits, price_cents, validity_days, stripe_price_id) VALUES (?, ?, ?, ?, ?)'
555
+ ).run(b.name, Number(b.credits) || 10, Math.round(Number(b.price || 0) * 100),
556
+ Number(b.validity_days) || null, b.stripe_price_id || null);
557
+ audit(db, who(req), 'pack_product_create', b.name);
558
+ res.redirect('/admin/products');
559
+ });
560
+
561
+ r.post('/products/pack/:id', (req, res) => {
562
+ const b = req.body;
563
+ if (b.action === 'toggle') {
564
+ db.prepare('UPDATE pack_products SET active = 1 - active WHERE id = ?').run(req.params.id);
565
+ } else {
566
+ db.prepare(
567
+ 'UPDATE pack_products SET name=?, credits=?, price_cents=?, validity_days=?, stripe_price_id=? WHERE id=?'
568
+ ).run(b.name, Number(b.credits) || 10, Math.round(Number(b.price || 0) * 100),
569
+ Number(b.validity_days) || null, b.stripe_price_id || null, req.params.id);
570
+ }
571
+ res.redirect('/admin/products');
572
+ });
573
+
574
+ r.post('/products/plan', (req, res) => {
575
+ const b = req.body;
576
+ db.prepare(
577
+ 'INSERT INTO membership_plans (name, price_cents, unlimited, credits_per_month, stripe_price_id) VALUES (?, ?, ?, ?, ?)'
578
+ ).run(b.name, Math.round(Number(b.price || 0) * 100), b.unlimited === '1' ? 1 : 0,
579
+ b.unlimited === '1' ? null : Number(b.credits_per_month) || 8, b.stripe_price_id || null);
580
+ audit(db, who(req), 'plan_create', b.name);
581
+ res.redirect('/admin/products');
582
+ });
583
+
584
+ r.post('/products/plan/:id', (req, res) => {
585
+ const b = req.body;
586
+ if (b.action === 'toggle') {
587
+ db.prepare('UPDATE membership_plans SET active = 1 - active WHERE id = ?').run(req.params.id);
588
+ } else {
589
+ db.prepare(
590
+ 'UPDATE membership_plans SET name=?, price_cents=?, unlimited=?, credits_per_month=?, stripe_price_id=? WHERE id=?'
591
+ ).run(b.name, Math.round(Number(b.price || 0) * 100), b.unlimited === '1' ? 1 : 0,
592
+ b.unlimited === '1' ? null : Number(b.credits_per_month) || 8, b.stripe_price_id || null, req.params.id);
593
+ }
594
+ res.redirect('/admin/products');
595
+ });
596
+
597
+ // ----------------------------------------------------------------- reports
598
+ function revenueByMonth() {
599
+ const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
600
+ const rows = db.prepare(
601
+ "SELECT created_at, amount_cents, what FROM payments WHERE status = 'paid'"
602
+ ).all();
603
+ const byMonth = new Map();
604
+ for (const p of rows) {
605
+ const month = localDateStr(tz, p.created_at).slice(0, 7);
606
+ if (!byMonth.has(month)) byMonth.set(month, { month, total: 0, drop_in: 0, pack: 0, membership: 0, count: 0 });
607
+ const m = byMonth.get(month);
608
+ m.total += p.amount_cents;
609
+ m[p.what] += p.amount_cents;
610
+ m.count++;
611
+ }
612
+ return [...byMonth.values()].sort((a, b) => b.month.localeCompare(a.month));
613
+ }
614
+
615
+ function attendanceByType() {
616
+ return db.prepare(
617
+ `SELECT ct.name AS class_type,
618
+ SUM(CASE WHEN b.status = 'attended' THEN 1 ELSE 0 END) AS attended,
619
+ SUM(CASE WHEN b.status = 'no_show' THEN 1 ELSE 0 END) AS no_shows,
620
+ SUM(CASE WHEN b.status = 'booked' THEN 1 ELSE 0 END) AS booked,
621
+ COUNT(*) AS total
622
+ FROM bookings b
623
+ JOIN class_instances ci ON ci.id = b.class_instance_id
624
+ JOIN class_types ct ON ct.id = ci.class_type_id
625
+ WHERE b.status IN ('attended','no_show','booked')
626
+ GROUP BY ct.id ORDER BY total DESC`
627
+ ).all();
628
+ }
629
+
630
+ r.get('/reports', (req, res) => {
631
+ res.render('admin/reports', {
632
+ title: 'Reports', revenue: revenueByMonth(), attendance: attendanceByType(),
633
+ });
634
+ });
635
+
636
+ r.get('/reports.csv', (req, res) => {
637
+ const report = req.query.report === 'attendance' ? 'attendance' : 'revenue';
638
+ const rows = report === 'attendance' ? attendanceByType() : revenueByMonth();
639
+ if (!rows.length) return res.type('text/csv').send('');
640
+ const headers = Object.keys(rows[0]);
641
+ const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
642
+ const csv = [headers.join(','), ...rows.map((r2) => headers.map((h) => esc(r2[h])).join(','))].join('\r\n');
643
+ res.setHeader('Content-Disposition', `attachment; filename="${report}.csv"`);
644
+ res.type('text/csv').send(csv);
645
+ });
646
+
647
+ // ---------------------------------------------------------------- settings
648
+ r.get('/settings', (req, res) => {
649
+ res.render('admin/settings', { title: 'Settings' });
650
+ });
651
+
652
+ r.post('/settings', (req, res) => {
653
+ const b = req.body;
654
+ if (b.studio_name) setSetting(db, 'studio_name', b.studio_name.trim());
655
+ if (b.currency) setSetting(db, 'currency', b.currency.toUpperCase().slice(0, 3));
656
+ if (b.timezone) {
657
+ try {
658
+ new Intl.DateTimeFormat('en', { timeZone: b.timezone });
659
+ setSetting(db, 'timezone', b.timezone);
660
+ } catch { req.session.flash = 'Invalid timezone — not saved.'; }
661
+ }
662
+ if (b.cancellation_window_hours !== undefined) {
663
+ setSetting(db, 'cancellation_window_hours', String(Math.max(0, Number(b.cancellation_window_hours) || 0)));
664
+ }
665
+ if (b.late_cancel_policy) {
666
+ setSetting(db, 'late_cancel_policy', b.late_cancel_policy === 'refund' ? 'refund' : 'forfeit');
667
+ }
668
+ if (b.waiver_markdown !== undefined) setSetting(db, 'waiver_markdown', b.waiver_markdown);
669
+ audit(db, who(req), 'settings_update', 'settings');
670
+ if (!req.session.flash) req.session.flash = 'Settings saved.';
671
+ res.redirect('/admin/settings');
672
+ });
673
+
674
+ r.get('/backup', (req, res, next) => {
675
+ try {
676
+ const tmp = path.join(os.tmpdir(), `studio-backup-${Date.now()}.db`);
677
+ db.prepare('VACUUM INTO ?').run(tmp);
678
+ audit(db, who(req), 'backup_download', 'studio.db');
679
+ res.download(tmp, `studio-backup-${localDateStr(getSetting(db, 'timezone', 'UTC'))}.db`, (err) => {
680
+ fs.unlink(tmp, () => {});
681
+ if (err && !res.headersSent) next(err);
682
+ });
683
+ } catch (err) { next(err); }
684
+ });
685
+
686
+ // ------------------------------------------------------------------- staff
687
+ r.post('/staff', (req, res) => {
688
+ const me = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.userId);
689
+ if (!me || me.role !== 'owner') {
690
+ req.session.flash = 'Only the owner can add staff.';
691
+ return res.redirect('/admin/settings');
692
+ }
693
+ try {
694
+ createUser(db, {
695
+ email: req.body.email, password: req.body.password,
696
+ name: req.body.name || '', role: 'staff',
697
+ });
698
+ req.session.flash = 'Staff account created.';
699
+ audit(db, who(req), 'staff_create', req.body.email);
700
+ } catch (err) {
701
+ req.session.flash = err.message.includes('UNIQUE') ? 'That email already has an account.' : err.message;
702
+ }
703
+ res.redirect('/admin/settings');
704
+ });
705
+
706
+ // ------------------------------------------------------------------ import
707
+ r.get('/import', (req, res) => {
708
+ res.render('admin/import', { title: 'Mindbody import', result: null, kind: null, dryRun: true });
709
+ });
710
+
711
+ r.post('/import', (req, res) => {
712
+ const kind = req.body.kind === 'passes' ? 'passes' : 'clients';
713
+ const dryRun = req.body.dry_run === '1';
714
+ const csv = String(req.body.csv || '');
715
+ if (!csv.trim()) {
716
+ req.session.flash = 'Paste CSV content first.';
717
+ return res.redirect('/admin/import');
718
+ }
719
+ const result = kind === 'passes'
720
+ ? importPasses(db, csv, { dryRun })
721
+ : importClients(db, csv, { dryRun });
722
+ audit(db, who(req), `import_${kind}${dryRun ? '_dryrun' : ''}`,
723
+ `created:${result.created} updated:${result.updated} skipped:${result.skipped}`);
724
+ res.render('admin/import', { title: 'Mindbody import', result, kind, dryRun });
725
+ });
726
+
727
+ return r;
728
+ }