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/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);
@@ -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 #<%= result.booking.id ? '' : '' %> in line — if a spot opens we'll book you in automatically<%= smtpConfigured ? ' and email you' : '' %>.</p>
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') { %>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.
15
15
  <% } %>
16
16
  </p>
17
17
  <% } %>
18
- <p>Manage or cancel your bookings any time:</p>
19
- <p><a href="<%= magicUrl %>" role="button" class="outline">My bookings</a></p>
20
- <% if (!smtpConfigured) { %>
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><small>A confirmation email with your personal link is on its way.</small></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 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') %>