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/CHANGELOG.md +34 -0
- package/package.json +5 -5
- package/src/db/004-payment-booking.sql +5 -0
- package/src/db/index.js +1 -0
- package/src/routes/admin.js +728 -692
- package/src/routes/me.js +3 -9
- package/src/routes/public.js +10 -24
- package/src/services/booking.js +61 -8
- package/src/services/notify.js +19 -0
- package/test/booking.test.js +80 -1
- package/test/web.test.js +108 -1
- package/views/admin/roster.ejs +19 -0
- package/views/public/book_result.ejs +1 -1
- package/views/public/buy_thanks.ejs +1 -1
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
|
|
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.');
|
package/src/routes/public.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
92
|
+
return { client, ...booked };
|
|
107
93
|
})();
|
|
108
94
|
|
|
109
95
|
const magicUrl = `${services.baseUrl(req)}/me?token=${makeMagicToken(db, result.client.id)}`;
|
package/src/services/booking.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
+
}
|
package/test/booking.test.js
CHANGED
|
@@ -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);
|
package/views/admin/roster.ejs
CHANGED
|
@@ -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') {
|
|
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
|
|
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') %>
|