studio-os 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/README.md +8 -1
- package/package.json +5 -5
- package/src/app.js +8 -1
- package/src/db/003-stripe-invoices.sql +6 -0
- package/src/db/004-payment-booking.sql +5 -0
- package/src/db/index.js +84 -82
- package/src/routes/admin.js +728 -692
- package/src/routes/me.js +4 -10
- package/src/routes/public.js +21 -25
- package/src/services/booking.js +61 -8
- package/src/services/notify.js +19 -0
- package/src/services/stripe.js +38 -1
- package/test/booking-link.test.js +47 -0
- package/test/booking.test.js +80 -1
- package/test/error-paths.test.js +49 -0
- package/test/stripe.test.js +84 -1
- package/test/web.test.js +113 -1
- package/views/admin/roster.ejs +19 -0
- package/views/public/book_result.ejs +6 -6
- package/views/public/buy_thanks.ejs +1 -1
package/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 () => {
|
|
@@ -178,6 +201,59 @@ test('full class books to waitlist via HTTP', async () => {
|
|
|
178
201
|
.send({ email: 'b@t.hk', waiver_agree: '1', name: 'B', _csrf });
|
|
179
202
|
assert.equal(res.status, 200);
|
|
180
203
|
assert.match(res.text, /waitlist/i);
|
|
204
|
+
assert.match(res.text, /You're #1 in line/);
|
|
205
|
+
|
|
206
|
+
const second = await agent.post(`/class/${inst}/book`).type('form')
|
|
207
|
+
.send({ email: 'c@t.hk', waiver_agree: '1', name: 'C', _csrf });
|
|
208
|
+
assert.match(second.text, /You're #2 in line/);
|
|
209
|
+
});
|
|
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);
|
|
181
257
|
});
|
|
182
258
|
|
|
183
259
|
test('magic link: verify round-trip, view bookings, cancel within policy', async () => {
|
|
@@ -274,6 +350,42 @@ test('admin: rules CRUD generates instances; roster check-in works over HTTP', a
|
|
|
274
350
|
assert.equal(db.prepare('SELECT status FROM bookings WHERE id=?').get(booking.id).status, 'attended');
|
|
275
351
|
});
|
|
276
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
|
+
|
|
277
389
|
test('admin: manual pass sale + payment, revenue report and CSV export', async () => {
|
|
278
390
|
const { db, app } = makeApp();
|
|
279
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 %>">
|
|
@@ -3,24 +3,24 @@
|
|
|
3
3
|
<% if (result.waitlisted) { %>
|
|
4
4
|
<h2>You're on the waitlist</h2>
|
|
5
5
|
<p><strong><%= inst.class_name %></strong> on <%= fmtDt(inst.starts_at) %> is currently full.
|
|
6
|
-
You're #<%=
|
|
6
|
+
You're #<%= waitlistPosition %> in line — if a spot opens we'll book you in automatically<%= smtpConfigured ? ' and email you' : '' %>.</p>
|
|
7
7
|
<% } else { %>
|
|
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.
|
|
15
15
|
<% } %>
|
|
16
16
|
</p>
|
|
17
17
|
<% } %>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
<% if (magicUrl) { %>
|
|
19
|
+
<p>Manage or cancel your bookings any time:</p>
|
|
20
|
+
<p><a href="<%= magicUrl %>" role="button" class="outline">My bookings</a></p>
|
|
21
21
|
<p><small>Save that link — it's your personal booking manager (valid 7 days; you can request a new one from “My bookings” in the menu).</small></p>
|
|
22
22
|
<% } else { %>
|
|
23
|
-
<p
|
|
23
|
+
<p>A confirmation email with a link to manage or cancel your booking is on its way.</p>
|
|
24
24
|
<% } %>
|
|
25
25
|
</article>
|
|
26
26
|
<%- include('../partials/foot') %>
|
|
@@ -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') %>
|