studio-os 0.4.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/test/web.test.js CHANGED
@@ -160,7 +160,43 @@ test('returning client with signed waiver books without checkbox, uses pack cred
160
160
  const res = await agent.post(`/class/${inst}/book`).type('form')
161
161
  .send({ email: 'returning@test.hk', _csrf });
162
162
  assert.equal(res.status, 200);
163
- assert.match(res.text, /credit deducted from your class pack/);
163
+ assert.match(res.text, /1 credit deducted from your class pack/);
164
+ });
165
+
166
+ test('a two-credit class says two credits came off the pack', async () => {
167
+ const { db, app } = makeApp();
168
+ const inst = makeInstance(db, makeClassType(db, { credits: 2 }), { hoursFromNow: 48 });
169
+ makePass(db, makeClient(db, { email: 'double@test.hk', waiver: true }));
170
+ const agent = request.agent(app);
171
+ const _csrf = await csrfToken(agent, `/class/${inst}`);
172
+ const res = await agent.post(`/class/${inst}/book`).type('form').send({ email: 'double@test.hk', _csrf });
173
+ assert.match(res.text, /2 credits deducted from your class pack/);
174
+ });
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
+
189
+ test("booking with an existing client's email does not rename them", async () => {
190
+ const { db, app } = makeApp();
191
+ const inst = makeInstance(db, makeClassType(db), { hoursFromNow: 48 });
192
+ const clientId = makeClient(db, { name: 'Mei Chan', email: 'mei@test.hk', waiver: true });
193
+
194
+ const agent = request.agent(app);
195
+ const _csrf = await csrfToken(agent, `/class/${inst}`);
196
+ const res = await agent.post(`/class/${inst}/book`).type('form')
197
+ .send({ name: 'Someone Else', email: 'mei@test.hk', _csrf });
198
+ assert.equal(res.status, 200);
199
+ assert.equal(db.prepare('SELECT name FROM clients WHERE id = ?').get(clientId).name, 'Mei Chan');
164
200
  });
165
201
 
166
202
  test('full class books to waitlist via HTTP', async () => {
@@ -185,6 +221,54 @@ test('full class books to waitlist via HTTP', async () => {
185
221
  assert.match(second.text, /You're #2 in line/);
186
222
  });
187
223
 
224
+ test('drop-in dues: nothing owed on the waitlist, and a cancelled booking owes nothing', async () => {
225
+ const { db, app } = makeApp();
226
+ const inst = makeInstance(db, makeClassType(db, { capacity: 1, price: 15000 }), { hoursFromNow: 48 });
227
+ const agent = request.agent(app);
228
+ const _csrf = await csrfToken(agent, `/class/${inst}`);
229
+ for (const email of ['first@t.hk', 'second@t.hk']) {
230
+ await agent.post(`/class/${inst}/book`).type('form').send({ email, waiver_agree: '1', _csrf });
231
+ }
232
+ const owing = () => db.prepare(
233
+ `SELECT c.email FROM payments p JOIN clients c ON c.id = p.client_id
234
+ WHERE p.status = 'pending' ORDER BY p.id`
235
+ ).all().map((r) => r.email);
236
+ assert.deepEqual(owing(), ['first@t.hk']);
237
+
238
+ const { cancelBooking } = await import('../src/services/booking.js');
239
+ const first = db.prepare("SELECT id FROM bookings WHERE status = 'booked'").get();
240
+ cancelBooking(db, first.id);
241
+ assert.deepEqual(owing(), ['second@t.hk']);
242
+ });
243
+
244
+ test('admin: editing a class from the roster moves the waitlist up and emails them', async () => {
245
+ const { db, app } = makeApp();
246
+ const agent = request.agent(app);
247
+ const _csrf = await adminLogin(agent);
248
+ const inst = makeInstance(db, makeClassType(db, { capacity: 1 }), { hoursFromNow: 48 });
249
+ const kim = db.prepare("INSERT INTO instructors (name) VALUES ('Kim')").run().lastInsertRowid;
250
+ const { book } = await import('../src/services/booking.js');
251
+ book(db, makeClient(db, { email: 'first@edit.hk' }), inst);
252
+ const { booking: wl } = book(db, makeClient(db, { email: 'waiting@edit.hk' }), inst);
253
+
254
+ const roster = await agent.get(`/admin/instances/${inst}`);
255
+ assert.match(roster.text, /Edit class/);
256
+ const res = await agent.post(`/admin/instances/${inst}/edit`).type('form')
257
+ .send({ capacity: '2', instructor_id: String(kim), notes: 'Room B', _csrf });
258
+ assert.equal(res.status, 302);
259
+ const row = db.prepare('SELECT capacity, instructor_id, notes FROM class_instances WHERE id = ?').get(inst);
260
+ assert.deepEqual({ ...row }, { capacity: 2, instructor_id: kim, notes: 'Room B' });
261
+ assert.equal(db.prepare('SELECT status FROM bookings WHERE id = ?').get(wl.id).status, 'booked');
262
+ await new Promise((r) => setTimeout(r, 100));
263
+ const mails = fs.readdirSync(OUTBOX).map((f) => fs.readFileSync(path.join(OUTBOX, f), 'utf8'));
264
+ assert.ok(mails.some((m) => m.includes('To: waiting@edit.hk') && m.includes('/me?token=')));
265
+
266
+ const bad = await agent.post(`/admin/instances/${inst}/edit`).type('form')
267
+ .send({ capacity: '2', instructor_id: '999', _csrf });
268
+ assert.equal(bad.status, 302, 'an unknown instructor is a flash, not a 500');
269
+ assert.equal(db.prepare('SELECT instructor_id FROM class_instances WHERE id = ?').get(inst).instructor_id, kim);
270
+ });
271
+
188
272
  test('magic link: verify round-trip, view bookings, cancel within policy', async () => {
189
273
  const { db, app } = makeApp();
190
274
  const type = makeClassType(db, { name: 'Spin' });
@@ -279,6 +363,105 @@ test('admin: rules CRUD generates instances; roster check-in works over HTTP', a
279
363
  assert.equal(db.prepare('SELECT status FROM bookings WHERE id=?').get(booking.id).status, 'attended');
280
364
  });
281
365
 
366
+ test('admin: editing a rule moves its empty classes instead of adding a second slot', async () => {
367
+ const { db, app } = makeApp();
368
+ const agent = request.agent(app);
369
+ const _csrf = await adminLogin(agent);
370
+ const { book } = await import('../src/services/booking.js');
371
+ const { localTimeStr } = await import('../src/lib/time.js');
372
+
373
+ const type = makeClassType(db, { name: 'Pilates' });
374
+ await agent.post('/admin/rules').type('form').send({
375
+ class_type_id: String(type), weekday: '4', start_time: '18:00', _csrf,
376
+ });
377
+ const ruleId = db.prepare('SELECT id FROM schedule_rules').get().id;
378
+ const nowIso = new Date().toISOString();
379
+ const future = () => db.prepare(
380
+ 'SELECT starts_at FROM class_instances WHERE rule_id = ? AND starts_at > ? ORDER BY starts_at'
381
+ ).all(ruleId, nowIso).map((ci) => localTimeStr('Asia/Hong_Kong', ci.starts_at));
382
+ const slots = future().length;
383
+ const last = db.prepare('SELECT id FROM class_instances WHERE rule_id = ? ORDER BY starts_at DESC').get(ruleId).id;
384
+ book(db, makeClient(db), last);
385
+
386
+ const edit = await agent.post(`/admin/rules/${ruleId}`).type('form').send({
387
+ class_type_id: String(type), weekday: '4', start_time: '19:00', _csrf,
388
+ });
389
+ assert.equal(edit.status, 302);
390
+ const times = future();
391
+ assert.equal(times.filter((t) => t === '18:00').length, 1, 'only the booked class stays at the old time');
392
+ assert.equal(times.filter((t) => t === '19:00').length, slots);
393
+ assert.match((await agent.get('/admin/rules')).text, /1 booked class\(es\) are still at the old time/);
394
+
395
+ const bad = await agent.post(`/admin/rules/${ruleId}`).type('form').send({
396
+ class_type_id: String(type), weekday: '4', start_time: 'seven', _csrf,
397
+ });
398
+ assert.equal(bad.status, 302);
399
+ assert.equal(db.prepare('SELECT start_time FROM schedule_rules WHERE id = ?').get(ruleId).start_time, '19:00');
400
+ });
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
+
282
465
  test('admin: manual pass sale + payment, revenue report and CSV export', async () => {
283
466
  const { db, app } = makeApp();
284
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>
@@ -5,6 +5,7 @@
5
5
  <% if (inst.instructor_name) { %> · <%= inst.instructor_name %><% } %>
6
6
  <% if (inst.status === 'cancelled') { %> · <mark>CANCELLED</mark><% } %></p>
7
7
  </hgroup>
8
+ <% if (inst.notes) { %><p><small><%= inst.notes %></small></p><% } %>
8
9
  <table>
9
10
  <thead><tr><th>Client</th><th>Paid with</th><th>Status</th><th>Actions</th></tr></thead>
10
11
  <tbody>
@@ -38,6 +39,24 @@
38
39
  <% } %>
39
40
  </ol>
40
41
  <% } %>
42
+ <% if (inst.status === 'scheduled') { %>
43
+ <details>
44
+ <summary role="button" class="outline">Edit class</summary>
45
+ <form method="post" action="/admin/instances/<%= inst.id %>/edit"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
46
+ <div class="grid">
47
+ <label>Capacity <input type="number" name="capacity" min="1" required value="<%= inst.capacity %>"></label>
48
+ <label>Instructor
49
+ <select name="instructor_id"><option value="">—</option>
50
+ <% for (const i of instructors) { %><option value="<%= i.id %>" <%= i.id === inst.instructor_id ? 'selected' : '' %>><%= i.name %></option><% } %>
51
+ </select>
52
+ </label>
53
+ </div>
54
+ <label>Notes <input name="notes" value="<%= inst.notes || '' %>"></label>
55
+ <small>Extra spots go to the waitlist in order, and they're emailed.</small>
56
+ <button>Save</button>
57
+ </form>
58
+ </details>
59
+ <% } %>
41
60
  <details>
42
61
  <summary role="button" class="outline">Add walk-in</summary>
43
62
  <form method="post" action="/admin/instances/<%= inst.id %>/walkin"><input type="hidden" name="_csrf" value="<%= csrfToken %>">
@@ -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>
@@ -8,7 +8,7 @@
8
8
  <h2>You're booked!</h2>
9
9
  <p><strong><%= inst.class_name %></strong> · <%= fmtDt(inst.starts_at) %></p>
10
10
  <p>Payment:
11
- <% if (result.booking.paid_with === 'pack') { %>1 credit deducted from your class pack.
11
+ <% if (result.booking.paid_with === 'pack') { const n = result.instance.credits_required ?? 1; %><%= n %> credit<%= n === 1 ? '' : 's' %> deducted from your class pack.
12
12
  <% } else if (result.booking.paid_with === 'membership') { %>covered by your membership.
13
13
  <% } else if (result.paymentId) { %>please pay <%= fmtMoney(inst.drop_in_price_cents) %> at the studio (cash/FPS) before class.
14
14
  <% } else { %>drop-in.
@@ -1,7 +1,7 @@
1
1
  <%- include('../partials/head', { adminNav: false }) %>
2
2
  <article>
3
3
  <h2>Thank you!</h2>
4
- <p>Your payment was received. Your pass or membership is now active on your account — just book with the same email.</p>
4
+ <p>Your pass or membership goes on your account as soon as Stripe confirms the payment. For cards that's usually within a minute. Some bank payments take a few days. Book with the same email you paid with.</p>
5
5
  <p><a href="/" role="button">Browse the schedule</a></p>
6
6
  </article>
7
7
  <%- include('../partials/foot') %>