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,34 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { authenticate } from '../services/auth.js';
|
|
3
|
+
import { audit } from '../db/index.js';
|
|
4
|
+
|
|
5
|
+
export default function authRoutes({ db }) {
|
|
6
|
+
const r = Router();
|
|
7
|
+
|
|
8
|
+
const homeFor = (user) => (user.role === 'instructor' ? '/instructor/schedule' : '/admin');
|
|
9
|
+
|
|
10
|
+
// One login form for every staff role; /login is a friendly alias.
|
|
11
|
+
r.get('/login', (req, res) => res.redirect('/admin/login'));
|
|
12
|
+
|
|
13
|
+
r.get('/admin/login', (req, res) => {
|
|
14
|
+
if (req.session.userId && res.locals.user) return res.redirect(homeFor(res.locals.user));
|
|
15
|
+
res.render('login', { title: 'Staff login', error: null });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
r.post('/admin/login', (req, res) => {
|
|
19
|
+
const user = authenticate(db, req.body.email, req.body.password);
|
|
20
|
+
if (!user) {
|
|
21
|
+
return res.status(401).render('login', { title: 'Staff login', error: 'Invalid email or password.' });
|
|
22
|
+
}
|
|
23
|
+
req.session.userId = user.id;
|
|
24
|
+
audit(db, user.email, 'login', 'user:' + user.id);
|
|
25
|
+
res.redirect(homeFor(user));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
r.post('/admin/logout', (req, res) => {
|
|
29
|
+
req.session = null;
|
|
30
|
+
res.redirect('/');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return r;
|
|
34
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { requireInstructor } from '../services/auth.js';
|
|
3
|
+
import { getSetting, audit } from '../db/index.js';
|
|
4
|
+
import { markAttendance, BookingError } from '../services/booking.js';
|
|
5
|
+
import { localDateStr, zonedToUtc } from '../lib/time.js';
|
|
6
|
+
|
|
7
|
+
export default function instructorRoutes(services) {
|
|
8
|
+
const { db } = services;
|
|
9
|
+
const r = Router();
|
|
10
|
+
r.use(requireInstructor);
|
|
11
|
+
|
|
12
|
+
const isAssigned = (instructorId, classId) => !!db.prepare(
|
|
13
|
+
'SELECT 1 FROM instructor_class_assignments WHERE instructor_id = ? AND class_id = ?'
|
|
14
|
+
).get(instructorId, classId);
|
|
15
|
+
|
|
16
|
+
// From studio-local midnight today, so a class stays visible for check-in
|
|
17
|
+
// while it is running, not just before it starts.
|
|
18
|
+
const todayStartUtc = () => {
|
|
19
|
+
const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
|
|
20
|
+
const [y, m, d] = localDateStr(tz).split('-').map(Number);
|
|
21
|
+
return zonedToUtc(tz, y, m, d, 0, 0).toISOString();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------- schedule
|
|
25
|
+
r.get('/schedule', (req, res) => {
|
|
26
|
+
const classes = db.prepare(
|
|
27
|
+
`SELECT ci.*, ct.name AS class_name, ct.color,
|
|
28
|
+
(SELECT COUNT(*) FROM bookings b WHERE b.class_instance_id = ci.id
|
|
29
|
+
AND b.status IN ('booked','attended','no_show')) AS booked_count
|
|
30
|
+
FROM instructor_class_assignments ica
|
|
31
|
+
JOIN class_instances ci ON ci.id = ica.class_id
|
|
32
|
+
JOIN class_types ct ON ct.id = ci.class_type_id
|
|
33
|
+
WHERE ica.instructor_id = ? AND ci.starts_at >= ?
|
|
34
|
+
ORDER BY ci.starts_at`
|
|
35
|
+
).all(req.session.userId, todayStartUtc());
|
|
36
|
+
res.render('instructor/schedule', { title: 'My schedule', classes });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ------------------------------------------------------------------ roster
|
|
40
|
+
r.get('/classes/:id/roster', (req, res) => {
|
|
41
|
+
if (!isAssigned(req.session.userId, Number(req.params.id))) {
|
|
42
|
+
return res.status(403).render('error', {
|
|
43
|
+
title: 'Forbidden', message: 'You are not assigned to this class.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const inst = db.prepare(
|
|
47
|
+
`SELECT ci.*, ct.name AS class_name FROM class_instances ci
|
|
48
|
+
JOIN class_types ct ON ct.id = ci.class_type_id WHERE ci.id = ?`
|
|
49
|
+
).get(req.params.id);
|
|
50
|
+
if (!inst) return res.status(404).render('error', { title: 'Not found', message: 'Class not found.' });
|
|
51
|
+
const roster = db.prepare(
|
|
52
|
+
`SELECT b.*, c.name AS client_name FROM bookings b JOIN clients c ON c.id = b.client_id
|
|
53
|
+
WHERE b.class_instance_id = ? AND b.status IN ('booked','attended','no_show','cancelled')
|
|
54
|
+
ORDER BY b.status = 'cancelled', b.created_at`
|
|
55
|
+
).all(inst.id);
|
|
56
|
+
res.render('instructor/roster', { title: `Roster — ${inst.class_name}`, inst, roster });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ----------------------------------------------------------------- check-in
|
|
60
|
+
r.post('/classes/:id/checkin/:booking_id', (req, res) => {
|
|
61
|
+
const classId = Number(req.params.id);
|
|
62
|
+
if (!isAssigned(req.session.userId, classId)) {
|
|
63
|
+
return res.status(403).render('error', {
|
|
64
|
+
title: 'Forbidden', message: 'You are not assigned to this class.',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.booking_id);
|
|
68
|
+
if (!booking || booking.class_instance_id !== classId) {
|
|
69
|
+
return res.status(404).render('error', { title: 'Not found', message: 'Booking not found in this class.' });
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
markAttendance(db, booking.id, 'attended');
|
|
73
|
+
audit(db, res.locals.user.email, 'booking_attended', `booking:${booking.id}`);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (!(err instanceof BookingError)) throw err;
|
|
76
|
+
req.session.flash = err.message;
|
|
77
|
+
}
|
|
78
|
+
res.redirect(`/instructor/classes/${classId}/roster`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return r;
|
|
82
|
+
}
|
package/src/routes/me.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Client self-service via signed magic links. No passwords.
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { verifyMagicToken, makeMagicToken } from '../services/auth.js';
|
|
4
|
+
import { cancelBooking, BookingError } from '../services/booking.js';
|
|
5
|
+
import { getSetting, audit } from '../db/index.js';
|
|
6
|
+
import { emails } from '../services/mailer.js';
|
|
7
|
+
|
|
8
|
+
export default function meRoutes(services) {
|
|
9
|
+
const { db, mailer } = services;
|
|
10
|
+
const r = Router();
|
|
11
|
+
|
|
12
|
+
function requireClient(req, res) {
|
|
13
|
+
const token = req.query.token || req.body.token;
|
|
14
|
+
const clientId = verifyMagicToken(db, token);
|
|
15
|
+
if (!clientId) {
|
|
16
|
+
res.status(401).render('public/magic_request', {
|
|
17
|
+
title: 'My bookings', sent: false, magicUrl: null,
|
|
18
|
+
error: 'That link is invalid or has expired. Request a new one below.',
|
|
19
|
+
});
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(clientId);
|
|
23
|
+
if (!client) {
|
|
24
|
+
res.status(401).render('public/magic_request', {
|
|
25
|
+
title: 'My bookings', sent: false, magicUrl: null, error: 'Unknown client.',
|
|
26
|
+
});
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return { client, token };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
r.get('/', (req, res) => {
|
|
33
|
+
const auth = requireClient(req, res);
|
|
34
|
+
if (!auth) return;
|
|
35
|
+
const { client, token } = auth;
|
|
36
|
+
const nowIso = new Date().toISOString();
|
|
37
|
+
const bookings = db.prepare(
|
|
38
|
+
`SELECT b.*, ci.starts_at, ci.status AS instance_status, ct.name AS class_name,
|
|
39
|
+
i.name AS instructor_name
|
|
40
|
+
FROM bookings b
|
|
41
|
+
JOIN class_instances ci ON ci.id = b.class_instance_id
|
|
42
|
+
JOIN class_types ct ON ct.id = ci.class_type_id
|
|
43
|
+
LEFT JOIN instructors i ON i.id = ci.instructor_id
|
|
44
|
+
WHERE b.client_id = ? AND b.status IN ('booked','waitlist') AND ci.starts_at >= ?
|
|
45
|
+
ORDER BY ci.starts_at`
|
|
46
|
+
).all(client.id, nowIso);
|
|
47
|
+
const passes = db.prepare(
|
|
48
|
+
'SELECT * FROM passes WHERE client_id = ? AND credits_remaining > 0 ORDER BY expires_on IS NULL, expires_on'
|
|
49
|
+
).all(client.id);
|
|
50
|
+
const memberships = db.prepare(
|
|
51
|
+
"SELECT * FROM memberships WHERE client_id = ? AND status = 'active'"
|
|
52
|
+
).all(client.id);
|
|
53
|
+
const windowHours = Number(getSetting(db, 'cancellation_window_hours', '12'));
|
|
54
|
+
// NB: the local is named clientInfo because `client` is a reserved EJS
|
|
55
|
+
// option name (client-mode compilation) when present in render data.
|
|
56
|
+
res.render('public/me', {
|
|
57
|
+
title: 'My bookings', clientInfo: client, token, bookings, passes, memberships, windowHours,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
r.post('/cancel/:bookingId', (req, res) => {
|
|
62
|
+
const auth = requireClient(req, res);
|
|
63
|
+
if (!auth) return;
|
|
64
|
+
const { client, token } = auth;
|
|
65
|
+
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.bookingId);
|
|
66
|
+
if (!booking || booking.client_id !== client.id) {
|
|
67
|
+
return res.status(404).render('error', { title: 'Not found', message: 'Booking not found.' });
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const result = cancelBooking(db, booking.id);
|
|
71
|
+
audit(db, client.email, 'client_cancel', `booking:${booking.id}`);
|
|
72
|
+
const studio = getSetting(db, 'studio_name', 'the studio');
|
|
73
|
+
const inst = result.instance;
|
|
74
|
+
const when = res.locals.fmtDt(inst.starts_at);
|
|
75
|
+
mailer.send({
|
|
76
|
+
to: client.email,
|
|
77
|
+
...emails.cancellation({ studio, clientName: client.name, className: inst.class_name, when, refunded: result.refunded }),
|
|
78
|
+
});
|
|
79
|
+
if (result.promoted) {
|
|
80
|
+
const promotedClient = db.prepare('SELECT * FROM clients WHERE id = ?').get(result.promoted.client_id);
|
|
81
|
+
const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, promotedClient.id)}`;
|
|
82
|
+
mailer.send({
|
|
83
|
+
to: promotedClient.email,
|
|
84
|
+
...emails.waitlistPromotion({ studio, clientName: promotedClient.name, className: inst.class_name, when, magicUrl }),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
req.session.flash = result.refunded
|
|
88
|
+
? 'Booking cancelled — your credit has been returned.'
|
|
89
|
+
: (result.late ? 'Booking cancelled. This was a late cancellation, so the credit was not returned.' : 'Booking cancelled.');
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (err instanceof BookingError) {
|
|
92
|
+
req.session.flash = err.message;
|
|
93
|
+
} else {
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
res.redirect(`/me?token=${encodeURIComponent(token)}`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return r;
|
|
101
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { marked } from 'marked';
|
|
3
|
+
import { getSetting, audit } from '../db/index.js';
|
|
4
|
+
import { upcomingInstances } from '../services/schedule.js';
|
|
5
|
+
import { book, resolvePayment, BookingError } from '../services/booking.js';
|
|
6
|
+
import { makeMagicToken } from '../services/auth.js';
|
|
7
|
+
import { emails } from '../services/mailer.js';
|
|
8
|
+
|
|
9
|
+
function findOrCreateClient(db, name, email) {
|
|
10
|
+
const cleanEmail = String(email || '').trim().toLowerCase();
|
|
11
|
+
let client = db.prepare('SELECT * FROM clients WHERE email = ?').get(cleanEmail);
|
|
12
|
+
if (!client) {
|
|
13
|
+
const info = db.prepare(
|
|
14
|
+
"INSERT INTO clients (name, email, source) VALUES (?, ?, 'self')"
|
|
15
|
+
).run(String(name || cleanEmail.split('@')[0]).trim(), cleanEmail);
|
|
16
|
+
client = db.prepare('SELECT * FROM clients WHERE id = ?').get(info.lastInsertRowid);
|
|
17
|
+
} else if (name && name.trim() && client.name !== name.trim()) {
|
|
18
|
+
// keep the freshest name the client typed
|
|
19
|
+
db.prepare('UPDATE clients SET name = ? WHERE id = ?').run(name.trim(), client.id);
|
|
20
|
+
client.name = name.trim();
|
|
21
|
+
}
|
|
22
|
+
return client;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function loadInstanceFull(db, id) {
|
|
26
|
+
return db.prepare(
|
|
27
|
+
`SELECT ci.*, ct.name AS class_name, ct.description AS class_description, ct.color,
|
|
28
|
+
ct.credits_required, ct.drop_in_price_cents, i.name AS instructor_name,
|
|
29
|
+
(SELECT COUNT(*) FROM bookings b WHERE b.class_instance_id = ci.id
|
|
30
|
+
AND b.status IN ('booked','attended','no_show')) AS booked_count
|
|
31
|
+
FROM class_instances ci
|
|
32
|
+
JOIN class_types ct ON ct.id = ci.class_type_id
|
|
33
|
+
LEFT JOIN instructors i ON i.id = ci.instructor_id
|
|
34
|
+
WHERE ci.id = ?`
|
|
35
|
+
).get(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function publicRoutes(services) {
|
|
39
|
+
const { db, mailer } = services;
|
|
40
|
+
const r = Router();
|
|
41
|
+
|
|
42
|
+
// Public schedule, next 14 days, filterable.
|
|
43
|
+
r.get('/', (req, res) => {
|
|
44
|
+
const classTypeId = Number(req.query.type) || null;
|
|
45
|
+
const instructorId = Number(req.query.instructor) || null;
|
|
46
|
+
const instances = upcomingInstances(db, { days: 14, classTypeId, instructorId });
|
|
47
|
+
const types = db.prepare('SELECT * FROM class_types WHERE active = 1 ORDER BY name').all();
|
|
48
|
+
const instructors = db.prepare('SELECT * FROM instructors WHERE active = 1 ORDER BY name').all();
|
|
49
|
+
res.render('public/schedule', {
|
|
50
|
+
title: 'Schedule', instances, types, instructors,
|
|
51
|
+
filterType: classTypeId, filterInstructor: instructorId,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
r.get('/class/:id', (req, res) => {
|
|
56
|
+
const inst = loadInstanceFull(db, req.params.id);
|
|
57
|
+
if (!inst || inst.status !== 'scheduled') {
|
|
58
|
+
return res.status(404).render('error', { title: 'Not found', message: 'Class not found.' });
|
|
59
|
+
}
|
|
60
|
+
res.render('public/class', {
|
|
61
|
+
title: inst.class_name, inst,
|
|
62
|
+
full: inst.booked_count >= inst.capacity,
|
|
63
|
+
waiverHtml: marked.parse(getSetting(db, 'waiver_markdown', '')),
|
|
64
|
+
error: null, form: {},
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
r.post('/class/:id/book', async (req, res) => {
|
|
69
|
+
const inst = loadInstanceFull(db, req.params.id);
|
|
70
|
+
if (!inst || inst.status !== 'scheduled') {
|
|
71
|
+
return res.status(404).render('error', { title: 'Not found', message: 'Class not found.' });
|
|
72
|
+
}
|
|
73
|
+
const { name, email, waiver_agree } = req.body;
|
|
74
|
+
const renderErr = (error, status = 400) => res.status(status).render('public/class', {
|
|
75
|
+
title: inst.class_name, inst, full: inst.booked_count >= inst.capacity,
|
|
76
|
+
waiverHtml: marked.parse(getSetting(db, 'waiver_markdown', '')),
|
|
77
|
+
error, form: { name, email },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return renderErr('A valid email is required.');
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const result = db.transaction(() => {
|
|
84
|
+
const client = findOrCreateClient(db, name, email);
|
|
85
|
+
if (!client.waiver_signed_at) {
|
|
86
|
+
if (!waiver_agree) {
|
|
87
|
+
const e = new BookingError('waiver_required', 'Please read and accept the waiver to book.');
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
db.prepare('UPDATE clients SET waiver_signed_at = ? WHERE id = ?')
|
|
91
|
+
.run(new Date().toISOString(), client.id);
|
|
92
|
+
}
|
|
93
|
+
const booked = book(db, client.id, inst.id);
|
|
94
|
+
// Drop-in without membership/pack: record the pending manual payment.
|
|
95
|
+
let payment = null;
|
|
96
|
+
if (booked.booking.paid_with === 'drop_in_manual' && inst.drop_in_price_cents > 0) {
|
|
97
|
+
const info = db.prepare(
|
|
98
|
+
`INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
|
|
99
|
+
VALUES (?, ?, ?, 'other', ?, 'drop_in', 'pending')`
|
|
100
|
+
).run(client.id, inst.drop_in_price_cents, getSetting(db, 'currency', 'HKD'),
|
|
101
|
+
`Drop-in: ${inst.class_name} ${inst.starts_at}`);
|
|
102
|
+
payment = info.lastInsertRowid;
|
|
103
|
+
}
|
|
104
|
+
audit(db, client.email, booked.waitlisted ? 'waitlist_join' : 'booking_create',
|
|
105
|
+
`instance:${inst.id} booking:${booked.booking.id}`);
|
|
106
|
+
return { client, ...booked, paymentId: payment };
|
|
107
|
+
})();
|
|
108
|
+
|
|
109
|
+
const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, result.client.id)}`;
|
|
110
|
+
const when = res.locals.fmtDt(inst.starts_at);
|
|
111
|
+
const studio = getSetting(db, 'studio_name', 'the studio');
|
|
112
|
+
const tpl = result.waitlisted
|
|
113
|
+
? emails.waitlisted({ studio, clientName: result.client.name, className: inst.class_name, when, magicUrl })
|
|
114
|
+
: emails.bookingConfirmation({
|
|
115
|
+
studio, clientName: result.client.name, className: inst.class_name, when,
|
|
116
|
+
paidWith: result.booking.paid_with.replace(/_/g, ' '), magicUrl,
|
|
117
|
+
});
|
|
118
|
+
mailer.send({ to: result.client.email, ...tpl });
|
|
119
|
+
|
|
120
|
+
res.render('public/book_result', {
|
|
121
|
+
title: result.waitlisted ? 'Waitlisted' : 'Booked',
|
|
122
|
+
inst, result, magicUrl,
|
|
123
|
+
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err instanceof BookingError) {
|
|
126
|
+
const messages = {
|
|
127
|
+
waiver_required: 'Please read and accept the waiver to book.',
|
|
128
|
+
already_booked: 'You already have a booking for this class. Check your email for your management link.',
|
|
129
|
+
in_past: 'This class has already started.',
|
|
130
|
+
class_cancelled: 'This class has been cancelled.',
|
|
131
|
+
no_credits: 'Your pass no longer has enough credits.',
|
|
132
|
+
};
|
|
133
|
+
return renderErr(messages[err.code] || err.message, 409);
|
|
134
|
+
}
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Buy page — packs + memberships.
|
|
140
|
+
r.get('/buy', (req, res) => {
|
|
141
|
+
const packs = db.prepare('SELECT * FROM pack_products WHERE active = 1 ORDER BY price_cents').all();
|
|
142
|
+
const plans = db.prepare('SELECT * FROM membership_plans WHERE active = 1 ORDER BY price_cents').all();
|
|
143
|
+
res.render('public/buy', { title: 'Passes & Memberships', packs, plans, error: null });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
r.post('/buy/pack/:id', async (req, res, next) => {
|
|
147
|
+
const product = db.prepare('SELECT * FROM pack_products WHERE id = ? AND active = 1').get(req.params.id);
|
|
148
|
+
if (!product) return res.status(404).render('error', { title: 'Not found', message: 'Pack not found.' });
|
|
149
|
+
const email = String(req.body.email || '').trim().toLowerCase();
|
|
150
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
|
151
|
+
return res.status(400).render('public/buy_manual', { title: 'Buy', product, plan: null, email: null, error: 'A valid email is required.' });
|
|
152
|
+
}
|
|
153
|
+
if (services.stripe.configured) {
|
|
154
|
+
try {
|
|
155
|
+
const session = await services.stripe.createPackCheckout({
|
|
156
|
+
product, clientEmail: email, baseUrl: services.baseUrl(req),
|
|
157
|
+
currency: getSetting(db, 'currency', 'HKD'),
|
|
158
|
+
});
|
|
159
|
+
return res.redirect(303, session.url);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return next(err);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
res.render('public/buy_manual', { title: 'Almost there', product, plan: null, email, error: null });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
r.post('/buy/membership/:id', async (req, res, next) => {
|
|
168
|
+
const plan = db.prepare('SELECT * FROM membership_plans WHERE id = ? AND active = 1').get(req.params.id);
|
|
169
|
+
if (!plan) return res.status(404).render('error', { title: 'Not found', message: 'Plan not found.' });
|
|
170
|
+
const email = String(req.body.email || '').trim().toLowerCase();
|
|
171
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
|
172
|
+
return res.status(400).render('public/buy_manual', { title: 'Buy', product: null, plan, email: null, error: 'A valid email is required.' });
|
|
173
|
+
}
|
|
174
|
+
if (services.stripe.configured && plan.stripe_price_id) {
|
|
175
|
+
try {
|
|
176
|
+
const session = await services.stripe.createMembershipCheckout({
|
|
177
|
+
plan, clientEmail: email, baseUrl: services.baseUrl(req),
|
|
178
|
+
});
|
|
179
|
+
return res.redirect(303, session.url);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return next(err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
res.render('public/buy_manual', { title: 'Almost there', product: null, plan, email, error: null });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
r.get('/buy/thanks', (req, res) => {
|
|
188
|
+
res.render('public/buy_thanks', { title: 'Thank you' });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Request a magic link by email.
|
|
192
|
+
r.get('/magic-link', (req, res) => {
|
|
193
|
+
res.render('public/magic_request', { title: 'My bookings', sent: false, magicUrl: null, error: null });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
r.post('/magic-link', (req, res) => {
|
|
197
|
+
const email = String(req.body.email || '').trim().toLowerCase();
|
|
198
|
+
const client = db.prepare('SELECT * FROM clients WHERE email = ?').get(email);
|
|
199
|
+
if (!client) {
|
|
200
|
+
// Don't leak which emails exist; show the same confirmation.
|
|
201
|
+
return res.render('public/magic_request', { title: 'My bookings', sent: true, magicUrl: null, error: null });
|
|
202
|
+
}
|
|
203
|
+
const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, client.id)}`;
|
|
204
|
+
const studio = getSetting(db, 'studio_name', 'the studio');
|
|
205
|
+
if (mailer.smtpConfigured) {
|
|
206
|
+
mailer.send({ to: email, ...emails.magicLink({ studio, clientName: client.name, magicUrl }) });
|
|
207
|
+
return res.render('public/magic_request', { title: 'My bookings', sent: true, magicUrl: null, error: null });
|
|
208
|
+
}
|
|
209
|
+
// SMTP off: surface the link on screen (spec: testable without SMTP).
|
|
210
|
+
mailer.send({ to: email, ...emails.magicLink({ studio, clientName: client.name, magicUrl }) });
|
|
211
|
+
res.render('public/magic_request', { title: 'My bookings', sent: true, magicUrl, error: null });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return r;
|
|
215
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { setSetting, getSetting, audit } from '../db/index.js';
|
|
3
|
+
import { createUser } from '../services/auth.js';
|
|
4
|
+
|
|
5
|
+
const TIMEZONES = [
|
|
6
|
+
'Asia/Hong_Kong', 'Asia/Singapore', 'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Bangkok',
|
|
7
|
+
'Australia/Sydney', 'Europe/London', 'Europe/Berlin', 'Europe/Paris',
|
|
8
|
+
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'UTC',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export default function setupRoutes({ db }) {
|
|
12
|
+
const r = Router();
|
|
13
|
+
|
|
14
|
+
r.get('/', (req, res) => {
|
|
15
|
+
res.render('setup', { title: 'Setup', timezones: TIMEZONES, error: null, form: {} });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
r.post('/', (req, res) => {
|
|
19
|
+
const { studio_name, timezone, currency, email, password, password2, seed_examples } = req.body;
|
|
20
|
+
const form = { studio_name, timezone, currency, email };
|
|
21
|
+
const fail = (error) => res.status(400).render('setup', { title: 'Setup', timezones: TIMEZONES, error, form });
|
|
22
|
+
|
|
23
|
+
if (!studio_name || !studio_name.trim()) return fail('Studio name is required.');
|
|
24
|
+
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return fail('A valid owner email is required.');
|
|
25
|
+
if (!password || password.length < 8) return fail('Password must be at least 8 characters.');
|
|
26
|
+
if (password !== password2) return fail('Passwords do not match.');
|
|
27
|
+
try {
|
|
28
|
+
new Intl.DateTimeFormat('en', { timeZone: timezone });
|
|
29
|
+
} catch {
|
|
30
|
+
return fail('Invalid timezone.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const userId = db.transaction(() => {
|
|
34
|
+
setSetting(db, 'studio_name', studio_name.trim());
|
|
35
|
+
setSetting(db, 'timezone', timezone || 'Asia/Hong_Kong');
|
|
36
|
+
setSetting(db, 'currency', (currency || 'HKD').toUpperCase().slice(0, 3));
|
|
37
|
+
const id = createUser(db, { email, password, name: 'Owner', role: 'owner' });
|
|
38
|
+
if (seed_examples) {
|
|
39
|
+
const ins = db.prepare(
|
|
40
|
+
'INSERT INTO class_types (name, description, duration_min, capacity, drop_in_price_cents, credits_required, color) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
41
|
+
);
|
|
42
|
+
ins.run('Yoga Flow', 'All-levels vinyasa flow.', 60, 14, 18000, 1, '#4f7cac');
|
|
43
|
+
ins.run('Mat Pilates', 'Core-focused mat work.', 55, 10, 20000, 1, '#a06cd5');
|
|
44
|
+
}
|
|
45
|
+
setSetting(db, 'setup_complete', '1');
|
|
46
|
+
audit(db, email, 'setup_complete', 'studio');
|
|
47
|
+
return id;
|
|
48
|
+
})();
|
|
49
|
+
|
|
50
|
+
req.session.userId = userId;
|
|
51
|
+
res.redirect('/admin');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return r;
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { handleStripeEvent } from '../services/stripe.js';
|
|
3
|
+
|
|
4
|
+
export default function webhookRoutes({ db, stripe }) {
|
|
5
|
+
const r = Router();
|
|
6
|
+
|
|
7
|
+
r.post('/', async (req, res) => {
|
|
8
|
+
if (!stripe.configured || !stripe.webhookConfigured) {
|
|
9
|
+
return res.status(501).json({ error: 'Stripe not configured' });
|
|
10
|
+
}
|
|
11
|
+
let event;
|
|
12
|
+
try {
|
|
13
|
+
event = await stripe.parseWebhook(req.body, req.headers['stripe-signature']);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
return res.status(400).json({ error: `Signature verification failed: ${err.message}` });
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const result = handleStripeEvent(db, event);
|
|
19
|
+
return res.json({ received: true, ...result });
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.error('[webhook] fulfillment failed:', err.message);
|
|
22
|
+
// 500 so Stripe retries
|
|
23
|
+
return res.status(500).json({ error: err.message });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return r;
|
|
28
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { openDb, getSetting } from './db/index.js';
|
|
3
|
+
import { createApp } from './app.js';
|
|
4
|
+
import { generateInstances } from './services/schedule.js';
|
|
5
|
+
|
|
6
|
+
const db = openDb();
|
|
7
|
+
const app = createApp({ db });
|
|
8
|
+
|
|
9
|
+
// Materialize the rolling schedule at boot and daily thereafter.
|
|
10
|
+
if (getSetting(db, 'setup_complete', '0') === '1') {
|
|
11
|
+
const created = generateInstances(db);
|
|
12
|
+
if (created) console.log(`[schedule] materialized ${created} class instances`);
|
|
13
|
+
}
|
|
14
|
+
const daily = setInterval(() => {
|
|
15
|
+
try {
|
|
16
|
+
if (getSetting(db, 'setup_complete', '0') === '1') generateInstances(db);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
console.error('[schedule] generator failed:', err.message);
|
|
19
|
+
}
|
|
20
|
+
}, 24 * 3600000);
|
|
21
|
+
daily.unref();
|
|
22
|
+
|
|
23
|
+
const port = Number(process.env.PORT || 3000);
|
|
24
|
+
app.listen(port, () => {
|
|
25
|
+
console.log(`Studio OS listening on http://localhost:${port}`);
|
|
26
|
+
if (getSetting(db, 'setup_complete', '0') !== '1') {
|
|
27
|
+
console.log(`First run: open http://localhost:${port}/setup to create your studio.`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Staff auth (bcryptjs) + HMAC magic-link tokens for client self-service.
|
|
2
|
+
import bcrypt from 'bcryptjs';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { getSetting } from '../db/index.js';
|
|
5
|
+
|
|
6
|
+
export function hashPassword(plain) {
|
|
7
|
+
return bcrypt.hashSync(plain, 10);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function verifyPassword(plain, hash) {
|
|
11
|
+
return bcrypt.compareSync(plain, hash);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createUser(db, { email, password, name = '', role = 'staff' }) {
|
|
15
|
+
const info = db.prepare(
|
|
16
|
+
'INSERT INTO users (email, password_hash, name, role) VALUES (?, ?, ?, ?)'
|
|
17
|
+
).run(email.trim().toLowerCase(), hashPassword(password), name, role);
|
|
18
|
+
return info.lastInsertRowid;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function authenticate(db, email, password) {
|
|
22
|
+
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(String(email || '').trim().toLowerCase());
|
|
23
|
+
if (!user) return null;
|
|
24
|
+
return verifyPassword(String(password || ''), user.password_hash) ? user : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- magic links -----------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
function hmac(secret, payload) {
|
|
30
|
+
return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Signed token for a client, valid for ttlHours (default 7 days). */
|
|
34
|
+
export function makeMagicToken(db, clientId, { ttlHours = 24 * 7, now = Date.now() } = {}) {
|
|
35
|
+
const secret = getSetting(db, 'app_secret');
|
|
36
|
+
const expires = now + ttlHours * 3600000;
|
|
37
|
+
const payload = `${clientId}.${expires}`;
|
|
38
|
+
return `${payload}.${hmac(secret, payload)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Returns clientId or null. Constant-time signature check. */
|
|
42
|
+
export function verifyMagicToken(db, token, { now = Date.now() } = {}) {
|
|
43
|
+
if (typeof token !== 'string') return null;
|
|
44
|
+
const parts = token.split('.');
|
|
45
|
+
if (parts.length !== 3) return null;
|
|
46
|
+
const [idStr, expStr, sig] = parts;
|
|
47
|
+
const secret = getSetting(db, 'app_secret');
|
|
48
|
+
const expected = hmac(secret, `${idStr}.${expStr}`);
|
|
49
|
+
const a = Buffer.from(sig);
|
|
50
|
+
const b = Buffer.from(expected);
|
|
51
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
|
52
|
+
if (Number(expStr) < now) return null;
|
|
53
|
+
const id = Number(idStr);
|
|
54
|
+
return Number.isInteger(id) && id > 0 ? id : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- express middleware ----------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export function requireStaff(req, res, next) {
|
|
60
|
+
if (req.session && req.session.userId) return next();
|
|
61
|
+
return res.redirect('/admin/login');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Admin area: any logged-in owner/staff. Instructors get a hard 403. */
|
|
65
|
+
export function requireAdmin(req, res, next) {
|
|
66
|
+
if (!req.session || !req.session.userId || !res.locals.user) return res.redirect('/admin/login');
|
|
67
|
+
if (res.locals.user.role === 'instructor') {
|
|
68
|
+
return res.status(403).render('error', {
|
|
69
|
+
title: 'Forbidden',
|
|
70
|
+
message: 'Instructor accounts cannot access the admin area.',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
next();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Instructor portal: instructor role only; admins are sent to their own UI. */
|
|
77
|
+
export function requireInstructor(req, res, next) {
|
|
78
|
+
if (!req.session || !req.session.userId || !res.locals.user) return res.redirect('/admin/login');
|
|
79
|
+
if (res.locals.user.role !== 'instructor') return res.redirect('/admin');
|
|
80
|
+
next();
|
|
81
|
+
}
|