studio-os 0.5.0 → 0.6.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 +30 -0
- package/README.md +9 -4
- package/package.json +2 -1
- package/src/app.js +6 -0
- package/src/lib/email.js +5 -0
- package/src/lib/ratelimit.js +65 -24
- package/src/routes/admin.js +76 -13
- package/src/routes/public.js +4 -3
- package/src/routes/setup.js +2 -1
- package/src/services/importer.js +3 -2
- package/src/services/stripe.js +51 -0
- package/test/ratelimit.test.js +28 -0
- package/test/stripe.test.js +90 -1
- package/test/web.test.js +76 -0
- package/views/admin/class_types.ejs +1 -1
- package/views/admin/rules.ejs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 — 2026-09-27
|
|
4
|
+
|
|
5
|
+
**Upgrading:** in the Stripe dashboard, add `customer.subscription.updated`
|
|
6
|
+
to your webhook endpoint's events. Without it, failed renewals still won't
|
|
7
|
+
pause memberships. No database changes.
|
|
8
|
+
|
|
9
|
+
- **A failed renewal left the membership active.** When a card was declined
|
|
10
|
+
the subscription went past due, but the client kept booking on it. The
|
|
11
|
+
membership now pauses until the payment goes through, and resumes by
|
|
12
|
+
itself when it does.
|
|
13
|
+
- **Pausing or cancelling a Stripe membership didn't stop the billing.**
|
|
14
|
+
The buttons on the client page only changed the app's record. They now
|
|
15
|
+
pause, resume or cancel the subscription itself, and if Stripe says no,
|
|
16
|
+
nothing changes. An unrecognised status used to 500.
|
|
17
|
+
- **Capacity changes skipped classes already on the schedule.** Raising a
|
|
18
|
+
class type's capacity, or a rule's override, left every class already
|
|
19
|
+
generated at the old size. Upcoming classes still at that size now follow,
|
|
20
|
+
and the extra spots go to the waitlist, same as editing one class.
|
|
21
|
+
Classes resized by hand keep their own number.
|
|
22
|
+
- **Rate limiting on every route.** Magic links, login and booking already
|
|
23
|
+
had tight limits. Everything else now sits under a 1000 requests per
|
|
24
|
+
15 minutes ceiling per address, well above what a busy front desk uses.
|
|
25
|
+
IPv6 clients are counted per /56, so rotating addresses no longer gets
|
|
26
|
+
around any limit.
|
|
27
|
+
- **A long junk email could stall the server.** The email check backtracked
|
|
28
|
+
on a 50kb input for about a second, blocking every other request. It's
|
|
29
|
+
linear now.
|
|
30
|
+
- **Dependencies**: express-rate-limit 8.7.0 added.
|
|
31
|
+
- **Tests**: 144 → 151.
|
|
32
|
+
|
|
3
33
|
## 0.5.0 — 2026-09-27
|
|
4
34
|
|
|
5
35
|
**Upgrading:** the database migrates itself on start (schema v4, one new
|
package/README.md
CHANGED
|
@@ -136,9 +136,12 @@ activate purchases manually from the client profile — fully usable cash-only.
|
|
|
136
136
|
- `STRIPE_WEBHOOK_SECRET` — see step 3
|
|
137
137
|
3. Add a webhook endpoint in Stripe: `https://your-domain/webhooks/stripe`,
|
|
138
138
|
events `checkout.session.completed`, `checkout.session.async_payment_succeeded`,
|
|
139
|
-
`invoice.paid
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
`invoice.paid`, `customer.subscription.updated` and
|
|
140
|
+
`customer.subscription.deleted`. The async event is what fulfils payments
|
|
141
|
+
that clear later, like bank debits; card-only studios never see it.
|
|
142
|
+
`invoice.paid` records each membership renewal in the revenue report, and
|
|
143
|
+
`customer.subscription.updated` pauses a membership whose renewal failed
|
|
144
|
+
until the card goes through.
|
|
142
145
|
Copy the signing secret into `STRIPE_WEBHOOK_SECRET`.
|
|
143
146
|
(Local testing: `stripe listen --forward-to localhost:3000/webhooks/stripe`.)
|
|
144
147
|
The webhook secret is not optional: online payment stays off until it is
|
|
@@ -170,7 +173,9 @@ What's protected out of the box:
|
|
|
170
173
|
authenticated by Stripe's signature over the raw body instead.
|
|
171
174
|
- **Rate limiting** (in-memory fixed window, per IP + route): magic-link
|
|
172
175
|
requests 5/15 min, admin login 10/15 min, public booking/buy POSTs
|
|
173
|
-
30/15 min
|
|
176
|
+
30/15 min, and 1000/15 min across the whole app. IPv6 clients are
|
|
177
|
+
counted per /56, so rotating addresses doesn't reset the count. Over the
|
|
178
|
+
limit → friendly 429. Behind a reverse proxy, set
|
|
174
179
|
`TRUST_PROXY=1` so limits key on the first `X-Forwarded-For` hop; without
|
|
175
180
|
it that header is ignored (it's spoofable).
|
|
176
181
|
- **Reverse-proxy awareness**: `TRUST_PROXY=1` also makes the app honour
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "studio-os",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Self-hosted studio management: booking, class packs, memberships. Bring your own Stripe.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"dotenv": "^18.0.3",
|
|
23
23
|
"ejs": "^6.0.1",
|
|
24
24
|
"express": "^5.2.1",
|
|
25
|
+
"express-rate-limit": "^8.7.0",
|
|
25
26
|
"marked": "^18.0.14",
|
|
26
27
|
"nodemailer": "^10.0.10",
|
|
27
28
|
"stripe": "^22.6.2"
|
package/src/app.js
CHANGED
|
@@ -58,6 +58,12 @@ export function createApp({ db, mailer, stripeService, env = process.env, now =
|
|
|
58
58
|
app.use('/admin/import', express.urlencoded({ extended: true, limit: '20mb' }));
|
|
59
59
|
app.use(express.urlencoded({ extended: true }));
|
|
60
60
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
61
|
+
// A ceiling on everything else, well above what a busy front desk or a
|
|
62
|
+
// shared studio wifi does. Static files don't count, and the Stripe
|
|
63
|
+
// webhook is mounted before this so a burst of events is never refused.
|
|
64
|
+
app.use(createRateLimiter({
|
|
65
|
+
windowMs: 15 * 60 * 1000, max: 1000, env, now, perRoute: false,
|
|
66
|
+
}));
|
|
61
67
|
app.use(cookieSession({
|
|
62
68
|
name: 'studio_session',
|
|
63
69
|
keys: [getSetting(db, 'app_secret', 'dev-secret')],
|
package/src/lib/email.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Linear time on purpose. The old pattern let the domain overlap the dot and
|
|
2
|
+
// backtracked quadratically: one 50kb POST held the event loop for ~1s.
|
|
3
|
+
export function isEmail(s) {
|
|
4
|
+
return typeof s === 'string' && s.length <= 254 && /^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$/.test(s);
|
|
5
|
+
}
|
package/src/lib/ratelimit.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// In-memory fixed-window rate limiting on top of express-rate-limit.
|
|
2
2
|
//
|
|
3
3
|
// Deliberately simple and honest about its limits: counters live in this
|
|
4
4
|
// process's memory, so they reset on restart and are per-process (a
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// proxy, set TRUST_PROXY=1 so the client IP is read from the first
|
|
7
7
|
// X-Forwarded-For hop; without it the header is ignored (spoofable) and the
|
|
8
8
|
// socket address is used.
|
|
9
|
+
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
|
|
9
10
|
|
|
10
11
|
export function clientIp(req, env = process.env) {
|
|
11
12
|
if (env.TRUST_PROXY) {
|
|
@@ -15,36 +16,76 @@ export function clientIp(req, env = process.env) {
|
|
|
15
16
|
return (req.socket && req.socket.remoteAddress) || 'unknown';
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
/** Fixed windows on an injectable clock, so tests can move time. */
|
|
20
|
+
class FixedWindowStore {
|
|
21
|
+
constructor(now) {
|
|
22
|
+
this.now = now;
|
|
23
|
+
this.buckets = new Map(); // key → { window, count }
|
|
24
|
+
this.localKeys = true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
init({ windowMs }) {
|
|
28
|
+
this.windowMs = windowMs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
increment(key) {
|
|
32
|
+
const windowId = Math.floor(this.now() / this.windowMs);
|
|
33
|
+
let b = this.buckets.get(key);
|
|
30
34
|
if (!b || b.window !== windowId) {
|
|
31
35
|
b = { window: windowId, count: 0 };
|
|
32
|
-
buckets.set(key, b);
|
|
36
|
+
this.buckets.set(key, b);
|
|
33
37
|
}
|
|
34
38
|
b.count += 1;
|
|
35
|
-
|
|
39
|
+
// opportunistic cleanup so the map cannot grow without bound
|
|
40
|
+
if (this.buckets.size > 10000) {
|
|
41
|
+
for (const [k, v] of this.buckets) if (v.window !== windowId) this.buckets.delete(k);
|
|
42
|
+
}
|
|
43
|
+
return { totalHits: b.count, resetTime: new Date((windowId + 1) * this.windowMs) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
decrement(key) {
|
|
47
|
+
const b = this.buckets.get(key);
|
|
48
|
+
if (b && b.count > 0) b.count -= 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
resetKey(key) {
|
|
52
|
+
this.buckets.delete(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* createRateLimiter({ windowMs, max, env?, now?, message?, perRoute? }) → Express middleware.
|
|
58
|
+
* Keyed on client IP (IPv6 by /56, so one host can't rotate addresses) plus,
|
|
59
|
+
* unless perRoute is false, the matched route pattern (so /class/1/book and
|
|
60
|
+
* /class/2/book share one bucket). `now` is injectable for tests.
|
|
61
|
+
*/
|
|
62
|
+
export function createRateLimiter({
|
|
63
|
+
windowMs, max, env = process.env, now = Date.now, message, perRoute = true,
|
|
64
|
+
} = {}) {
|
|
65
|
+
const store = new FixedWindowStore(now);
|
|
66
|
+
const mw = rateLimit({
|
|
67
|
+
windowMs,
|
|
68
|
+
limit: max,
|
|
69
|
+
store,
|
|
70
|
+
standardHeaders: false,
|
|
71
|
+
legacyHeaders: false,
|
|
72
|
+
// clientIp() does its own proxy handling; the library's checks assume req.ip.
|
|
73
|
+
validate: false,
|
|
74
|
+
keyGenerator(req) {
|
|
75
|
+
const ip = ipKeyGenerator(clientIp(req, env));
|
|
76
|
+
if (!perRoute) return ip;
|
|
77
|
+
return `${ip}|${req.method} ${(req.route && req.route.path) || req.path}`;
|
|
78
|
+
},
|
|
79
|
+
handler(req, res) {
|
|
80
|
+
// The app-wide limiter runs before the view locals exist.
|
|
81
|
+
res.locals = { settings: {}, user: null, flash: null, ...res.locals };
|
|
36
82
|
res.set('Retry-After', String(Math.ceil(windowMs / 1000)));
|
|
37
|
-
|
|
83
|
+
res.status(429).render('error', {
|
|
38
84
|
title: 'Too many requests',
|
|
39
85
|
message: message || 'Too many requests from your address — please wait a few minutes and try again.',
|
|
40
86
|
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
mw.buckets = store.buckets; // exposed for tests/inspection
|
|
49
90
|
return mw;
|
|
50
91
|
}
|
package/src/routes/admin.js
CHANGED
|
@@ -12,9 +12,10 @@ import { importClients, importPasses } from '../services/importer.js';
|
|
|
12
12
|
import { emails } from '../services/mailer.js';
|
|
13
13
|
import { notifyPromoted } from '../services/notify.js';
|
|
14
14
|
import { localDateStr, localTimeStr, zonedToUtc, addDays, weekdayOf } from '../lib/time.js';
|
|
15
|
+
import { isEmail } from '../lib/email.js';
|
|
15
16
|
|
|
16
17
|
export default function adminRoutes(services) {
|
|
17
|
-
const { db, mailer } = services;
|
|
18
|
+
const { db, mailer, stripe } = services;
|
|
18
19
|
const r = Router();
|
|
19
20
|
r.use(requireAdmin);
|
|
20
21
|
|
|
@@ -75,16 +76,46 @@ export default function adminRoutes(services) {
|
|
|
75
76
|
res.redirect('/admin/class-types');
|
|
76
77
|
});
|
|
77
78
|
|
|
79
|
+
// Future classes still at the old size follow a capacity change, and anyone
|
|
80
|
+
// who moves up from the waitlist is emailed. A class resized by hand on its
|
|
81
|
+
// roster keeps its own number.
|
|
82
|
+
function resizeFuture(req, res, instanceIds, capacity) {
|
|
83
|
+
const results = db.transaction(() => instanceIds.map((id) => updateInstance(db, id, { capacity })))();
|
|
84
|
+
let promoted = 0;
|
|
85
|
+
for (const { instance, promoted: rows } of results) {
|
|
86
|
+
for (const p of rows) notifyPromoted(services, req, res, p, instance);
|
|
87
|
+
promoted += rows.length;
|
|
88
|
+
}
|
|
89
|
+
return promoted;
|
|
90
|
+
}
|
|
91
|
+
const resizedNote = (count, promoted) => (count
|
|
92
|
+
? ` ${count} upcoming class(es) resized${promoted ? `, ${promoted} moved up from the waitlist and emailed` : ''}.`
|
|
93
|
+
: '');
|
|
94
|
+
|
|
78
95
|
r.post('/class-types/:id', (req, res) => {
|
|
79
96
|
const b = req.body;
|
|
80
97
|
if (b.action === 'toggle') {
|
|
81
98
|
db.prepare('UPDATE class_types SET active = 1 - active WHERE id = ?').run(req.params.id);
|
|
82
99
|
} else {
|
|
100
|
+
const capacity = Number(b.capacity || 10);
|
|
101
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
102
|
+
req.session.flash = 'Capacity must be a whole number, at least 1.';
|
|
103
|
+
return res.redirect('/admin/class-types');
|
|
104
|
+
}
|
|
105
|
+
const type = db.prepare('SELECT capacity FROM class_types WHERE id = ?').get(req.params.id);
|
|
106
|
+
if (!type) return res.redirect('/admin/class-types');
|
|
83
107
|
db.prepare(
|
|
84
108
|
'UPDATE class_types SET name=?, description=?, duration_min=?, capacity=?, drop_in_price_cents=?, credits_required=?, color=? WHERE id=?'
|
|
85
|
-
).run(b.name, b.description || '', Number(b.duration_min) || 60,
|
|
109
|
+
).run(b.name, b.description || '', Number(b.duration_min) || 60, capacity,
|
|
86
110
|
Math.round(Number(b.drop_in_price || 0) * 100), Number(b.credits_required) || 1,
|
|
87
111
|
b.color || '#4f7cac', req.params.id);
|
|
112
|
+
const ids = capacity === type.capacity ? [] : db.prepare(
|
|
113
|
+
`SELECT ci.id FROM class_instances ci LEFT JOIN schedule_rules sr ON sr.id = ci.rule_id
|
|
114
|
+
WHERE ci.class_type_id = ? AND ci.status = 'scheduled' AND ci.starts_at > ?
|
|
115
|
+
AND ci.capacity = ? AND sr.capacity_override IS NULL`
|
|
116
|
+
).pluck().all(req.params.id, new Date().toISOString(), type.capacity);
|
|
117
|
+
const promoted = resizeFuture(req, res, ids, capacity);
|
|
118
|
+
req.session.flash = `Class type updated.${resizedNote(ids.length, promoted)}`;
|
|
88
119
|
}
|
|
89
120
|
audit(db, who(req), 'class_type_update', `class_type:${req.params.id}`);
|
|
90
121
|
res.redirect('/admin/class-types');
|
|
@@ -117,7 +148,7 @@ export default function adminRoutes(services) {
|
|
|
117
148
|
r.post('/instructors/new', (req, res) => {
|
|
118
149
|
const email = String(req.body.email || '').trim().toLowerCase();
|
|
119
150
|
const password = String(req.body.password || '');
|
|
120
|
-
if (
|
|
151
|
+
if (!isEmail(email) || password.length < 8) {
|
|
121
152
|
return res.status(400).render('admin/instructor_new', {
|
|
122
153
|
title: 'New instructor login',
|
|
123
154
|
error: 'A valid email and a password of at least 8 characters are required.',
|
|
@@ -242,15 +273,31 @@ export default function adminRoutes(services) {
|
|
|
242
273
|
req.session.flash = 'Start time must be HH:MM.';
|
|
243
274
|
return res.redirect('/admin/rules');
|
|
244
275
|
}
|
|
276
|
+
const override = b.capacity_override ? Number(b.capacity_override) : null;
|
|
277
|
+
if (override !== null && (!Number.isInteger(override) || override < 1)) {
|
|
278
|
+
req.session.flash = 'Capacity override must be a whole number, at least 1.';
|
|
279
|
+
return res.redirect('/admin/rules');
|
|
280
|
+
}
|
|
281
|
+
const effectiveCapacity = (ruleId) => db.prepare(
|
|
282
|
+
`SELECT COALESCE(sr.capacity_override, ct.capacity) FROM schedule_rules sr
|
|
283
|
+
JOIN class_types ct ON ct.id = sr.class_type_id WHERE sr.id = ?`
|
|
284
|
+
).pluck().get(ruleId);
|
|
285
|
+
const before = effectiveCapacity(req.params.id);
|
|
245
286
|
db.transaction(() => {
|
|
246
287
|
db.prepare(
|
|
247
288
|
'UPDATE schedule_rules SET class_type_id=?, instructor_id=?, weekday=?, start_time=?, capacity_override=?, active_from=?, active_until=? WHERE id=?'
|
|
248
289
|
).run(Number(b.class_type_id), Number(b.instructor_id) || null, Number(b.weekday),
|
|
249
|
-
b.start_time,
|
|
290
|
+
b.start_time, override, b.active_from || null,
|
|
250
291
|
b.active_until || null, req.params.id);
|
|
251
292
|
dropUnbookedFutureInstances(req.params.id);
|
|
252
293
|
generateInstances(db);
|
|
253
294
|
})();
|
|
295
|
+
const after = effectiveCapacity(req.params.id);
|
|
296
|
+
const ids = after === before ? [] : db.prepare(
|
|
297
|
+
`SELECT id FROM class_instances WHERE rule_id = ? AND class_type_id = ? AND status = 'scheduled'
|
|
298
|
+
AND starts_at > ? AND capacity = ?`
|
|
299
|
+
).pluck().all(req.params.id, Number(b.class_type_id), new Date().toISOString(), before);
|
|
300
|
+
const promoted = resizeFuture(req, res, ids, after);
|
|
254
301
|
// Booked classes stay where they were, so say if any are now off-pattern.
|
|
255
302
|
const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
|
|
256
303
|
const stranded = db.prepare(
|
|
@@ -259,9 +306,9 @@ export default function adminRoutes(services) {
|
|
|
259
306
|
weekdayOf(localDateStr(tz, ci.starts_at)) !== Number(b.weekday)
|
|
260
307
|
|| localTimeStr(tz, ci.starts_at) !== b.start_time
|
|
261
308
|
)).length;
|
|
262
|
-
req.session.flash = stranded
|
|
309
|
+
req.session.flash = (stranded
|
|
263
310
|
? `Rule updated. ${stranded} booked class(es) are still at the old time; cancel them from the schedule if they've moved.`
|
|
264
|
-
: 'Rule updated and schedule regenerated.';
|
|
311
|
+
: 'Rule updated and schedule regenerated.') + resizedNote(ids.length, promoted);
|
|
265
312
|
}
|
|
266
313
|
audit(db, who(req), 'rule_update', `rule:${req.params.id}`);
|
|
267
314
|
res.redirect('/admin/rules');
|
|
@@ -386,7 +433,7 @@ export default function adminRoutes(services) {
|
|
|
386
433
|
r.post('/instances/:id/walkin', (req, res) => {
|
|
387
434
|
const email = String(req.body.email || '').trim().toLowerCase();
|
|
388
435
|
const name = String(req.body.name || '').trim();
|
|
389
|
-
if (
|
|
436
|
+
if (!isEmail(email)) {
|
|
390
437
|
req.session.flash = 'A valid email is required for walk-ins.';
|
|
391
438
|
return res.redirect(`/admin/instances/${req.params.id}`);
|
|
392
439
|
}
|
|
@@ -425,7 +472,7 @@ export default function adminRoutes(services) {
|
|
|
425
472
|
|
|
426
473
|
r.post('/clients', (req, res) => {
|
|
427
474
|
const email = String(req.body.email || '').trim().toLowerCase();
|
|
428
|
-
if (
|
|
475
|
+
if (!isEmail(email)) {
|
|
429
476
|
req.session.flash = 'A valid email is required.';
|
|
430
477
|
return res.redirect('/admin/clients');
|
|
431
478
|
}
|
|
@@ -495,13 +542,29 @@ export default function adminRoutes(services) {
|
|
|
495
542
|
res.redirect(`/admin/clients/${req.params.id}`);
|
|
496
543
|
});
|
|
497
544
|
|
|
498
|
-
r.post('/clients/:id/memberships', (req, res) => {
|
|
545
|
+
r.post('/clients/:id/memberships', async (req, res) => {
|
|
499
546
|
const b = req.body;
|
|
500
547
|
if (b.action === 'status' && b.membership_id) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
548
|
+
const back = `/admin/clients/${req.params.id}`;
|
|
549
|
+
const m = db.prepare('SELECT * FROM memberships WHERE id = ? AND client_id = ?')
|
|
550
|
+
.get(b.membership_id, req.params.id);
|
|
551
|
+
if (!m || !['active', 'paused', 'cancelled'].includes(b.status)) {
|
|
552
|
+
req.session.flash = 'Membership or status not recognised.';
|
|
553
|
+
return res.redirect(back);
|
|
554
|
+
}
|
|
555
|
+
if (m.stripe_subscription_id && !stripe.configured) {
|
|
556
|
+
req.session.flash = "Stripe isn't configured, so the subscription behind this membership wasn't changed. Change it in the Stripe dashboard too.";
|
|
557
|
+
} else if (m.stripe_subscription_id) {
|
|
558
|
+
try {
|
|
559
|
+
await stripe.setSubscriptionStatus(m.stripe_subscription_id, b.status);
|
|
560
|
+
} catch (err) {
|
|
561
|
+
req.session.flash = `Stripe refused the change, so nothing changed here either: ${err.message}`;
|
|
562
|
+
return res.redirect(back);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
db.prepare('UPDATE memberships SET status = ? WHERE id = ?').run(b.status, m.id);
|
|
566
|
+
audit(db, who(req), 'membership_status', `membership:${m.id} → ${b.status}`);
|
|
567
|
+
return res.redirect(back);
|
|
505
568
|
}
|
|
506
569
|
const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
|
|
507
570
|
const today = localDateStr(tz);
|
package/src/routes/public.js
CHANGED
|
@@ -5,6 +5,7 @@ import { upcomingInstances } from '../services/schedule.js';
|
|
|
5
5
|
import { book, resolvePayment, BookingError } from '../services/booking.js';
|
|
6
6
|
import { makeMagicToken } from '../services/auth.js';
|
|
7
7
|
import { emails } from '../services/mailer.js';
|
|
8
|
+
import { isEmail } from '../lib/email.js';
|
|
8
9
|
|
|
9
10
|
function findOrCreateClient(db, name, email) {
|
|
10
11
|
const cleanEmail = String(email || '').trim().toLowerCase();
|
|
@@ -73,7 +74,7 @@ export default function publicRoutes(services) {
|
|
|
73
74
|
error, form: { name, email },
|
|
74
75
|
});
|
|
75
76
|
|
|
76
|
-
if (!
|
|
77
|
+
if (!isEmail(email)) return renderErr('A valid email is required.');
|
|
77
78
|
|
|
78
79
|
try {
|
|
79
80
|
const result = db.transaction(() => {
|
|
@@ -143,7 +144,7 @@ export default function publicRoutes(services) {
|
|
|
143
144
|
const product = db.prepare('SELECT * FROM pack_products WHERE id = ? AND active = 1').get(req.params.id);
|
|
144
145
|
if (!product) return res.status(404).render('error', { title: 'Not found', message: 'Pack not found.' });
|
|
145
146
|
const email = String(req.body.email || '').trim().toLowerCase();
|
|
146
|
-
if (
|
|
147
|
+
if (!isEmail(email)) {
|
|
147
148
|
return res.status(400).render('public/buy_manual', { title: 'Buy', product, plan: null, email: null, error: 'A valid email is required.' });
|
|
148
149
|
}
|
|
149
150
|
if (services.stripe.checkoutReady) {
|
|
@@ -164,7 +165,7 @@ export default function publicRoutes(services) {
|
|
|
164
165
|
const plan = db.prepare('SELECT * FROM membership_plans WHERE id = ? AND active = 1').get(req.params.id);
|
|
165
166
|
if (!plan) return res.status(404).render('error', { title: 'Not found', message: 'Plan not found.' });
|
|
166
167
|
const email = String(req.body.email || '').trim().toLowerCase();
|
|
167
|
-
if (
|
|
168
|
+
if (!isEmail(email)) {
|
|
168
169
|
return res.status(400).render('public/buy_manual', { title: 'Buy', product: null, plan, email: null, error: 'A valid email is required.' });
|
|
169
170
|
}
|
|
170
171
|
if (services.stripe.checkoutReady && plan.stripe_price_id) {
|
package/src/routes/setup.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { setSetting, getSetting, audit } from '../db/index.js';
|
|
3
3
|
import { createUser } from '../services/auth.js';
|
|
4
|
+
import { isEmail } from '../lib/email.js';
|
|
4
5
|
|
|
5
6
|
const TIMEZONES = [
|
|
6
7
|
'Asia/Hong_Kong', 'Asia/Singapore', 'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Bangkok',
|
|
@@ -21,7 +22,7 @@ export default function setupRoutes({ db }) {
|
|
|
21
22
|
const fail = (error) => res.status(400).render('setup', { title: 'Setup', timezones: TIMEZONES, error, form });
|
|
22
23
|
|
|
23
24
|
if (!studio_name || !studio_name.trim()) return fail('Studio name is required.');
|
|
24
|
-
if (!
|
|
25
|
+
if (!isEmail(email)) return fail('A valid owner email is required.');
|
|
25
26
|
if (!password || password.length < 8) return fail('Password must be at least 8 characters.');
|
|
26
27
|
if (password !== password2) return fail('Passwords do not match.');
|
|
27
28
|
try {
|
package/src/services/importer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Mindbody CSV importer core. Used by scripts/import-mindbody.mjs and the
|
|
2
2
|
// admin import page. Idempotent by client email: re-running updates, never
|
|
3
3
|
// duplicates. No external CSV dependency — small RFC-4180-ish parser below.
|
|
4
|
+
import { isEmail } from '../lib/email.js';
|
|
4
5
|
|
|
5
6
|
/** Parse CSV text into an array of row objects keyed by header. Handles quoted
|
|
6
7
|
* fields, embedded commas/newlines, and doubled quotes. */
|
|
@@ -96,7 +97,7 @@ export function importClients(db, csvText, { mapping = DEFAULT_MAPPING.clients,
|
|
|
96
97
|
const work = db.transaction(() => {
|
|
97
98
|
for (const row of rows) {
|
|
98
99
|
const email = pick(row, mapping.email).toLowerCase();
|
|
99
|
-
if (!
|
|
100
|
+
if (!isEmail(email)) {
|
|
100
101
|
result.skipped++;
|
|
101
102
|
result.rows.push({ action: 'skip', reason: 'missing/invalid email', row });
|
|
102
103
|
continue;
|
|
@@ -148,7 +149,7 @@ export function importPasses(db, csvText, { mapping = DEFAULT_MAPPING.passes, dr
|
|
|
148
149
|
const work = db.transaction(() => {
|
|
149
150
|
for (const row of rows) {
|
|
150
151
|
const email = pick(row, mapping.email).toLowerCase();
|
|
151
|
-
if (!
|
|
152
|
+
if (!isEmail(email)) {
|
|
152
153
|
result.skipped++;
|
|
153
154
|
result.rows.push({ action: 'skip', reason: 'missing/invalid email', row });
|
|
154
155
|
continue;
|
package/src/services/stripe.js
CHANGED
|
@@ -67,6 +67,29 @@ export function createStripeService({ env = process.env, client } = {}) {
|
|
|
67
67
|
});
|
|
68
68
|
},
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Carry a staff status change over to the membership's subscription, so a
|
|
72
|
+
* cancelled or paused member stops being billed. Pausing voids invoices
|
|
73
|
+
* rather than deferring them.
|
|
74
|
+
*/
|
|
75
|
+
async setSubscriptionStatus(subscriptionId, status) {
|
|
76
|
+
const s = await getClient();
|
|
77
|
+
if (status !== 'cancelled') {
|
|
78
|
+
return s.subscriptions.update(subscriptionId, {
|
|
79
|
+
pause_collection: status === 'paused' ? { behavior: 'void' } : '',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return await s.subscriptions.cancel(subscriptionId);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// Already cancelled at Stripe's end is what we wanted anyway.
|
|
86
|
+
if (err.code === 'resource_missing') return null;
|
|
87
|
+
const sub = await s.subscriptions.retrieve(subscriptionId);
|
|
88
|
+
if (sub.status !== 'canceled') throw err;
|
|
89
|
+
return sub;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
|
|
70
93
|
/** Verify webhook signature and return the event. Throws on bad signature. */
|
|
71
94
|
async parseWebhook(rawBody, signature) {
|
|
72
95
|
const s = await getClient();
|
|
@@ -191,6 +214,31 @@ export function recordMembershipRenewal(db, invoice) {
|
|
|
191
214
|
})();
|
|
192
215
|
}
|
|
193
216
|
|
|
217
|
+
// Subscription status → membership status. A failed renewal pauses the
|
|
218
|
+
// membership, so it stops covering classes until the card goes through.
|
|
219
|
+
const MEMBERSHIP_STATUS = {
|
|
220
|
+
active: 'active', trialing: 'active',
|
|
221
|
+
past_due: 'paused', unpaid: 'paused', paused: 'paused',
|
|
222
|
+
canceled: 'cancelled', incomplete_expired: 'cancelled',
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Follow a subscription's status onto its membership. Only an actual status
|
|
227
|
+
* change counts: every renewal fires customer.subscription.updated too, and
|
|
228
|
+
* those mustn't undo a pause staff set in the app. A cancelled membership
|
|
229
|
+
* stays cancelled.
|
|
230
|
+
*/
|
|
231
|
+
function followSubscriptionStatus(db, sub, previous) {
|
|
232
|
+
const status = MEMBERSHIP_STATUS[sub.status];
|
|
233
|
+
if (!('status' in previous) || !status) return { fulfilled: false, reason: 'no_status_change' };
|
|
234
|
+
const r = db.prepare(
|
|
235
|
+
"UPDATE memberships SET status = ? WHERE stripe_subscription_id = ? AND status != 'cancelled'"
|
|
236
|
+
).run(status, sub.id);
|
|
237
|
+
if (!r.changes) return { fulfilled: false, reason: 'unknown_subscription' };
|
|
238
|
+
audit(db, 'stripe-webhook', 'subscription_status', `${sub.id} ${previous.status} → ${sub.status}`);
|
|
239
|
+
return { fulfilled: true, kind: 'subscription_status', status };
|
|
240
|
+
}
|
|
241
|
+
|
|
194
242
|
/** Handle a parsed Stripe event. Returns a result object for logging. */
|
|
195
243
|
export function handleStripeEvent(db, event) {
|
|
196
244
|
if (event.type === 'checkout.session.completed'
|
|
@@ -208,5 +256,8 @@ export function handleStripeEvent(db, event) {
|
|
|
208
256
|
audit(db, 'stripe-webhook', 'subscription_cancelled', sub.id);
|
|
209
257
|
return { fulfilled: r.changes > 0, kind: 'subscription_cancelled' };
|
|
210
258
|
}
|
|
259
|
+
if (event.type === 'customer.subscription.updated') {
|
|
260
|
+
return followSubscriptionStatus(db, event.data.object, event.data.previous_attributes || {});
|
|
261
|
+
}
|
|
211
262
|
return { fulfilled: false, reason: `ignored event ${event.type}` };
|
|
212
263
|
}
|
package/test/ratelimit.test.js
CHANGED
|
@@ -121,3 +121,31 @@ test('X-Forwarded-For is ignored unless TRUST_PROXY is set', async () => {
|
|
|
121
121
|
.set('X-Forwarded-For', '2.2.2.2').send({ email: 'a@t.test', _csrf: csrf2 });
|
|
122
122
|
assert.equal(otherIp.status, 200, 'different forwarded IP has its own bucket');
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
test('one IPv6 host cannot dodge the limit by rotating addresses', async () => {
|
|
126
|
+
const { app } = makeApp({ env: { TRUST_PROXY: '1' }, now: () => 10 * WINDOW });
|
|
127
|
+
const agent = request.agent(app);
|
|
128
|
+
const _csrf = await csrfToken(agent, '/magic-link');
|
|
129
|
+
const statuses = [];
|
|
130
|
+
for (let i = 1; i <= 6; i++) {
|
|
131
|
+
const res = await agent.post('/magic-link').type('form')
|
|
132
|
+
.set('X-Forwarded-For', `2001:db8:0:1::${i}`).send({ email: 'a@t.test', _csrf });
|
|
133
|
+
statuses.push(res.status);
|
|
134
|
+
}
|
|
135
|
+
assert.deepEqual(statuses, [200, 200, 200, 200, 200, 429]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('every route sits under a 1000 per 15 min ceiling; static files do not count', async () => {
|
|
139
|
+
let t = 10 * WINDOW;
|
|
140
|
+
const { app } = makeApp({ now: () => t });
|
|
141
|
+
const agent = request.agent(app);
|
|
142
|
+
for (let i = 0; i < 50; i++) assert.equal((await agent.get('/css/custom.css')).status, 200);
|
|
143
|
+
for (let i = 1; i <= 1000; i++) {
|
|
144
|
+
const res = await agent.get('/magic-link');
|
|
145
|
+
if (res.status !== 200) assert.fail(`request ${i} got ${res.status}`);
|
|
146
|
+
}
|
|
147
|
+
const blocked = await agent.get('/admin');
|
|
148
|
+
assert.equal(blocked.status, 429);
|
|
149
|
+
t += WINDOW;
|
|
150
|
+
assert.equal((await agent.get('/magic-link')).status, 200);
|
|
151
|
+
});
|
package/test/stripe.test.js
CHANGED
|
@@ -8,14 +8,23 @@ import { createStripeService, fulfillCheckoutSession } from '../src/services/str
|
|
|
8
8
|
import { createMailer } from '../src/services/mailer.js';
|
|
9
9
|
import { createUser } from '../src/services/auth.js';
|
|
10
10
|
import { openDb, setSetting } from '../src/db/index.js';
|
|
11
|
+
import { csrfToken } from './helpers.js';
|
|
11
12
|
|
|
12
13
|
const VALID_SIG = 't=123,v1=goodsignature';
|
|
13
14
|
|
|
14
15
|
/** Mock of the stripe npm client: signature check + checkout session capture. */
|
|
15
16
|
function mockStripeClient() {
|
|
16
17
|
const created = [];
|
|
17
|
-
|
|
18
|
+
const subscriptionCalls = [];
|
|
19
|
+
const client = {
|
|
18
20
|
created,
|
|
21
|
+
subscriptionCalls,
|
|
22
|
+
failNext: null,
|
|
23
|
+
subscriptions: {
|
|
24
|
+
async update(id, params) { return call(['update', id, params]); },
|
|
25
|
+
async cancel(id) { return call(['cancel', id]); },
|
|
26
|
+
async retrieve(id) { return { id, status: 'active' }; },
|
|
27
|
+
},
|
|
19
28
|
checkout: {
|
|
20
29
|
sessions: {
|
|
21
30
|
create: async (params) => {
|
|
@@ -32,6 +41,16 @@ function mockStripeClient() {
|
|
|
32
41
|
},
|
|
33
42
|
},
|
|
34
43
|
};
|
|
44
|
+
function call(entry) {
|
|
45
|
+
if (client.failNext) {
|
|
46
|
+
const err = client.failNext;
|
|
47
|
+
client.failNext = null;
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
subscriptionCalls.push(entry);
|
|
51
|
+
return { id: entry[1] };
|
|
52
|
+
}
|
|
53
|
+
return client;
|
|
35
54
|
}
|
|
36
55
|
|
|
37
56
|
function makeApp() {
|
|
@@ -247,6 +266,76 @@ test('customer.subscription.deleted cancels the matching membership', async () =
|
|
|
247
266
|
assert.equal(db.prepare('SELECT status FROM memberships WHERE client_id = ?').get(clientId).status, 'cancelled');
|
|
248
267
|
});
|
|
249
268
|
|
|
269
|
+
function membershipWithSub(db, subId, status = 'active') {
|
|
270
|
+
const clientId = db.prepare(
|
|
271
|
+
"INSERT INTO clients (name, email, source) VALUES ('M', ?, 'self')"
|
|
272
|
+
).run(`${subId}@test.hk`).lastInsertRowid;
|
|
273
|
+
const id = db.prepare(
|
|
274
|
+
`INSERT INTO memberships (client_id, plan_name, status, started_on, stripe_subscription_id, unlimited)
|
|
275
|
+
VALUES (?, 'Unlimited', ?, '2026-07-01', ?, 1)`
|
|
276
|
+
).run(clientId, status, subId).lastInsertRowid;
|
|
277
|
+
return { clientId, id, status: () => db.prepare('SELECT status FROM memberships WHERE id = ?').get(id).status };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
test('a failed renewal pauses the membership until the card goes through', async () => {
|
|
281
|
+
const { db, app } = makeApp();
|
|
282
|
+
const m = membershipWithSub(db, 'sub_dunning');
|
|
283
|
+
const updated = (status, previous) => postEvent(app, {
|
|
284
|
+
type: 'customer.subscription.updated',
|
|
285
|
+
data: { object: { id: 'sub_dunning', status }, previous_attributes: previous },
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await updated('past_due', { status: 'active' });
|
|
289
|
+
assert.equal(m.status(), 'paused');
|
|
290
|
+
await updated('active', { status: 'past_due' });
|
|
291
|
+
assert.equal(m.status(), 'active');
|
|
292
|
+
|
|
293
|
+
// A renewal also fires .updated, without a status change. It must not
|
|
294
|
+
// undo a pause staff set in the app.
|
|
295
|
+
db.prepare("UPDATE memberships SET status = 'paused' WHERE id = ?").run(m.id);
|
|
296
|
+
const renewal = await updated('active', { current_period_end: 1790000000 });
|
|
297
|
+
assert.equal(renewal.body.reason, 'no_status_change');
|
|
298
|
+
assert.equal(m.status(), 'paused');
|
|
299
|
+
|
|
300
|
+
db.prepare("UPDATE memberships SET status = 'cancelled' WHERE id = ?").run(m.id);
|
|
301
|
+
await updated('active', { status: 'past_due' });
|
|
302
|
+
assert.equal(m.status(), 'cancelled', 'a cancelled membership stays cancelled');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('staff pausing, resuming or cancelling a Stripe membership changes the subscription too', async () => {
|
|
306
|
+
const { db, app, client } = makeApp();
|
|
307
|
+
const m = membershipWithSub(db, 'sub_staff');
|
|
308
|
+
const agent = request.agent(app);
|
|
309
|
+
let _csrf = await csrfToken(agent, '/admin/login');
|
|
310
|
+
await agent.post('/admin/login').type('form').send({ email: 'owner@test.test', password: 'password123', _csrf });
|
|
311
|
+
_csrf = await csrfToken(agent, `/admin/clients/${m.clientId}`);
|
|
312
|
+
const setStatus = (status) => agent.post(`/admin/clients/${m.clientId}/memberships`).type('form')
|
|
313
|
+
.send({ action: 'status', membership_id: String(m.id), status, _csrf });
|
|
314
|
+
|
|
315
|
+
await setStatus('paused');
|
|
316
|
+
assert.equal(m.status(), 'paused');
|
|
317
|
+
await setStatus('active');
|
|
318
|
+
assert.equal(m.status(), 'active');
|
|
319
|
+
assert.deepEqual(client.subscriptionCalls, [
|
|
320
|
+
['update', 'sub_staff', { pause_collection: { behavior: 'void' } }],
|
|
321
|
+
['update', 'sub_staff', { pause_collection: '' }],
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
// Stripe saying no leaves the membership as it was.
|
|
325
|
+
client.failNext = Object.assign(new Error('card_declined'), { code: 'card_declined' });
|
|
326
|
+
const refused = await setStatus('paused');
|
|
327
|
+
assert.equal(refused.status, 302);
|
|
328
|
+
assert.equal(m.status(), 'active');
|
|
329
|
+
|
|
330
|
+
const bogus = await setStatus('frozen');
|
|
331
|
+
assert.equal(bogus.status, 302, 'an unknown status is a flash, not a 500');
|
|
332
|
+
assert.equal(m.status(), 'active');
|
|
333
|
+
|
|
334
|
+
await setStatus('cancelled');
|
|
335
|
+
assert.equal(m.status(), 'cancelled');
|
|
336
|
+
assert.deepEqual(client.subscriptionCalls.at(-1), ['cancel', 'sub_staff']);
|
|
337
|
+
});
|
|
338
|
+
|
|
250
339
|
test('drop-in checkout marks the pending payment paid', async () => {
|
|
251
340
|
const { db, app } = makeApp();
|
|
252
341
|
const clientId = db.prepare(
|
package/test/web.test.js
CHANGED
|
@@ -173,6 +173,19 @@ test('a two-credit class says two credits came off the pack', async () => {
|
|
|
173
173
|
assert.match(res.text, /2 credits deducted from your class pack/);
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
test('a 50kb junk email is turned away without stalling the server', async () => {
|
|
177
|
+
const { db, app } = makeApp();
|
|
178
|
+
const inst = makeInstance(db, makeClassType(db), { hoursFromNow: 48 });
|
|
179
|
+
const agent = request.agent(app);
|
|
180
|
+
const _csrf = await csrfToken(agent, `/class/${inst}`);
|
|
181
|
+
const started = Date.now();
|
|
182
|
+
const res = await agent.post(`/class/${inst}/book`).type('form')
|
|
183
|
+
.send({ email: `a@${'.'.repeat(50000)}@`, waiver_agree: '1', _csrf });
|
|
184
|
+
assert.equal(res.status, 400);
|
|
185
|
+
// The old regex took ~850ms on this; the event loop was blocked throughout.
|
|
186
|
+
assert.ok(Date.now() - started < 300, `took ${Date.now() - started}ms`);
|
|
187
|
+
});
|
|
188
|
+
|
|
176
189
|
test("booking with an existing client's email does not rename them", async () => {
|
|
177
190
|
const { db, app } = makeApp();
|
|
178
191
|
const inst = makeInstance(db, makeClassType(db), { hoursFromNow: 48 });
|
|
@@ -386,6 +399,69 @@ test('admin: editing a rule moves its empty classes instead of adding a second s
|
|
|
386
399
|
assert.equal(db.prepare('SELECT start_time FROM schedule_rules WHERE id = ?').get(ruleId).start_time, '19:00');
|
|
387
400
|
});
|
|
388
401
|
|
|
402
|
+
test('admin: a class type capacity change reaches upcoming classes still at the old size', async () => {
|
|
403
|
+
const { db, app } = makeApp();
|
|
404
|
+
const agent = request.agent(app);
|
|
405
|
+
const _csrf = await adminLogin(agent);
|
|
406
|
+
const { book } = await import('../src/services/booking.js');
|
|
407
|
+
const type = makeClassType(db, { name: 'Barre', capacity: 1 });
|
|
408
|
+
const booked = makeInstance(db, type, { hoursFromNow: 48 });
|
|
409
|
+
const handSized = makeInstance(db, type, { hoursFromNow: 72, capacity: 5 });
|
|
410
|
+
const past = makeInstance(db, type, { hoursFromNow: -24 });
|
|
411
|
+
const ruleId = db.prepare(
|
|
412
|
+
"INSERT INTO schedule_rules (class_type_id, weekday, start_time, capacity_override) VALUES (?, 1, '09:00', 1)"
|
|
413
|
+
).run(type).lastInsertRowid;
|
|
414
|
+
const fromRule = makeInstance(db, type, { hoursFromNow: 96 });
|
|
415
|
+
db.prepare('UPDATE class_instances SET rule_id = ? WHERE id = ?').run(ruleId, fromRule);
|
|
416
|
+
book(db, makeClient(db, { email: 'first@type.hk' }), booked);
|
|
417
|
+
const { booking: wl } = book(db, makeClient(db, { email: 'waiting@type.hk' }), booked);
|
|
418
|
+
const capacity = (id) => db.prepare('SELECT capacity FROM class_instances WHERE id = ?').get(id).capacity;
|
|
419
|
+
|
|
420
|
+
const res = await agent.post(`/admin/class-types/${type}`).type('form')
|
|
421
|
+
.send({ name: 'Barre', capacity: '2', drop_in_price: '150', _csrf });
|
|
422
|
+
assert.equal(res.status, 302);
|
|
423
|
+
assert.deepEqual([booked, handSized, past, fromRule].map(capacity), [2, 5, 1, 1]);
|
|
424
|
+
assert.equal(db.prepare('SELECT status FROM bookings WHERE id = ?').get(wl.id).status, 'booked');
|
|
425
|
+
assert.match((await agent.get('/admin/class-types')).text, /1 upcoming class\(es\) resized, 1 moved up from the waitlist/);
|
|
426
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
427
|
+
const mails = fs.readdirSync(OUTBOX).map((f) => fs.readFileSync(path.join(OUTBOX, f), 'utf8'));
|
|
428
|
+
assert.ok(mails.some((m) => m.includes('To: waiting@type.hk')));
|
|
429
|
+
|
|
430
|
+
const bad = await agent.post(`/admin/class-types/${type}`).type('form')
|
|
431
|
+
.send({ name: 'Barre', capacity: '-3', _csrf });
|
|
432
|
+
assert.equal(bad.status, 302);
|
|
433
|
+
assert.equal(db.prepare('SELECT capacity FROM class_types WHERE id = ?').get(type).capacity, 2);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('admin: a rule capacity change reaches its booked classes too', async () => {
|
|
437
|
+
const { db, app } = makeApp();
|
|
438
|
+
const agent = request.agent(app);
|
|
439
|
+
const _csrf = await adminLogin(agent);
|
|
440
|
+
const { book } = await import('../src/services/booking.js');
|
|
441
|
+
const type = makeClassType(db, { name: 'Reformer', capacity: 1 });
|
|
442
|
+
await agent.post('/admin/rules').type('form').send({
|
|
443
|
+
class_type_id: String(type), weekday: '2', start_time: '07:00', _csrf,
|
|
444
|
+
});
|
|
445
|
+
const ruleId = db.prepare('SELECT id FROM schedule_rules').get().id;
|
|
446
|
+
const next = db.prepare('SELECT id FROM class_instances WHERE rule_id = ? ORDER BY starts_at').get(ruleId).id;
|
|
447
|
+
book(db, makeClient(db, { email: 'first@rule.hk' }), next);
|
|
448
|
+
const { booking: wl } = book(db, makeClient(db, { email: 'waiting@rule.hk' }), next);
|
|
449
|
+
|
|
450
|
+
const res = await agent.post(`/admin/rules/${ruleId}`).type('form').send({
|
|
451
|
+
class_type_id: String(type), weekday: '2', start_time: '07:00', capacity_override: '3', _csrf,
|
|
452
|
+
});
|
|
453
|
+
assert.equal(res.status, 302);
|
|
454
|
+
const caps = db.prepare('SELECT DISTINCT capacity FROM class_instances WHERE rule_id = ?').pluck().all(ruleId);
|
|
455
|
+
assert.deepEqual(caps, [3]);
|
|
456
|
+
assert.equal(db.prepare('SELECT status FROM bookings WHERE id = ?').get(wl.id).status, 'booked');
|
|
457
|
+
|
|
458
|
+
const bad = await agent.post(`/admin/rules/${ruleId}`).type('form').send({
|
|
459
|
+
class_type_id: String(type), weekday: '2', start_time: '07:00', capacity_override: '-1', _csrf,
|
|
460
|
+
});
|
|
461
|
+
assert.equal(bad.status, 302, 'a negative override is a flash, not a 500');
|
|
462
|
+
assert.equal(db.prepare('SELECT capacity_override FROM schedule_rules WHERE id = ?').get(ruleId).capacity_override, 3);
|
|
463
|
+
});
|
|
464
|
+
|
|
389
465
|
test('admin: manual pass sale + payment, revenue report and CSV export', async () => {
|
|
390
466
|
const { db, app } = makeApp();
|
|
391
467
|
const agent = request.agent(app);
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
<label>Description <input name="description" value="<%= t.description %>"></label>
|
|
32
32
|
<div class="grid">
|
|
33
33
|
<label>Duration <input type="number" name="duration_min" value="<%= t.duration_min %>"></label>
|
|
34
|
-
<label>Capacity <input type="number" name="capacity" value="<%= t.capacity %>"></label>
|
|
34
|
+
<label>Capacity <input type="number" name="capacity" value="<%= t.capacity %>" min="1"></label>
|
|
35
35
|
<label>Drop-in price <input type="number" name="drop_in_price" value="<%= (t.drop_in_price_cents / 100).toFixed(2) %>" step="0.01"></label>
|
|
36
36
|
<label>Credits <input type="number" name="credits_required" value="<%= t.credits_required %>"></label>
|
|
37
37
|
</div>
|
package/views/admin/rules.ejs
CHANGED
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
<label>Start <input name="start_time" value="<%= rule.start_time %>"></label>
|
|
58
58
|
</div>
|
|
59
59
|
<div class="grid">
|
|
60
|
-
<label>Capacity override <input type="number" name="capacity_override" value="<%= rule.capacity_override || '' %>"></label>
|
|
60
|
+
<label>Capacity override <input type="number" name="capacity_override" value="<%= rule.capacity_override || '' %>" min="1" placeholder="(class default)"></label>
|
|
61
61
|
<label>Active from <input type="date" name="active_from" value="<%= rule.active_from || '' %>"></label>
|
|
62
62
|
<label>Active until <input type="date" name="active_until" value="<%= rule.active_until || '' %>"></label>
|
|
63
63
|
</div>
|