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 +69 -0
- package/README.md +8 -1
- package/package.json +5 -5
- package/src/app.js +8 -1
- package/src/db/003-stripe-invoices.sql +6 -0
- package/src/db/004-payment-booking.sql +5 -0
- package/src/db/index.js +84 -82
- package/src/routes/admin.js +728 -692
- package/src/routes/me.js +4 -10
- package/src/routes/public.js +21 -25
- package/src/services/booking.js +61 -8
- package/src/services/notify.js +19 -0
- package/src/services/stripe.js +38 -1
- package/test/booking-link.test.js +47 -0
- package/test/booking.test.js +80 -1
- package/test/error-paths.test.js +49 -0
- package/test/stripe.test.js +84 -1
- package/test/web.test.js +113 -1
- package/views/admin/roster.ejs +19 -0
- package/views/public/book_result.ejs +6 -6
- package/views/public/buy_thanks.ejs +1 -1
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
|
|
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
|
+
"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": "^
|
|
22
|
+
"dotenv": "^18.0.3",
|
|
23
23
|
"ejs": "^6.0.1",
|
|
24
24
|
"express": "^5.2.1",
|
|
25
|
-
"marked": "^18.0.
|
|
26
|
-
"nodemailer": "^10.0.
|
|
25
|
+
"marked": "^18.0.14",
|
|
26
|
+
"nodemailer": "^10.0.10",
|
|
27
27
|
"stripe": "^22.6.2"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"supertest": "^7.
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
db
|
|
35
|
-
db.pragma('
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
}
|