studio-os 0.2.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.
- package/.dockerignore +7 -0
- package/.env.example +34 -0
- package/CHANGELOG.md +86 -0
- package/Dockerfile +25 -0
- package/LICENSE +21 -0
- package/PROGRESS.md +60 -0
- package/README.md +253 -0
- package/SPEC.md +130 -0
- package/docker-compose.yml +26 -0
- package/docs/screenshots/admin-client.png +0 -0
- package/docs/screenshots/admin-dashboard.png +0 -0
- package/docs/screenshots/admin-reports.png +0 -0
- package/docs/screenshots/admin-roster.png +0 -0
- package/docs/screenshots/admin-schedule.png +0 -0
- package/docs/screenshots/public-booking.png +0 -0
- package/docs/screenshots/public-buy.png +0 -0
- package/docs/screenshots/public-schedule.png +0 -0
- package/package.json +54 -0
- package/public/css/custom.css +40 -0
- package/public/js/icon.svg +4 -0
- package/public/js/sw.js +23 -0
- package/public/manifest.json +17 -0
- package/public/vendor/htmx.min.js +1 -0
- package/public/vendor/pico.min.css +4 -0
- package/scripts/import-mindbody.mjs +109 -0
- package/scripts/seed.mjs +147 -0
- package/src/app.js +141 -0
- package/src/db/002-instructor-role.sql +26 -0
- package/src/db/index.js +82 -0
- package/src/db/schema.sql +164 -0
- package/src/lib/ratelimit.js +50 -0
- package/src/lib/time.js +87 -0
- package/src/routes/admin.js +692 -0
- package/src/routes/auth.js +34 -0
- package/src/routes/instructor.js +82 -0
- package/src/routes/me.js +101 -0
- package/src/routes/public.js +215 -0
- package/src/routes/setup.js +55 -0
- package/src/routes/webhooks.js +28 -0
- package/src/server.js +29 -0
- package/src/services/auth.js +81 -0
- package/src/services/booking.js +261 -0
- package/src/services/importer.js +201 -0
- package/src/services/mailer.js +92 -0
- package/src/services/schedule.js +71 -0
- package/src/services/stripe.js +169 -0
- package/test/booking.test.js +346 -0
- package/test/fixtures/mindbody-clients.csv +9 -0
- package/test/fixtures/mindbody-passes.csv +8 -0
- package/test/helpers.js +59 -0
- package/test/importer.test.js +173 -0
- package/test/instructor.test.js +217 -0
- package/test/ratelimit.test.js +123 -0
- package/test/schedule.test.js +102 -0
- package/test/stripe.test.js +249 -0
- package/test/views.test.js +38 -0
- package/test/web.test.js +392 -0
- package/views/admin/class_types.ejs +45 -0
- package/views/admin/client.ejs +149 -0
- package/views/admin/clients.ejs +33 -0
- package/views/admin/dashboard.ejs +55 -0
- package/views/admin/import.ejs +39 -0
- package/views/admin/instructor_classes.ejs +21 -0
- package/views/admin/instructor_new.ejs +15 -0
- package/views/admin/instructors.ejs +49 -0
- package/views/admin/products.ejs +83 -0
- package/views/admin/reports.ejs +34 -0
- package/views/admin/roster.ejs +58 -0
- package/views/admin/rules.ejs +71 -0
- package/views/admin/schedule.ejs +50 -0
- package/views/admin/settings.ejs +53 -0
- package/views/error.ejs +7 -0
- package/views/instructor/roster.ejs +27 -0
- package/views/instructor/schedule.ejs +21 -0
- package/views/login.ejs +11 -0
- package/views/partials/foot.ejs +11 -0
- package/views/partials/head.ejs +54 -0
- package/views/public/book_result.ejs +26 -0
- package/views/public/buy.ejs +37 -0
- package/views/public/buy_manual.ejs +17 -0
- package/views/public/buy_thanks.ejs +7 -0
- package/views/public/class.ejs +29 -0
- package/views/public/magic_request.ejs +21 -0
- package/views/public/me.ejs +47 -0
- package/views/public/schedule.ejs +49 -0
- package/views/setup.ejs +53 -0
|
@@ -0,0 +1,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, 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
|
+
}
|