studio-os 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,74 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-09-27
4
+
5
+ **Upgrading:** the database migrates itself on start (schema v4, one new
6
+ column on `payments`). Drop-in dues recorded before the upgrade aren't linked
7
+ to a booking, so they won't clear themselves if that booking is cancelled.
8
+ Check the dashboard's Pending payments for stale ones.
9
+
10
+ - **Editing a weekly rule moved nothing and doubled the schedule.** Changing
11
+ a rule's day or time left every future class at the old slot and then
12
+ generated a second set at the new one. Empty future classes now move with
13
+ the rule, and booked ones stay put with a note saying how many. A malformed
14
+ time used to save, 500, and then break the daily generator for every rule.
15
+ It's rejected now.
16
+ - **Drop-in dues were charged to people who never got a spot.** Booking
17
+ onto the waitlist recorded a pending drop-in payment, and cancelling never
18
+ removed one. Dues are now tied to the booking: recorded when the client
19
+ holds a spot (including when the waitlist promotes them), dropped when
20
+ they cancel in time or the studio cancels the class. A forfeited late
21
+ cancel still owes, same as it would lose a pack credit.
22
+ - **The waitlist got promoted into classes that had already started.** A
23
+ late cancel or a staff roster edit after start time moved the next person
24
+ in and charged them for a class they'd missed. Clients also can't cancel
25
+ once a class has started. Staff still can, to tidy the roster.
26
+ - **The booking form renamed existing clients.** Anyone who typed a known
27
+ email with a different name overwrote that client's name. An existing
28
+ client now keeps theirs.
29
+ - **Edit a single class.** The roster page can change one class's capacity,
30
+ instructor and notes. Extra capacity goes to the waitlist in order, and
31
+ those clients are emailed.
32
+ - A two-credit class said "1 credit deducted". The purchase thank-you page
33
+ said the pass was active before Stripe had confirmed the payment.
34
+ - **Dependencies**: dotenv → 18.0.3, marked → 18.0.14, supertest → 7.3.0.
35
+ - **Tests**: 135 → 144.
36
+
37
+ ## 0.4.0 — 2026-09-27
38
+
39
+ **Upgrading with Stripe:** add `checkout.session.async_payment_succeeded` and
40
+ `invoice.paid` to your webhook endpoint's events. Without them, delayed
41
+ payments never turn into passes and renewals still go unrecorded. The database
42
+ migrates itself on start (schema v3, one new column on `payments`).
43
+
44
+ - **Security: booking in someone else's name handed over their magic link.**
45
+ Booking takes only a name and an email, and the confirmation page always
46
+ showed the booked client's `/me` link, a 7-day token to their bookings,
47
+ passes and cancel buttons. With SMTP configured the link now only goes out
48
+ by email. Without SMTP it's still shown on screen, as before, and the
49
+ README says what that means.
50
+ - **Security: a 100kb POST to any public form leaked a stack trace.** The
51
+ body-size 413 reached the error handler before the layout locals were set,
52
+ `error.ejs` threw, and Express's fallback answered 500 with the
53
+ ReferenceError and server paths (hidden only when `NODE_ENV=production`,
54
+ which the Docker image sets). The error page now renders on its own and 4xx
55
+ statuses stay 4xx.
56
+ - **Stripe renewals were never recorded.** Only a membership's first month
57
+ showed up as a payment, so the revenue report undercounted every
58
+ subscription after month one. `invoice.paid` now records each renewal,
59
+ keyed on the invoice id so webhook retries can't count a month twice.
60
+ - **Delayed Stripe payments got a pass before the money cleared.** Bank
61
+ debits and other delayed methods complete Checkout with `payment_status:
62
+ unpaid`, and fulfilment ignored that. Unpaid sessions now wait for
63
+ `checkout.session.async_payment_succeeded`.
64
+ - **Mindbody imports past a few hundred clients got a 413.** Admin → Import
65
+ now accepts up to 20mb.
66
+ - **The waitlist page said "You're # in line"** with no number. It now shows
67
+ the actual position.
68
+ - `GET /me` without a token answered 500 instead of the "link expired" page.
69
+ - **Dependencies**: dotenv 17 → 18, nodemailer → 10.0.10.
70
+ - **Tests**: 127 → 135.
71
+
3
72
  ## 0.3.0 — 2026-09-17
4
73
 
5
74
  - **Security: magic links were emailed as `http://` behind a reverse proxy.**
package/README.md CHANGED
@@ -135,7 +135,10 @@ activate purchases manually from the client profile — fully usable cash-only.
135
135
  - `STRIPE_PUBLISHABLE_KEY`
136
136
  - `STRIPE_WEBHOOK_SECRET` — see step 3
137
137
  3. Add a webhook endpoint in Stripe: `https://your-domain/webhooks/stripe`,
138
- events `checkout.session.completed` and `customer.subscription.deleted`.
138
+ events `checkout.session.completed`, `checkout.session.async_payment_succeeded`,
139
+ `invoice.paid` and `customer.subscription.deleted`. The async event is what
140
+ fulfils payments that clear later, like bank debits; card-only studios never
141
+ see it. `invoice.paid` records each membership renewal in the revenue report.
139
142
  Copy the signing secret into `STRIPE_WEBHOOK_SECRET`.
140
143
  (Local testing: `stripe listen --forward-to localhost:3000/webhooks/stripe`.)
141
144
  The webhook secret is not optional: online payment stays off until it is
@@ -184,6 +187,10 @@ What's protected out of the box:
184
187
  What's *not* there yet — plan accordingly:
185
188
 
186
189
  - **No 2FA** on staff logins.
190
+ - **Without SMTP, client links are shown on screen**, so anyone who knows a
191
+ client's email can open their bookings page. That's fine for trying the app
192
+ out; configure SMTP before real clients use it. With SMTP on, links only
193
+ ever go to the inbox.
187
194
  - The rate limiter is **single-instance and in-memory**: counters are
188
195
  per-process and reset on restart. Fine for the one-container target; a
189
196
  multi-instance deployment needs a shared store (or limit at the proxy).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "studio-os",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Self-hosted studio management: booking, class packs, memberships. Bring your own Stripe.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,15 +19,15 @@
19
19
  "bcryptjs": "^3.0.3",
20
20
  "better-sqlite3": "^13.0.1",
21
21
  "cookie-session": "^2.1.0",
22
- "dotenv": "^17.4.2",
22
+ "dotenv": "^18.0.3",
23
23
  "ejs": "^6.0.1",
24
24
  "express": "^5.2.1",
25
- "marked": "^18.0.9",
26
- "nodemailer": "^10.0.9",
25
+ "marked": "^18.0.14",
26
+ "nodemailer": "^10.0.10",
27
27
  "stripe": "^22.6.2"
28
28
  },
29
29
  "devDependencies": {
30
- "supertest": "^7.0.0"
30
+ "supertest": "^7.3.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
package/src/app.js CHANGED
@@ -53,6 +53,9 @@ export function createApp({ db, mailer, stripeService, env = process.env, now =
53
53
  // before any body parser.
54
54
  app.use('/webhooks/stripe', express.raw({ type: '*/*' }), webhookRoutes(services));
55
55
 
56
+ // A pasted Mindbody client export is 100+ bytes a row once URL-encoded, so
57
+ // the 100kb default refused any studio with more than a few hundred clients.
58
+ app.use('/admin/import', express.urlencoded({ extended: true, limit: '20mb' }));
56
59
  app.use(express.urlencoded({ extended: true }));
57
60
  app.use(express.static(path.join(__dirname, '..', 'public')));
58
61
  app.use(cookieSession({
@@ -149,7 +152,11 @@ export function createApp({ db, mailer, stripeService, env = process.env, now =
149
152
  // eslint-disable-next-line no-unused-vars
150
153
  app.use((err, req, res, next) => {
151
154
  console.error('[error]', err);
152
- res.status(500).render('error', { title: 'Error', message: err.expose ? err.message : 'Something went wrong.' });
155
+ // Body-parser errors (oversized or malformed bodies) arrive before the
156
+ // locals middleware has run, and the layout can't render without these.
157
+ res.locals = { settings: {}, user: null, flash: null, ...res.locals };
158
+ const status = err.status >= 400 && err.status < 500 ? err.status : 500;
159
+ res.status(status).render('error', { title: 'Error', message: err.expose ? err.message : 'Something went wrong.' });
153
160
  });
154
161
 
155
162
  return app;
@@ -0,0 +1,6 @@
1
+ -- v3: membership renewals are recorded from Stripe invoices, keyed on the
2
+ -- invoice id so webhook retries can't record the same month twice.
3
+
4
+ ALTER TABLE payments ADD COLUMN stripe_invoice_id TEXT;
5
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_payments_invoice
6
+ ON payments(stripe_invoice_id) WHERE stripe_invoice_id IS NOT NULL;
@@ -0,0 +1,5 @@
1
+ -- v4: a drop-in paid at the studio is only owed while its booking stands, so
2
+ -- the pending payment remembers which booking it's for.
3
+
4
+ ALTER TABLE payments ADD COLUMN booking_id INTEGER REFERENCES bookings(id);
5
+ CREATE INDEX IF NOT EXISTS idx_payments_booking ON payments(booking_id) WHERE booking_id IS NOT NULL;
package/src/db/index.js CHANGED
@@ -1,82 +1,84 @@
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
- }
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
+ { version: 3, file: '003-stripe-invoices.sql' },
13
+ { version: 4, file: '004-payment-booking.sql' },
14
+ ];
15
+
16
+ const DEFAULT_SETTINGS = {
17
+ studio_name: '',
18
+ timezone: 'Asia/Hong_Kong',
19
+ currency: 'HKD',
20
+ cancellation_window_hours: '12',
21
+ late_cancel_policy: 'forfeit', // forfeit | refund
22
+ 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.',
23
+ setup_complete: '0',
24
+ horizon_weeks: '8',
25
+ };
26
+
27
+ /**
28
+ * Open (or create) the database, enable WAL, run migrations, seed defaults.
29
+ * @param {string} [dbPath] file path or ':memory:' (tests)
30
+ */
31
+ export function openDb(dbPath) {
32
+ const file = dbPath || process.env.DB_PATH || path.join(process.cwd(), 'data', 'studio.db');
33
+ if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true });
34
+ const db = new Database(file);
35
+ db.pragma('journal_mode = WAL');
36
+ db.pragma('foreign_keys = ON');
37
+ db.pragma('busy_timeout = 5000');
38
+ migrate(db);
39
+ return db;
40
+ }
41
+
42
+ function migrate(db) {
43
+ db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
44
+ const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
45
+ for (const m of MIGRATIONS) {
46
+ if (applied.has(m.version)) continue;
47
+ const sql = fs.readFileSync(path.join(__dirname, m.file), 'utf8');
48
+ db.transaction(() => {
49
+ db.exec(sql);
50
+ db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)')
51
+ .run(m.version, new Date().toISOString());
52
+ })();
53
+ }
54
+ // seed default settings (INSERT OR IGNORE keeps existing values)
55
+ const ins = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
56
+ for (const [k, v] of Object.entries(DEFAULT_SETTINGS)) ins.run(k, v);
57
+ // app secret: generated once, persisted, overridable via env
58
+ if (!process.env.APP_SECRET) {
59
+ ins.run('app_secret', crypto.randomBytes(32).toString('hex'));
60
+ }
61
+ }
62
+
63
+ export function getSetting(db, key, fallback = '') {
64
+ if (key === 'app_secret' && process.env.APP_SECRET) return process.env.APP_SECRET;
65
+ const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
66
+ return row ? row.value : fallback;
67
+ }
68
+
69
+ export function setSetting(db, key, value) {
70
+ db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
71
+ .run(key, String(value));
72
+ }
73
+
74
+ export function allSettings(db) {
75
+ const out = {};
76
+ for (const r of db.prepare('SELECT key, value FROM settings').all()) out[r.key] = r.value;
77
+ if (process.env.APP_SECRET) out.app_secret = process.env.APP_SECRET;
78
+ return out;
79
+ }
80
+
81
+ export function audit(db, who, action, entity = '') {
82
+ db.prepare('INSERT INTO audit_log (who, action, entity, at) VALUES (?, ?, ?, ?)')
83
+ .run(who || '', action, entity, new Date().toISOString());
84
+ }