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.
Files changed (86) hide show
  1. package/.dockerignore +7 -0
  2. package/.env.example +34 -0
  3. package/CHANGELOG.md +86 -0
  4. package/Dockerfile +25 -0
  5. package/LICENSE +21 -0
  6. package/PROGRESS.md +60 -0
  7. package/README.md +253 -0
  8. package/SPEC.md +130 -0
  9. package/docker-compose.yml +26 -0
  10. package/docs/screenshots/admin-client.png +0 -0
  11. package/docs/screenshots/admin-dashboard.png +0 -0
  12. package/docs/screenshots/admin-reports.png +0 -0
  13. package/docs/screenshots/admin-roster.png +0 -0
  14. package/docs/screenshots/admin-schedule.png +0 -0
  15. package/docs/screenshots/public-booking.png +0 -0
  16. package/docs/screenshots/public-buy.png +0 -0
  17. package/docs/screenshots/public-schedule.png +0 -0
  18. package/package.json +54 -0
  19. package/public/css/custom.css +40 -0
  20. package/public/js/icon.svg +4 -0
  21. package/public/js/sw.js +23 -0
  22. package/public/manifest.json +17 -0
  23. package/public/vendor/htmx.min.js +1 -0
  24. package/public/vendor/pico.min.css +4 -0
  25. package/scripts/import-mindbody.mjs +109 -0
  26. package/scripts/seed.mjs +147 -0
  27. package/src/app.js +141 -0
  28. package/src/db/002-instructor-role.sql +26 -0
  29. package/src/db/index.js +82 -0
  30. package/src/db/schema.sql +164 -0
  31. package/src/lib/ratelimit.js +50 -0
  32. package/src/lib/time.js +87 -0
  33. package/src/routes/admin.js +692 -0
  34. package/src/routes/auth.js +34 -0
  35. package/src/routes/instructor.js +82 -0
  36. package/src/routes/me.js +101 -0
  37. package/src/routes/public.js +215 -0
  38. package/src/routes/setup.js +55 -0
  39. package/src/routes/webhooks.js +28 -0
  40. package/src/server.js +29 -0
  41. package/src/services/auth.js +81 -0
  42. package/src/services/booking.js +261 -0
  43. package/src/services/importer.js +201 -0
  44. package/src/services/mailer.js +92 -0
  45. package/src/services/schedule.js +71 -0
  46. package/src/services/stripe.js +169 -0
  47. package/test/booking.test.js +346 -0
  48. package/test/fixtures/mindbody-clients.csv +9 -0
  49. package/test/fixtures/mindbody-passes.csv +8 -0
  50. package/test/helpers.js +59 -0
  51. package/test/importer.test.js +173 -0
  52. package/test/instructor.test.js +217 -0
  53. package/test/ratelimit.test.js +123 -0
  54. package/test/schedule.test.js +102 -0
  55. package/test/stripe.test.js +249 -0
  56. package/test/views.test.js +38 -0
  57. package/test/web.test.js +392 -0
  58. package/views/admin/class_types.ejs +45 -0
  59. package/views/admin/client.ejs +149 -0
  60. package/views/admin/clients.ejs +33 -0
  61. package/views/admin/dashboard.ejs +55 -0
  62. package/views/admin/import.ejs +39 -0
  63. package/views/admin/instructor_classes.ejs +21 -0
  64. package/views/admin/instructor_new.ejs +15 -0
  65. package/views/admin/instructors.ejs +49 -0
  66. package/views/admin/products.ejs +83 -0
  67. package/views/admin/reports.ejs +34 -0
  68. package/views/admin/roster.ejs +58 -0
  69. package/views/admin/rules.ejs +71 -0
  70. package/views/admin/schedule.ejs +50 -0
  71. package/views/admin/settings.ejs +53 -0
  72. package/views/error.ejs +7 -0
  73. package/views/instructor/roster.ejs +27 -0
  74. package/views/instructor/schedule.ejs +21 -0
  75. package/views/login.ejs +11 -0
  76. package/views/partials/foot.ejs +11 -0
  77. package/views/partials/head.ejs +54 -0
  78. package/views/public/book_result.ejs +26 -0
  79. package/views/public/buy.ejs +37 -0
  80. package/views/public/buy_manual.ejs +17 -0
  81. package/views/public/buy_thanks.ejs +7 -0
  82. package/views/public/class.ejs +29 -0
  83. package/views/public/magic_request.ejs +21 -0
  84. package/views/public/me.ejs +47 -0
  85. package/views/public/schedule.ejs +49 -0
  86. package/views/setup.ejs +53 -0
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // Seed a demo studio: settings + owner login, instructors, class types,
3
+ // weekly schedule rules (materialized), pack products, membership plans,
4
+ // a few clients with passes/memberships and two demo bookings.
5
+ //
6
+ // Usage: npm run seed (refuses to touch a non-empty database)
7
+ // npm run seed -- --force (seed anyway)
8
+ // DB_PATH=... npm run seed
9
+ import process from 'node:process';
10
+ import { openDb, setSetting, getSetting } from '../src/db/index.js';
11
+ import { createUser } from '../src/services/auth.js';
12
+ import { generateInstances } from '../src/services/schedule.js';
13
+ import { book } from '../src/services/booking.js';
14
+
15
+ const force = process.argv.includes('--force');
16
+ const db = openDb(process.argv.includes('--db') ? process.argv[process.argv.indexOf('--db') + 1] : undefined);
17
+
18
+ const hasData = getSetting(db, 'setup_complete', '0') === '1'
19
+ || db.prepare('SELECT COUNT(*) c FROM clients').get().c > 0;
20
+ if (hasData && !force) {
21
+ console.error('Database already has data — refusing to seed. Re-run with --force to seed anyway.');
22
+ process.exit(1);
23
+ }
24
+
25
+ const OWNER_EMAIL = 'owner@example.com';
26
+ const OWNER_PASSWORD = 'studio-demo';
27
+
28
+ db.transaction(() => {
29
+ // --- settings + owner ------------------------------------------------
30
+ setSetting(db, 'studio_name', 'Harbour Movement Studio');
31
+ setSetting(db, 'timezone', 'Asia/Hong_Kong');
32
+ setSetting(db, 'currency', 'HKD');
33
+ setSetting(db, 'cancellation_window_hours', '12');
34
+ setSetting(db, 'late_cancel_policy', 'forfeit');
35
+ setSetting(db, 'waiver_markdown', [
36
+ '## Liability waiver',
37
+ '',
38
+ 'I understand that participation in classes at **Harbour Movement Studio** involves physical activity',
39
+ 'and carries a risk of injury. I confirm that I am physically fit to participate, and I agree to',
40
+ 'inform the instructor of any injuries or conditions before class.',
41
+ '',
42
+ 'I release the studio and its instructors from liability for injuries sustained during normal',
43
+ 'class activity, except where caused by negligence.',
44
+ ].join('\n'));
45
+ if (!db.prepare('SELECT id FROM users WHERE email = ?').get(OWNER_EMAIL)) {
46
+ createUser(db, { email: OWNER_EMAIL, password: OWNER_PASSWORD, name: 'Demo Owner', role: 'owner' });
47
+ }
48
+ setSetting(db, 'setup_complete', '1');
49
+
50
+ // --- instructors ------------------------------------------------------
51
+ const insInstructor = db.prepare('INSERT INTO instructors (name, bio) VALUES (?, ?)');
52
+ const mei = insInstructor.run('Mei Ling', 'RYT-500 vinyasa and yin teacher, 8 years.').lastInsertRowid;
53
+ const carlos = insInstructor.run('Carlos Reyes', 'Former gymnast; handstands and mobility.').lastInsertRowid;
54
+ const priya = insInstructor.run('Priya Nair', 'Mat and reformer pilates, pre/post-natal certified.').lastInsertRowid;
55
+
56
+ // --- class types ------------------------------------------------------
57
+ const insType = db.prepare(
58
+ 'INSERT INTO class_types (name, description, duration_min, capacity, drop_in_price_cents, credits_required, color) VALUES (?, ?, ?, ?, ?, ?, ?)'
59
+ );
60
+ const vinyasa = insType.run('Vinyasa Flow', 'Breath-led flowing yoga, all levels.', 60, 14, 18000, 1, '#4f7cac').lastInsertRowid;
61
+ const pilates = insType.run('Mat Pilates', 'Core-focused mat work.', 55, 10, 20000, 1, '#a06cd5').lastInsertRowid;
62
+ const handstand = insType.run('Handstand Basics', 'Wall drills to freestanding holds.', 75, 8, 25000, 2, '#e07a5f').lastInsertRowid;
63
+ const yin = insType.run('Yin & Restore', 'Slow deep stretch. Great for beginners.', 60, 16, 16000, 1, '#3d8168').lastInsertRowid;
64
+
65
+ // --- weekly rules (studio-local times) --------------------------------
66
+ const insRule = db.prepare(
67
+ 'INSERT INTO schedule_rules (class_type_id, instructor_id, weekday, start_time) VALUES (?, ?, ?, ?)'
68
+ );
69
+ insRule.run(vinyasa, mei, 1, '07:15'); // Mon
70
+ insRule.run(vinyasa, mei, 3, '07:15'); // Wed
71
+ insRule.run(vinyasa, mei, 6, '10:00'); // Sat
72
+ insRule.run(pilates, priya, 2, '18:30'); // Tue
73
+ insRule.run(pilates, priya, 4, '18:30'); // Thu
74
+ insRule.run(handstand, carlos, 6, '14:00'); // Sat
75
+ insRule.run(yin, mei, 0, '17:00'); // Sun
76
+ insRule.run(yin, priya, 5, '19:30'); // Fri
77
+
78
+ // --- products ---------------------------------------------------------
79
+ const insPack = db.prepare('INSERT INTO pack_products (name, credits, price_cents, validity_days) VALUES (?, ?, ?, ?)');
80
+ insPack.run('5 Class Pack', 5, 80000, 90);
81
+ insPack.run('10 Class Pack', 10, 150000, 180);
82
+ insPack.run('20 Class Pack', 20, 280000, 365);
83
+ const insPlan = db.prepare('INSERT INTO membership_plans (name, price_cents, unlimited, credits_per_month) VALUES (?, ?, ?, ?)');
84
+ insPlan.run('Unlimited Monthly', 128000, 1, null);
85
+ insPlan.run('8 Classes / Month', 88000, 0, 8);
86
+
87
+ // --- clients, passes, memberships ------------------------------------
88
+ const insClient = db.prepare(
89
+ "INSERT INTO clients (name, email, phone, waiver_signed_at, source) VALUES (?, ?, ?, ?, 'manual')"
90
+ );
91
+ const now = new Date().toISOString();
92
+ const alice = insClient.run('Alice Chan', 'alice.chan@example.com', '+852 9123 0001', now).lastInsertRowid;
93
+ const ben = insClient.run('Ben Kwok', 'ben.kwok@example.com', '+852 9123 0002', now).lastInsertRowid;
94
+ const carmen = insClient.run('Carmen Ho', 'carmen.ho@example.com', '+852 9123 0003', now).lastInsertRowid;
95
+ insClient.run('Dev Patel', 'dev.patel@example.com', '+852 9123 0004', null); // no waiver yet
96
+
97
+ const in6months = new Date(Date.now() + 182 * 86400000).toISOString().slice(0, 10);
98
+ db.prepare(
99
+ `INSERT INTO passes (client_id, name, credits_total, credits_remaining, expires_on, price_paid_cents, source)
100
+ VALUES (?, '10 Class Pack', 10, 7, ?, 150000, 'manual')`
101
+ ).run(alice, in6months);
102
+ db.prepare(
103
+ `INSERT INTO passes (client_id, name, credits_total, credits_remaining, expires_on, price_paid_cents, source)
104
+ VALUES (?, '5 Class Pack', 5, 5, ?, 80000, 'manual')`
105
+ ).run(ben, in6months);
106
+ const today = new Date().toISOString().slice(0, 10);
107
+ db.prepare(
108
+ `INSERT INTO memberships (client_id, plan_name, status, started_on, unlimited, cycle_started_on)
109
+ VALUES (?, 'Unlimited Monthly', 'active', ?, 1, ?)`
110
+ ).run(carmen, today, today);
111
+ db.prepare(
112
+ `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
113
+ VALUES (?, 150000, 'HKD', 'cash', '10 Class Pack', 'pack', 'paid')`
114
+ ).run(alice);
115
+ db.prepare(
116
+ `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
117
+ VALUES (?, 80000, 'HKD', 'fps', '5 Class Pack', 'pack', 'paid')`
118
+ ).run(ben);
119
+ db.prepare(
120
+ `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
121
+ VALUES (?, 128000, 'HKD', 'cash', 'Unlimited Monthly', 'membership', 'paid')`
122
+ ).run(carmen);
123
+ })();
124
+
125
+ // Materialize the schedule, then drop two demo bookings on the nearest classes.
126
+ const created = generateInstances(db);
127
+ const upcoming = db.prepare(
128
+ "SELECT id FROM class_instances WHERE status = 'scheduled' AND starts_at > ? ORDER BY starts_at LIMIT 2"
129
+ ).all(new Date().toISOString());
130
+ const aliceId = db.prepare('SELECT id FROM clients WHERE email = ?').get('alice.chan@example.com').id;
131
+ const carmenId = db.prepare('SELECT id FROM clients WHERE email = ?').get('carmen.ho@example.com').id;
132
+ let bookings = 0;
133
+ for (const inst of upcoming) {
134
+ try {
135
+ book(db, aliceId, inst.id); bookings++;
136
+ book(db, carmenId, inst.id); bookings++;
137
+ } catch { /* capacity or duplicate — fine for a seed */ }
138
+ }
139
+
140
+ console.log(`Seeded demo studio "Harbour Movement Studio":
141
+ 3 instructors, 4 class types, 8 weekly rules → ${created} class instances
142
+ 3 pack products, 2 membership plans
143
+ 4 clients (2 passes, 1 unlimited membership), ${bookings} demo bookings
144
+
145
+ Admin login: ${OWNER_EMAIL} / ${OWNER_PASSWORD}
146
+ Start the app: npm start → http://localhost:3000`);
147
+ db.close();
package/src/app.js ADDED
@@ -0,0 +1,141 @@
1
+ import express from 'express';
2
+ import cookieSession from 'cookie-session';
3
+ import crypto from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { getSetting, allSettings } from './db/index.js';
7
+ import { formatInTz } from './lib/time.js';
8
+ import { createRateLimiter } from './lib/ratelimit.js';
9
+ import { createMailer } from './services/mailer.js';
10
+ import { createStripeService } from './services/stripe.js';
11
+ import setupRoutes from './routes/setup.js';
12
+ import authRoutes from './routes/auth.js';
13
+ import publicRoutes from './routes/public.js';
14
+ import meRoutes from './routes/me.js';
15
+ import adminRoutes from './routes/admin.js';
16
+ import instructorRoutes from './routes/instructor.js';
17
+ import webhookRoutes from './routes/webhooks.js';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+
21
+ /**
22
+ * Build the Express app. Dependencies are injectable for tests:
23
+ * createApp({ db, mailer?, stripeService?, env?, now? })
24
+ * `now` (clock) is used by the rate limiters so tests can move time.
25
+ */
26
+ export function createApp({ db, mailer, stripeService, env = process.env, now = Date.now }) {
27
+ const app = express();
28
+ app.set('view engine', 'ejs');
29
+ app.set('views', path.join(__dirname, '..', 'views'));
30
+ app.disable('x-powered-by');
31
+
32
+ const services = {
33
+ db,
34
+ mailer: mailer || createMailer({ env }),
35
+ stripe: stripeService || createStripeService({ env }),
36
+ env,
37
+ baseUrl(req) {
38
+ return env.BASE_URL || `${req.protocol}://${req.get('host')}`;
39
+ },
40
+ };
41
+ app.locals.services = services;
42
+
43
+ // Stripe webhook needs the raw body for signature verification — mount
44
+ // before any body parser.
45
+ app.use('/webhooks/stripe', express.raw({ type: '*/*' }), webhookRoutes(services));
46
+
47
+ app.use(express.urlencoded({ extended: true }));
48
+ app.use(express.static(path.join(__dirname, '..', 'public')));
49
+ app.use(cookieSession({
50
+ name: 'studio_session',
51
+ keys: [getSetting(db, 'app_secret', 'dev-secret')],
52
+ sameSite: 'lax',
53
+ httpOnly: true,
54
+ maxAge: 30 * 24 * 3600000,
55
+ }));
56
+
57
+ // View locals: settings + formatting helpers, refreshed per request.
58
+ app.use((req, res, next) => {
59
+ const s = allSettings(db);
60
+ res.locals.settings = s;
61
+ res.locals.tz = s.timezone || 'Asia/Hong_Kong';
62
+ res.locals.currency = s.currency || 'HKD';
63
+ res.locals.fmtDt = (iso, opts) => formatInTz(res.locals.tz, iso, opts);
64
+ res.locals.fmtMoney = (cents) => new Intl.NumberFormat('en', {
65
+ style: 'currency', currency: res.locals.currency, minimumFractionDigits: 0,
66
+ }).format((cents || 0) / 100);
67
+ res.locals.user = req.session && req.session.userId
68
+ ? db.prepare('SELECT id, email, name, role FROM users WHERE id = ?').get(req.session.userId)
69
+ : null;
70
+ res.locals.stripeConfigured = services.stripe.configured;
71
+ res.locals.smtpConfigured = services.mailer.smtpConfigured;
72
+ res.locals.flash = req.session ? req.session.flash : null;
73
+ if (req.session) delete req.session.flash;
74
+ next();
75
+ });
76
+
77
+ // Rate limiting (before CSRF so hammering can't even probe tokens):
78
+ // in-memory fixed window, per IP + route. Single-process; resets on restart.
79
+ const RL_WINDOW = 15 * 60 * 1000;
80
+ app.post('/magic-link', createRateLimiter({
81
+ windowMs: RL_WINDOW, max: 5, env, now,
82
+ message: 'Too many link requests — please wait 15 minutes and try again.',
83
+ }));
84
+ app.post('/admin/login', createRateLimiter({
85
+ windowMs: RL_WINDOW, max: 10, env, now,
86
+ message: 'Too many login attempts — please wait 15 minutes and try again.',
87
+ }));
88
+ app.post(['/class/:id/book', '/buy/pack/:id', '/buy/membership/:id'], createRateLimiter({
89
+ windowMs: RL_WINDOW, max: 30, env, now,
90
+ message: 'Too many booking attempts from your address — please wait a few minutes and try again.',
91
+ }));
92
+
93
+ // CSRF protection: a random per-session token lives in the signed cookie
94
+ // session and must be echoed back (hidden `_csrf` input or `x-csrf-token`
95
+ // header) on every state-changing request. /webhooks/stripe is exempt — it
96
+ // is mounted before this middleware and authenticated by Stripe signature.
97
+ app.use((req, res, next) => {
98
+ if (!req.session.csrf) req.session.csrf = crypto.randomBytes(24).toString('hex');
99
+ res.locals.csrfToken = req.session.csrf;
100
+ if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) return next();
101
+ if (req.path.startsWith('/webhooks/')) return next();
102
+ const sent = (req.body && req.body._csrf) || req.get('x-csrf-token') || '';
103
+ const a = Buffer.from(String(sent));
104
+ const b = Buffer.from(req.session.csrf);
105
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
106
+ return res.status(403).render('error', {
107
+ title: 'Session expired',
108
+ message: 'Your form session expired or the security token was missing. Please go back, refresh the page, and try again.',
109
+ });
110
+ }
111
+ next();
112
+ });
113
+
114
+ // First-run gate: until setup completes, everything routes to the wizard.
115
+ app.use((req, res, next) => {
116
+ const done = getSetting(db, 'setup_complete', '0') === '1';
117
+ if (!done && !req.path.startsWith('/setup') && !req.path.startsWith('/vendor')
118
+ && !req.path.startsWith('/css') && !req.path.startsWith('/js')) {
119
+ return res.redirect('/setup');
120
+ }
121
+ if (done && req.path.startsWith('/setup')) return res.redirect('/');
122
+ next();
123
+ });
124
+
125
+ app.use('/setup', setupRoutes(services));
126
+ app.use('/', authRoutes(services));
127
+ app.use('/admin', adminRoutes(services));
128
+ app.use('/instructor', instructorRoutes(services));
129
+ app.use('/me', meRoutes(services));
130
+ app.use('/', publicRoutes(services));
131
+
132
+ // 404 + error handler
133
+ app.use((req, res) => res.status(404).render('error', { title: 'Not found', message: 'Page not found.' }));
134
+ // eslint-disable-next-line no-unused-vars
135
+ app.use((err, req, res, next) => {
136
+ console.error('[error]', err);
137
+ res.status(500).render('error', { title: 'Error', message: err.expose ? err.message : 'Something went wrong.' });
138
+ });
139
+
140
+ return app;
141
+ }
@@ -0,0 +1,26 @@
1
+ -- v2: instructor logins. Adds 'instructor' to the users role CHECK (SQLite
2
+ -- cannot alter a CHECK in place, so the table is rebuilt — no FKs reference
3
+ -- users) and a many-to-many instructor↔class assignment table.
4
+
5
+ CREATE TABLE users_v2 (
6
+ id INTEGER PRIMARY KEY,
7
+ email TEXT NOT NULL UNIQUE COLLATE NOCASE,
8
+ password_hash TEXT NOT NULL,
9
+ name TEXT NOT NULL DEFAULT '',
10
+ role TEXT NOT NULL DEFAULT 'staff' CHECK (role IN ('owner','staff','instructor')),
11
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
12
+ );
13
+ INSERT INTO users_v2 (id, email, password_hash, name, role, created_at)
14
+ SELECT id, email, password_hash, name, role, created_at FROM users;
15
+ DROP TABLE users;
16
+ ALTER TABLE users_v2 RENAME TO users;
17
+
18
+ -- instructor_id → users.id (role 'instructor'); class_id → class_instances.id.
19
+ -- CASCADE so deleting an account or a not-yet-booked future instance (rule
20
+ -- delete) never strands or blocks on an assignment row.
21
+ CREATE TABLE IF NOT EXISTS instructor_class_assignments (
22
+ instructor_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
23
+ class_id INTEGER NOT NULL REFERENCES class_instances(id) ON DELETE CASCADE,
24
+ PRIMARY KEY (instructor_id, class_id)
25
+ );
26
+ CREATE INDEX IF NOT EXISTS idx_ica_class ON instructor_class_assignments(class_id);
@@ -0,0 +1,82 @@
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ const MIGRATIONS = [
10
+ { version: 1, file: 'schema.sql' },
11
+ { version: 2, file: '002-instructor-role.sql' },
12
+ ];
13
+
14
+ const DEFAULT_SETTINGS = {
15
+ studio_name: '',
16
+ timezone: 'Asia/Hong_Kong',
17
+ currency: 'HKD',
18
+ cancellation_window_hours: '12',
19
+ late_cancel_policy: 'forfeit', // forfeit | refund
20
+ waiver_markdown: '## Liability Waiver\n\nI understand that participation in classes involves physical activity and I take part at my own risk. I confirm I am in good health and will inform the instructor of any injuries or conditions.\n\nBy checking the box I agree to this waiver.',
21
+ setup_complete: '0',
22
+ horizon_weeks: '8',
23
+ };
24
+
25
+ /**
26
+ * Open (or create) the database, enable WAL, run migrations, seed defaults.
27
+ * @param {string} [dbPath] file path or ':memory:' (tests)
28
+ */
29
+ export function openDb(dbPath) {
30
+ const file = dbPath || process.env.DB_PATH || path.join(process.cwd(), 'data', 'studio.db');
31
+ if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true });
32
+ const db = new Database(file);
33
+ db.pragma('journal_mode = WAL');
34
+ db.pragma('foreign_keys = ON');
35
+ db.pragma('busy_timeout = 5000');
36
+ migrate(db);
37
+ return db;
38
+ }
39
+
40
+ function migrate(db) {
41
+ db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
42
+ const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
43
+ for (const m of MIGRATIONS) {
44
+ if (applied.has(m.version)) continue;
45
+ const sql = fs.readFileSync(path.join(__dirname, m.file), 'utf8');
46
+ db.transaction(() => {
47
+ db.exec(sql);
48
+ db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)')
49
+ .run(m.version, new Date().toISOString());
50
+ })();
51
+ }
52
+ // seed default settings (INSERT OR IGNORE keeps existing values)
53
+ const ins = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
54
+ for (const [k, v] of Object.entries(DEFAULT_SETTINGS)) ins.run(k, v);
55
+ // app secret: generated once, persisted, overridable via env
56
+ if (!process.env.APP_SECRET) {
57
+ ins.run('app_secret', crypto.randomBytes(32).toString('hex'));
58
+ }
59
+ }
60
+
61
+ export function getSetting(db, key, fallback = '') {
62
+ if (key === 'app_secret' && process.env.APP_SECRET) return process.env.APP_SECRET;
63
+ const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
64
+ return row ? row.value : fallback;
65
+ }
66
+
67
+ export function setSetting(db, key, value) {
68
+ db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
69
+ .run(key, String(value));
70
+ }
71
+
72
+ export function allSettings(db) {
73
+ const out = {};
74
+ for (const r of db.prepare('SELECT key, value FROM settings').all()) out[r.key] = r.value;
75
+ if (process.env.APP_SECRET) out.app_secret = process.env.APP_SECRET;
76
+ return out;
77
+ }
78
+
79
+ export function audit(db, who, action, entity = '') {
80
+ db.prepare('INSERT INTO audit_log (who, action, entity, at) VALUES (?, ?, ?, ?)')
81
+ .run(who || '', action, entity, new Date().toISOString());
82
+ }
@@ -0,0 +1,164 @@
1
+ -- Studio OS schema v1. All timestamps are UTC ISO-8601 strings.
2
+
3
+ CREATE TABLE IF NOT EXISTS settings (
4
+ key TEXT PRIMARY KEY,
5
+ value TEXT NOT NULL DEFAULT ''
6
+ );
7
+
8
+ CREATE TABLE IF NOT EXISTS users (
9
+ id INTEGER PRIMARY KEY,
10
+ email TEXT NOT NULL UNIQUE COLLATE NOCASE,
11
+ password_hash TEXT NOT NULL,
12
+ name TEXT NOT NULL DEFAULT '',
13
+ role TEXT NOT NULL DEFAULT 'staff' CHECK (role IN ('owner','staff')),
14
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS instructors (
18
+ id INTEGER PRIMARY KEY,
19
+ name TEXT NOT NULL,
20
+ bio TEXT NOT NULL DEFAULT '',
21
+ active INTEGER NOT NULL DEFAULT 1
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS class_types (
25
+ id INTEGER PRIMARY KEY,
26
+ name TEXT NOT NULL,
27
+ description TEXT NOT NULL DEFAULT '',
28
+ duration_min INTEGER NOT NULL DEFAULT 60,
29
+ capacity INTEGER NOT NULL DEFAULT 10,
30
+ drop_in_price_cents INTEGER NOT NULL DEFAULT 0,
31
+ credits_required INTEGER NOT NULL DEFAULT 1,
32
+ color TEXT NOT NULL DEFAULT '#4f7cac',
33
+ active INTEGER NOT NULL DEFAULT 1
34
+ );
35
+
36
+ CREATE TABLE IF NOT EXISTS schedule_rules (
37
+ id INTEGER PRIMARY KEY,
38
+ class_type_id INTEGER NOT NULL REFERENCES class_types(id),
39
+ instructor_id INTEGER REFERENCES instructors(id),
40
+ weekday INTEGER NOT NULL CHECK (weekday BETWEEN 0 AND 6), -- 0=Sunday
41
+ start_time TEXT NOT NULL, -- 'HH:MM' studio-local
42
+ capacity_override INTEGER,
43
+ active_from TEXT, -- 'YYYY-MM-DD' local
44
+ active_until TEXT,
45
+ active INTEGER NOT NULL DEFAULT 1
46
+ );
47
+
48
+ CREATE TABLE IF NOT EXISTS class_instances (
49
+ id INTEGER PRIMARY KEY,
50
+ class_type_id INTEGER NOT NULL REFERENCES class_types(id),
51
+ instructor_id INTEGER REFERENCES instructors(id),
52
+ rule_id INTEGER REFERENCES schedule_rules(id),
53
+ starts_at TEXT NOT NULL, -- UTC ISO
54
+ duration_min INTEGER NOT NULL DEFAULT 60,
55
+ capacity INTEGER NOT NULL DEFAULT 10,
56
+ status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled','cancelled')),
57
+ notes TEXT NOT NULL DEFAULT ''
58
+ );
59
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_instance_rule_start
60
+ ON class_instances(rule_id, starts_at) WHERE rule_id IS NOT NULL;
61
+ CREATE INDEX IF NOT EXISTS idx_instances_starts ON class_instances(starts_at);
62
+
63
+ CREATE TABLE IF NOT EXISTS clients (
64
+ id INTEGER PRIMARY KEY,
65
+ name TEXT NOT NULL,
66
+ email TEXT NOT NULL UNIQUE COLLATE NOCASE,
67
+ phone TEXT NOT NULL DEFAULT '',
68
+ notes TEXT NOT NULL DEFAULT '',
69
+ waiver_signed_at TEXT,
70
+ source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual','import','self')),
71
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
72
+ );
73
+
74
+ CREATE TABLE IF NOT EXISTS passes (
75
+ id INTEGER PRIMARY KEY,
76
+ client_id INTEGER NOT NULL REFERENCES clients(id),
77
+ name TEXT NOT NULL,
78
+ credits_total INTEGER NOT NULL,
79
+ credits_remaining INTEGER NOT NULL,
80
+ expires_on TEXT, -- 'YYYY-MM-DD' local date, inclusive
81
+ price_paid_cents INTEGER NOT NULL DEFAULT 0,
82
+ source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('purchase','import','manual')),
83
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
84
+ );
85
+ CREATE INDEX IF NOT EXISTS idx_passes_client ON passes(client_id);
86
+
87
+ CREATE TABLE IF NOT EXISTS membership_plans (
88
+ id INTEGER PRIMARY KEY,
89
+ name TEXT NOT NULL,
90
+ price_cents INTEGER NOT NULL DEFAULT 0,
91
+ interval TEXT NOT NULL DEFAULT 'month',
92
+ unlimited INTEGER NOT NULL DEFAULT 1,
93
+ credits_per_month INTEGER,
94
+ stripe_price_id TEXT,
95
+ active INTEGER NOT NULL DEFAULT 1
96
+ );
97
+
98
+ -- Purchasable class-pack products shown on the buy page (SPEC is silent on a
99
+ -- pack catalogue; passes are per-client, so products live here).
100
+ CREATE TABLE IF NOT EXISTS pack_products (
101
+ id INTEGER PRIMARY KEY,
102
+ name TEXT NOT NULL,
103
+ credits INTEGER NOT NULL,
104
+ price_cents INTEGER NOT NULL DEFAULT 0,
105
+ validity_days INTEGER, -- NULL = never expires
106
+ stripe_price_id TEXT,
107
+ active INTEGER NOT NULL DEFAULT 1
108
+ );
109
+
110
+ CREATE TABLE IF NOT EXISTS memberships (
111
+ id INTEGER PRIMARY KEY,
112
+ client_id INTEGER NOT NULL REFERENCES clients(id),
113
+ plan_id INTEGER REFERENCES membership_plans(id),
114
+ plan_name TEXT NOT NULL,
115
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused','cancelled')),
116
+ started_on TEXT NOT NULL, -- 'YYYY-MM-DD'
117
+ renews_on TEXT,
118
+ stripe_subscription_id TEXT,
119
+ unlimited INTEGER NOT NULL DEFAULT 1,
120
+ credits_per_month INTEGER,
121
+ credits_used_this_cycle INTEGER NOT NULL DEFAULT 0,
122
+ cycle_started_on TEXT -- 'YYYY-MM-DD'
123
+ );
124
+ CREATE INDEX IF NOT EXISTS idx_memberships_client ON memberships(client_id);
125
+
126
+ CREATE TABLE IF NOT EXISTS bookings (
127
+ id INTEGER PRIMARY KEY,
128
+ class_instance_id INTEGER NOT NULL REFERENCES class_instances(id),
129
+ client_id INTEGER NOT NULL REFERENCES clients(id),
130
+ status TEXT NOT NULL DEFAULT 'booked'
131
+ CHECK (status IN ('booked','waitlist','cancelled','attended','no_show')),
132
+ paid_with TEXT NOT NULL
133
+ CHECK (paid_with IN ('pack','membership','drop_in_online','drop_in_manual','comp')),
134
+ pass_id INTEGER REFERENCES passes(id),
135
+ membership_id INTEGER REFERENCES memberships(id),
136
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
137
+ cancelled_at TEXT
138
+ );
139
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_booking_active
140
+ ON bookings(class_instance_id, client_id) WHERE status != 'cancelled';
141
+ CREATE INDEX IF NOT EXISTS idx_bookings_instance ON bookings(class_instance_id);
142
+ CREATE INDEX IF NOT EXISTS idx_bookings_client ON bookings(client_id);
143
+
144
+ CREATE TABLE IF NOT EXISTS payments (
145
+ id INTEGER PRIMARY KEY,
146
+ client_id INTEGER REFERENCES clients(id),
147
+ amount_cents INTEGER NOT NULL,
148
+ currency TEXT NOT NULL DEFAULT 'HKD',
149
+ method TEXT NOT NULL CHECK (method IN ('stripe','cash','fps','other')),
150
+ reference TEXT NOT NULL DEFAULT '',
151
+ what TEXT NOT NULL CHECK (what IN ('drop_in','pack','membership')),
152
+ status TEXT NOT NULL DEFAULT 'paid' CHECK (status IN ('paid','pending')),
153
+ stripe_session_id TEXT UNIQUE,
154
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
155
+ );
156
+ CREATE INDEX IF NOT EXISTS idx_payments_created ON payments(created_at);
157
+
158
+ CREATE TABLE IF NOT EXISTS audit_log (
159
+ id INTEGER PRIMARY KEY,
160
+ who TEXT NOT NULL DEFAULT '',
161
+ action TEXT NOT NULL,
162
+ entity TEXT NOT NULL DEFAULT '',
163
+ at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
164
+ );
@@ -0,0 +1,50 @@
1
+ // Tiny in-memory fixed-window rate limiter. No dependencies.
2
+ //
3
+ // Deliberately simple and honest about its limits: counters live in this
4
+ // process's memory, so they reset on restart and are per-process (a
5
+ // multi-instance deployment would need a shared store). Behind a reverse
6
+ // proxy, set TRUST_PROXY=1 so the client IP is read from the first
7
+ // X-Forwarded-For hop; without it the header is ignored (spoofable) and the
8
+ // socket address is used.
9
+
10
+ export function clientIp(req, env = process.env) {
11
+ if (env.TRUST_PROXY) {
12
+ const xff = req.get('x-forwarded-for');
13
+ if (xff) return xff.split(',')[0].trim();
14
+ }
15
+ return (req.socket && req.socket.remoteAddress) || 'unknown';
16
+ }
17
+
18
+ /**
19
+ * createRateLimiter({ windowMs, max, env?, now?, message? }) → Express middleware.
20
+ * Fixed window keyed on client IP + matched route pattern (so /class/1/book
21
+ * and /class/2/book share one bucket). `now` is injectable for tests.
22
+ */
23
+ export function createRateLimiter({ windowMs, max, env = process.env, now = Date.now, message } = {}) {
24
+ const buckets = new Map(); // key → { window, count }
25
+ const mw = (req, res, next) => {
26
+ const windowId = Math.floor(now() / windowMs);
27
+ const route = (req.route && req.route.path) || req.path;
28
+ const key = `${clientIp(req, env)}|${req.method} ${route}`;
29
+ let b = buckets.get(key);
30
+ if (!b || b.window !== windowId) {
31
+ b = { window: windowId, count: 0 };
32
+ buckets.set(key, b);
33
+ }
34
+ b.count += 1;
35
+ if (b.count > max) {
36
+ res.set('Retry-After', String(Math.ceil(windowMs / 1000)));
37
+ return res.status(429).render('error', {
38
+ title: 'Too many requests',
39
+ message: message || 'Too many requests from your address — please wait a few minutes and try again.',
40
+ });
41
+ }
42
+ // opportunistic cleanup so the map cannot grow without bound
43
+ if (buckets.size > 10000) {
44
+ for (const [k, v] of buckets) if (v.window !== windowId) buckets.delete(k);
45
+ }
46
+ next();
47
+ };
48
+ mw.buckets = buckets; // exposed for tests/inspection
49
+ return mw;
50
+ }