studio-os 0.4.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/src/routes/me.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // Client self-service via signed magic links. No passwords.
2
2
  import { Router } from 'express';
3
- import { verifyMagicToken, makeMagicToken } from '../services/auth.js';
3
+ import { verifyMagicToken } from '../services/auth.js';
4
4
  import { cancelBooking, BookingError } from '../services/booking.js';
5
5
  import { getSetting, audit } from '../db/index.js';
6
6
  import { emails } from '../services/mailer.js';
7
+ import { notifyPromoted } from '../services/notify.js';
7
8
 
8
9
  export default function meRoutes(services) {
9
10
  const { db, mailer } = services;
@@ -76,14 +77,7 @@ export default function meRoutes(services) {
76
77
  to: client.email,
77
78
  ...emails.cancellation({ studio, clientName: client.name, className: inst.class_name, when, refunded: result.refunded }),
78
79
  });
79
- if (result.promoted) {
80
- const promotedClient = db.prepare('SELECT * FROM clients WHERE id = ?').get(result.promoted.client_id);
81
- const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, promotedClient.id)}`;
82
- mailer.send({
83
- to: promotedClient.email,
84
- ...emails.waitlistPromotion({ studio, clientName: promotedClient.name, className: inst.class_name, when, magicUrl }),
85
- });
86
- }
80
+ if (result.promoted) notifyPromoted(services, req, res, result.promoted, inst);
87
81
  req.session.flash = result.refunded
88
82
  ? 'Booking cancelled — your credit has been returned.'
89
83
  : (result.late ? 'Booking cancelled. This was a late cancellation, so the credit was not returned.' : 'Booking cancelled.');
@@ -8,18 +8,14 @@ import { emails } from '../services/mailer.js';
8
8
 
9
9
  function findOrCreateClient(db, name, email) {
10
10
  const cleanEmail = String(email || '').trim().toLowerCase();
11
- let client = db.prepare('SELECT * FROM clients WHERE email = ?').get(cleanEmail);
12
- if (!client) {
13
- const info = db.prepare(
14
- "INSERT INTO clients (name, email, source) VALUES (?, ?, 'self')"
15
- ).run(String(name || cleanEmail.split('@')[0]).trim(), cleanEmail);
16
- client = db.prepare('SELECT * FROM clients WHERE id = ?').get(info.lastInsertRowid);
17
- } else if (name && name.trim() && client.name !== name.trim()) {
18
- // keep the freshest name the client typed
19
- db.prepare('UPDATE clients SET name = ? WHERE id = ?').run(name.trim(), client.id);
20
- client.name = name.trim();
21
- }
22
- return client;
11
+ const client = db.prepare('SELECT * FROM clients WHERE email = ?').get(cleanEmail);
12
+ // An existing client keeps their name. The form proves nothing about who's
13
+ // typing, so it can't be allowed to rename whoever owns the address.
14
+ if (client) return client;
15
+ const info = db.prepare(
16
+ "INSERT INTO clients (name, email, source) VALUES (?, ?, 'self')"
17
+ ).run(String(name || cleanEmail.split('@')[0]).trim(), cleanEmail);
18
+ return db.prepare('SELECT * FROM clients WHERE id = ?').get(info.lastInsertRowid);
23
19
  }
24
20
 
25
21
  function loadInstanceFull(db, id) {
@@ -90,20 +86,10 @@ export default function publicRoutes(services) {
90
86
  db.prepare('UPDATE clients SET waiver_signed_at = ? WHERE id = ?')
91
87
  .run(new Date().toISOString(), client.id);
92
88
  }
93
- const booked = book(db, client.id, inst.id);
94
- // Drop-in without membership/pack: record the pending manual payment.
95
- let payment = null;
96
- if (booked.booking.paid_with === 'drop_in_manual' && inst.drop_in_price_cents > 0) {
97
- const info = db.prepare(
98
- `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
99
- VALUES (?, ?, ?, 'other', ?, 'drop_in', 'pending')`
100
- ).run(client.id, inst.drop_in_price_cents, getSetting(db, 'currency', 'HKD'),
101
- `Drop-in: ${inst.class_name} ${inst.starts_at}`);
102
- payment = info.lastInsertRowid;
103
- }
89
+ const booked = book(db, client.id, inst.id, { recordDue: true });
104
90
  audit(db, client.email, booked.waitlisted ? 'waitlist_join' : 'booking_create',
105
91
  `instance:${inst.id} booking:${booked.booking.id}`);
106
- return { client, ...booked, paymentId: payment };
92
+ return { client, ...booked };
107
93
  })();
108
94
 
109
95
  const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, result.client.id)}`;
@@ -118,6 +118,20 @@ function refund(db, booking, creditsNeeded) {
118
118
  }
119
119
  }
120
120
 
121
+ /** A pay-at-studio drop-in is owed once the client holds a spot. */
122
+ function recordDropInDue(db, booking, inst) {
123
+ if (booking.paid_with !== 'drop_in_manual' || !(inst.drop_in_price_cents > 0)) return null;
124
+ return db.prepare(
125
+ `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status, booking_id)
126
+ VALUES (?, ?, ?, 'other', ?, 'drop_in', 'pending', ?)`
127
+ ).run(booking.client_id, inst.drop_in_price_cents, getSetting(db, 'currency', 'HKD'),
128
+ `Drop-in: ${inst.class_name} ${inst.starts_at}`, booking.id).lastInsertRowid;
129
+ }
130
+
131
+ function dropDropInDue(db, bookingId) {
132
+ db.prepare("DELETE FROM payments WHERE booking_id = ? AND status = 'pending'").run(bookingId);
133
+ }
134
+
121
135
  function loadInstance(db, instanceId) {
122
136
  const inst = db.prepare(
123
137
  `SELECT ci.*, ct.credits_required, ct.name AS class_name, ct.drop_in_price_cents
@@ -131,8 +145,9 @@ function loadInstance(db, instanceId) {
131
145
  /**
132
146
  * Book a client into a class instance.
133
147
  * options: { paidWith } to force ('drop_in_online'|'drop_in_manual'|'comp'),
134
- * { now } ISO override for tests, { allowPast } for admin walk-ins.
135
- * Returns the booking row plus { waitlisted: bool }.
148
+ * { now } ISO override for tests, { allowPast } for admin walk-ins,
149
+ * { recordDue } to record a pending payment for a pay-at-studio drop-in.
150
+ * Returns the booking row plus { waitlisted: bool, paymentId }.
136
151
  */
137
152
  export function book(db, clientId, instanceId, options = {}) {
138
153
  const nowIso = options.now || new Date().toISOString();
@@ -172,7 +187,8 @@ export function book(db, clientId, instanceId, options = {}) {
172
187
  status === 'booked' ? resolution.membershipId ?? null : null,
173
188
  nowIso);
174
189
  const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(info.lastInsertRowid);
175
- return { booking, waitlisted: full, instance: inst };
190
+ const paymentId = options.recordDue && !full ? recordDropInDue(db, booking, inst) : null;
191
+ return { booking, waitlisted: full, instance: inst, paymentId };
176
192
  })();
177
193
  }
178
194
 
@@ -190,12 +206,16 @@ function promoteNext(db, inst, nowIso) {
190
206
  db.prepare(
191
207
  'UPDATE bookings SET status = ?, paid_with = ?, pass_id = ?, membership_id = ? WHERE id = ?'
192
208
  ).run('booked', resolution.paidWith, resolution.passId ?? null, resolution.membershipId ?? null, next.id);
193
- return db.prepare('SELECT * FROM bookings WHERE id = ?').get(next.id);
209
+ const promoted = db.prepare('SELECT * FROM bookings WHERE id = ?').get(next.id);
210
+ recordDropInDue(db, promoted, inst);
211
+ return promoted;
194
212
  }
195
213
 
196
214
  /**
197
215
  * Cancel a booking. Within the cancellation window → credit refunded; late →
198
- * per policy (default forfeit). Auto-promotes the first waitlisted booking.
216
+ * per policy (default forfeit). Auto-promotes the first waitlisted booking
217
+ * while the class has yet to start. { allowPast } lets staff tidy a roster
218
+ * after the fact.
199
219
  * Returns { booking, refunded, late, promoted } (promoted = booking row or null).
200
220
  */
201
221
  export function cancelBooking(db, bookingId, options = {}) {
@@ -207,6 +227,8 @@ export function cancelBooking(db, bookingId, options = {}) {
207
227
  throw new BookingError('not_cancellable', `Cannot cancel a ${booking.status} booking`);
208
228
  }
209
229
  const inst = loadInstance(db, booking.class_instance_id);
230
+ const started = inst.starts_at <= nowIso;
231
+ if (started && !options.allowPast) throw new BookingError('in_past', 'Class has already started');
210
232
  const windowHours = Number(getSetting(db, 'cancellation_window_hours', '12'));
211
233
  const policy = getSetting(db, 'late_cancel_policy', 'forfeit');
212
234
  const hoursToStart = hoursBetween(nowIso, inst.starts_at);
@@ -214,23 +236,53 @@ export function cancelBooking(db, bookingId, options = {}) {
214
236
  const wasBooked = booking.status === 'booked';
215
237
  const creditsNeeded = inst.credits_required ?? 1;
216
238
 
239
+ const excused = !late || policy === 'refund' || options.forceRefund;
217
240
  let refunded = false;
218
- if (wasBooked && (!late || policy === 'refund' || options.forceRefund)) {
241
+ if (wasBooked && excused) {
219
242
  refund(db, booking, creditsNeeded);
220
243
  refunded = booking.paid_with === 'pack' || booking.paid_with === 'membership';
221
244
  }
245
+ // A forfeited late cancel still owes its drop-in, as it would lose a credit.
246
+ if (excused) dropDropInDue(db, bookingId);
222
247
  db.prepare("UPDATE bookings SET status = 'cancelled', cancelled_at = ? WHERE id = ?")
223
248
  .run(nowIso, bookingId);
224
249
 
225
- // A freed confirmed spot promotes the first waitlisted booking.
250
+ // A freed confirmed spot promotes the first waitlisted booking, but not
251
+ // into a class that's already running: they'd be charged for missing it.
226
252
  let promoted = null;
227
- if (wasBooked && bookedCount(db, inst.id) < inst.capacity) {
253
+ if (wasBooked && !started && bookedCount(db, inst.id) < inst.capacity) {
228
254
  promoted = promoteNext(db, inst, nowIso);
229
255
  }
230
256
  return { booking: { ...booking, status: 'cancelled', cancelled_at: nowIso }, refunded, late, promoted, instance: inst };
231
257
  })();
232
258
  }
233
259
 
260
+ /**
261
+ * Staff edit of a single class. Extra capacity before the class starts is
262
+ * filled from the waitlist, first come first served. Lowering it below the
263
+ * booked count cancels nobody. Returns { instance, promoted } (promoted = rows).
264
+ */
265
+ export function updateInstance(db, instanceId, changes, options = {}) {
266
+ const nowIso = options.now || new Date().toISOString();
267
+ return db.transaction(() => {
268
+ const inst = { ...loadInstance(db, instanceId), ...changes };
269
+ if (!Number.isInteger(inst.capacity) || inst.capacity < 1) {
270
+ throw new BookingError('bad_capacity', 'Capacity must be a whole number, at least 1');
271
+ }
272
+ db.prepare('UPDATE class_instances SET capacity = ?, instructor_id = ?, notes = ? WHERE id = ?')
273
+ .run(inst.capacity, inst.instructor_id, inst.notes, instanceId);
274
+ const promoted = [];
275
+ if (inst.status === 'scheduled' && inst.starts_at > nowIso) {
276
+ while (bookedCount(db, instanceId) < inst.capacity) {
277
+ const next = promoteNext(db, inst, nowIso);
278
+ if (!next) break;
279
+ promoted.push(next);
280
+ }
281
+ }
282
+ return { instance: inst, promoted };
283
+ })();
284
+ }
285
+
234
286
  /** Staff roster actions. */
235
287
  export function markAttendance(db, bookingId, status) {
236
288
  if (!['attended', 'no_show', 'booked'].includes(status)) {
@@ -258,6 +310,7 @@ export function cancelClass(db, instanceId, options = {}) {
258
310
  ).all(instanceId);
259
311
  for (const b of affected) {
260
312
  if (b.status === 'booked') refund(db, b, creditsNeeded);
313
+ dropDropInDue(db, b.id);
261
314
  db.prepare("UPDATE bookings SET status = 'cancelled', cancelled_at = ? WHERE id = ?")
262
315
  .run(nowIso, b.id);
263
316
  }
@@ -0,0 +1,19 @@
1
+ // Emails that more than one route sends.
2
+ import { getSetting } from '../db/index.js';
3
+ import { makeMagicToken } from './auth.js';
4
+ import { emails } from './mailer.js';
5
+
6
+ /** Tell a client they've come off the waitlist, with a link to manage the booking. */
7
+ export function notifyPromoted(services, req, res, booking, inst) {
8
+ const { db, mailer } = services;
9
+ const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(booking.client_id);
10
+ if (!client) return;
11
+ const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, client.id)}`;
12
+ mailer.send({
13
+ to: client.email,
14
+ ...emails.waitlistPromotion({
15
+ studio: getSetting(db, 'studio_name', 'the studio'), clientName: client.name,
16
+ className: inst.class_name, when: res.locals.fmtDt(inst.starts_at), magicUrl,
17
+ }),
18
+ });
19
+ }
@@ -5,7 +5,7 @@ import {
5
5
  getPass, getBooking, getMembership,
6
6
  } from './helpers.js';
7
7
  import {
8
- book, cancelBooking, cancelClass, markAttendance, resolvePayment, BookingError,
8
+ book, cancelBooking, cancelClass, markAttendance, resolvePayment, updateInstance, BookingError,
9
9
  } from '../src/services/booking.js';
10
10
 
11
11
  test('booking deducts a pack credit at booking time, in the same transaction', () => {
@@ -262,6 +262,58 @@ test('cancelling a waitlist booking does not promote or refund', () => {
262
262
  assert.equal(res.promoted, null);
263
263
  });
264
264
 
265
+ test('once a class has started, clients cannot cancel and nobody is promoted into it', () => {
266
+ const db = testDb();
267
+ const now = '2026-10-01T10:00:00Z';
268
+ const inst = makeInstance(db, makeClassType(db, { capacity: 1 }), { now, hoursFromNow: 2 });
269
+ const c1 = makeClient(db); const c2 = makeClient(db);
270
+ const waiting = makePass(db, c2, { total: 5 });
271
+ const { booking } = book(db, c1, inst, { now });
272
+ const { booking: wl } = book(db, c2, inst, { now });
273
+ const afterClass = '2026-10-01T13:30:00Z';
274
+
275
+ assert.throws(() => cancelBooking(db, booking.id, { now: afterClass }),
276
+ (e) => e instanceof BookingError && e.code === 'in_past');
277
+
278
+ // Staff tidying the roster afterwards still can, without dragging the
279
+ // waitlist into a class that's over.
280
+ const res = cancelBooking(db, booking.id, { now: afterClass, allowPast: true });
281
+ assert.equal(res.promoted, null);
282
+ assert.equal(getBooking(db, wl.id).status, 'waitlist');
283
+ assert.equal(getPass(db, waiting).credits_remaining, 5);
284
+ });
285
+
286
+ test('a pay-at-studio drop-in is owed only while the client holds a spot', () => {
287
+ const db = testDb();
288
+ const inst = makeInstance(db, makeClassType(db, { capacity: 1, price: 15000 }), { hoursFromNow: 48 });
289
+ const c1 = makeClient(db); const c2 = makeClient(db);
290
+ const dues = () => db.prepare(
291
+ "SELECT client_id, amount_cents FROM payments WHERE status = 'pending' ORDER BY id"
292
+ ).all();
293
+
294
+ const first = book(db, c1, inst, { recordDue: true });
295
+ assert.ok(first.paymentId);
296
+ const waiting = book(db, c2, inst, { recordDue: true });
297
+ assert.equal(waiting.paymentId, null, 'the waitlist owes nothing yet');
298
+ assert.deepEqual(dues(), [{ client_id: c1, amount_cents: 15000 }]);
299
+
300
+ // c1 cancels in time: their due goes, and c2 owes from the moment they're promoted.
301
+ cancelBooking(db, first.booking.id);
302
+ assert.deepEqual(dues(), [{ client_id: c2, amount_cents: 15000 }]);
303
+
304
+ // The studio cancelling the class clears it too.
305
+ cancelClass(db, inst);
306
+ assert.deepEqual(dues(), []);
307
+ });
308
+
309
+ test('a late cancel under the forfeit policy still owes the drop-in', () => {
310
+ const db = testDb();
311
+ const inst = makeInstance(db, makeClassType(db, { price: 15000 }), { hoursFromNow: 2 });
312
+ const { booking } = book(db, makeClient(db), inst, { recordDue: true });
313
+ cancelBooking(db, booking.id);
314
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM payments WHERE status = 'pending'").get().c, 1);
315
+ });
316
+
265
317
  test('promotion re-resolves payment if the waitlisted client acquired a membership meanwhile', () => {
266
318
  const db = testDb();
267
319
  const type = makeClassType(db, { capacity: 1 });
@@ -344,3 +396,30 @@ test('capacity race: concurrent-style bookings never exceed capacity (transactio
344
396
  assert.equal(booked, 5);
345
397
  assert.equal(wait, 15);
346
398
  });
399
+
400
+ test('raising a class capacity fills the new spots from the waitlist, in order', () => {
401
+ const db = testDb();
402
+ const now = '2026-10-01T10:00:00Z';
403
+ const inst = makeInstance(db, makeClassType(db, { capacity: 1 }), { now, hoursFromNow: 24 });
404
+ const [c1, c2, c3, c4] = [1, 2, 3, 4].map(() => makeClient(db));
405
+ const passes = [c2, c3, c4].map((c) => makePass(db, c, { total: 5 }));
406
+ const [, b2, b3, b4] = [c1, c2, c3, c4].map((c) => book(db, c, inst, { now }).booking);
407
+
408
+ const res = updateInstance(db, inst, { capacity: 3 }, { now });
409
+ assert.deepEqual(res.promoted.map((b) => b.id), [b2.id, b3.id]);
410
+ assert.equal(getPass(db, passes[0]).credits_remaining, 4);
411
+ assert.equal(getPass(db, passes[1]).credits_remaining, 4);
412
+ assert.equal(getBooking(db, b4.id).status, 'waitlist');
413
+ assert.equal(getPass(db, passes[2]).credits_remaining, 5);
414
+
415
+ // Shrinking the room cancels nobody.
416
+ assert.deepEqual(updateInstance(db, inst, { capacity: 1 }, { now }).promoted, []);
417
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM bookings WHERE status = 'booked'").get().c, 3);
418
+
419
+ // Nor does anyone get pulled into a class that has already started.
420
+ assert.deepEqual(updateInstance(db, inst, { capacity: 10 }, { now: '2026-10-02T11:00:00Z' }).promoted, []);
421
+ assert.equal(getBooking(db, b4.id).status, 'waitlist');
422
+
423
+ assert.throws(() => updateInstance(db, inst, { capacity: 0 }, { now }),
424
+ (e) => e instanceof BookingError && e.code === 'bad_capacity');
425
+ });
package/test/web.test.js CHANGED
@@ -160,7 +160,30 @@ 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("booking with an existing client's email does not rename them", async () => {
177
+ const { db, app } = makeApp();
178
+ const inst = makeInstance(db, makeClassType(db), { hoursFromNow: 48 });
179
+ const clientId = makeClient(db, { name: 'Mei Chan', email: 'mei@test.hk', waiver: true });
180
+
181
+ const agent = request.agent(app);
182
+ const _csrf = await csrfToken(agent, `/class/${inst}`);
183
+ const res = await agent.post(`/class/${inst}/book`).type('form')
184
+ .send({ name: 'Someone Else', email: 'mei@test.hk', _csrf });
185
+ assert.equal(res.status, 200);
186
+ assert.equal(db.prepare('SELECT name FROM clients WHERE id = ?').get(clientId).name, 'Mei Chan');
164
187
  });
165
188
 
166
189
  test('full class books to waitlist via HTTP', async () => {
@@ -185,6 +208,54 @@ test('full class books to waitlist via HTTP', async () => {
185
208
  assert.match(second.text, /You're #2 in line/);
186
209
  });
187
210
 
211
+ test('drop-in dues: nothing owed on the waitlist, and a cancelled booking owes nothing', async () => {
212
+ const { db, app } = makeApp();
213
+ const inst = makeInstance(db, makeClassType(db, { capacity: 1, price: 15000 }), { hoursFromNow: 48 });
214
+ const agent = request.agent(app);
215
+ const _csrf = await csrfToken(agent, `/class/${inst}`);
216
+ for (const email of ['first@t.hk', 'second@t.hk']) {
217
+ await agent.post(`/class/${inst}/book`).type('form').send({ email, waiver_agree: '1', _csrf });
218
+ }
219
+ const owing = () => db.prepare(
220
+ `SELECT c.email FROM payments p JOIN clients c ON c.id = p.client_id
221
+ WHERE p.status = 'pending' ORDER BY p.id`
222
+ ).all().map((r) => r.email);
223
+ assert.deepEqual(owing(), ['first@t.hk']);
224
+
225
+ const { cancelBooking } = await import('../src/services/booking.js');
226
+ const first = db.prepare("SELECT id FROM bookings WHERE status = 'booked'").get();
227
+ cancelBooking(db, first.id);
228
+ assert.deepEqual(owing(), ['second@t.hk']);
229
+ });
230
+
231
+ test('admin: editing a class from the roster moves the waitlist up and emails them', async () => {
232
+ const { db, app } = makeApp();
233
+ const agent = request.agent(app);
234
+ const _csrf = await adminLogin(agent);
235
+ const inst = makeInstance(db, makeClassType(db, { capacity: 1 }), { hoursFromNow: 48 });
236
+ const kim = db.prepare("INSERT INTO instructors (name) VALUES ('Kim')").run().lastInsertRowid;
237
+ const { book } = await import('../src/services/booking.js');
238
+ book(db, makeClient(db, { email: 'first@edit.hk' }), inst);
239
+ const { booking: wl } = book(db, makeClient(db, { email: 'waiting@edit.hk' }), inst);
240
+
241
+ const roster = await agent.get(`/admin/instances/${inst}`);
242
+ assert.match(roster.text, /Edit class/);
243
+ const res = await agent.post(`/admin/instances/${inst}/edit`).type('form')
244
+ .send({ capacity: '2', instructor_id: String(kim), notes: 'Room B', _csrf });
245
+ assert.equal(res.status, 302);
246
+ const row = db.prepare('SELECT capacity, instructor_id, notes FROM class_instances WHERE id = ?').get(inst);
247
+ assert.deepEqual({ ...row }, { capacity: 2, instructor_id: kim, notes: 'Room B' });
248
+ assert.equal(db.prepare('SELECT status FROM bookings WHERE id = ?').get(wl.id).status, 'booked');
249
+ await new Promise((r) => setTimeout(r, 100));
250
+ const mails = fs.readdirSync(OUTBOX).map((f) => fs.readFileSync(path.join(OUTBOX, f), 'utf8'));
251
+ assert.ok(mails.some((m) => m.includes('To: waiting@edit.hk') && m.includes('/me?token=')));
252
+
253
+ const bad = await agent.post(`/admin/instances/${inst}/edit`).type('form')
254
+ .send({ capacity: '2', instructor_id: '999', _csrf });
255
+ assert.equal(bad.status, 302, 'an unknown instructor is a flash, not a 500');
256
+ assert.equal(db.prepare('SELECT instructor_id FROM class_instances WHERE id = ?').get(inst).instructor_id, kim);
257
+ });
258
+
188
259
  test('magic link: verify round-trip, view bookings, cancel within policy', async () => {
189
260
  const { db, app } = makeApp();
190
261
  const type = makeClassType(db, { name: 'Spin' });
@@ -279,6 +350,42 @@ test('admin: rules CRUD generates instances; roster check-in works over HTTP', a
279
350
  assert.equal(db.prepare('SELECT status FROM bookings WHERE id=?').get(booking.id).status, 'attended');
280
351
  });
281
352
 
353
+ test('admin: editing a rule moves its empty classes instead of adding a second slot', async () => {
354
+ const { db, app } = makeApp();
355
+ const agent = request.agent(app);
356
+ const _csrf = await adminLogin(agent);
357
+ const { book } = await import('../src/services/booking.js');
358
+ const { localTimeStr } = await import('../src/lib/time.js');
359
+
360
+ const type = makeClassType(db, { name: 'Pilates' });
361
+ await agent.post('/admin/rules').type('form').send({
362
+ class_type_id: String(type), weekday: '4', start_time: '18:00', _csrf,
363
+ });
364
+ const ruleId = db.prepare('SELECT id FROM schedule_rules').get().id;
365
+ const nowIso = new Date().toISOString();
366
+ const future = () => db.prepare(
367
+ 'SELECT starts_at FROM class_instances WHERE rule_id = ? AND starts_at > ? ORDER BY starts_at'
368
+ ).all(ruleId, nowIso).map((ci) => localTimeStr('Asia/Hong_Kong', ci.starts_at));
369
+ const slots = future().length;
370
+ const last = db.prepare('SELECT id FROM class_instances WHERE rule_id = ? ORDER BY starts_at DESC').get(ruleId).id;
371
+ book(db, makeClient(db), last);
372
+
373
+ const edit = await agent.post(`/admin/rules/${ruleId}`).type('form').send({
374
+ class_type_id: String(type), weekday: '4', start_time: '19:00', _csrf,
375
+ });
376
+ assert.equal(edit.status, 302);
377
+ const times = future();
378
+ assert.equal(times.filter((t) => t === '18:00').length, 1, 'only the booked class stays at the old time');
379
+ assert.equal(times.filter((t) => t === '19:00').length, slots);
380
+ assert.match((await agent.get('/admin/rules')).text, /1 booked class\(es\) are still at the old time/);
381
+
382
+ const bad = await agent.post(`/admin/rules/${ruleId}`).type('form').send({
383
+ class_type_id: String(type), weekday: '4', start_time: 'seven', _csrf,
384
+ });
385
+ assert.equal(bad.status, 302);
386
+ assert.equal(db.prepare('SELECT start_time FROM schedule_rules WHERE id = ?').get(ruleId).start_time, '19:00');
387
+ });
388
+
282
389
  test('admin: manual pass sale + payment, revenue report and CSV export', async () => {
283
390
  const { db, app } = makeApp();
284
391
  const agent = request.agent(app);
@@ -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 %>">
@@ -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') %>