studio-os 0.2.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.
Files changed (86) hide show
  1. package/.dockerignore +7 -0
  2. package/.env.example +34 -0
  3. package/CHANGELOG.md +86 -0
  4. package/Dockerfile +25 -0
  5. package/LICENSE +21 -0
  6. package/PROGRESS.md +60 -0
  7. package/README.md +253 -0
  8. package/SPEC.md +130 -0
  9. package/docker-compose.yml +26 -0
  10. package/docs/screenshots/admin-client.png +0 -0
  11. package/docs/screenshots/admin-dashboard.png +0 -0
  12. package/docs/screenshots/admin-reports.png +0 -0
  13. package/docs/screenshots/admin-roster.png +0 -0
  14. package/docs/screenshots/admin-schedule.png +0 -0
  15. package/docs/screenshots/public-booking.png +0 -0
  16. package/docs/screenshots/public-buy.png +0 -0
  17. package/docs/screenshots/public-schedule.png +0 -0
  18. package/package.json +54 -0
  19. package/public/css/custom.css +40 -0
  20. package/public/js/icon.svg +4 -0
  21. package/public/js/sw.js +23 -0
  22. package/public/manifest.json +17 -0
  23. package/public/vendor/htmx.min.js +1 -0
  24. package/public/vendor/pico.min.css +4 -0
  25. package/scripts/import-mindbody.mjs +109 -0
  26. package/scripts/seed.mjs +147 -0
  27. package/src/app.js +141 -0
  28. package/src/db/002-instructor-role.sql +26 -0
  29. package/src/db/index.js +82 -0
  30. package/src/db/schema.sql +164 -0
  31. package/src/lib/ratelimit.js +50 -0
  32. package/src/lib/time.js +87 -0
  33. package/src/routes/admin.js +692 -0
  34. package/src/routes/auth.js +34 -0
  35. package/src/routes/instructor.js +82 -0
  36. package/src/routes/me.js +101 -0
  37. package/src/routes/public.js +215 -0
  38. package/src/routes/setup.js +55 -0
  39. package/src/routes/webhooks.js +28 -0
  40. package/src/server.js +29 -0
  41. package/src/services/auth.js +81 -0
  42. package/src/services/booking.js +261 -0
  43. package/src/services/importer.js +201 -0
  44. package/src/services/mailer.js +92 -0
  45. package/src/services/schedule.js +71 -0
  46. package/src/services/stripe.js +169 -0
  47. package/test/booking.test.js +346 -0
  48. package/test/fixtures/mindbody-clients.csv +9 -0
  49. package/test/fixtures/mindbody-passes.csv +8 -0
  50. package/test/helpers.js +59 -0
  51. package/test/importer.test.js +173 -0
  52. package/test/instructor.test.js +217 -0
  53. package/test/ratelimit.test.js +123 -0
  54. package/test/schedule.test.js +102 -0
  55. package/test/stripe.test.js +249 -0
  56. package/test/views.test.js +38 -0
  57. package/test/web.test.js +392 -0
  58. package/views/admin/class_types.ejs +45 -0
  59. package/views/admin/client.ejs +149 -0
  60. package/views/admin/clients.ejs +33 -0
  61. package/views/admin/dashboard.ejs +55 -0
  62. package/views/admin/import.ejs +39 -0
  63. package/views/admin/instructor_classes.ejs +21 -0
  64. package/views/admin/instructor_new.ejs +15 -0
  65. package/views/admin/instructors.ejs +49 -0
  66. package/views/admin/products.ejs +83 -0
  67. package/views/admin/reports.ejs +34 -0
  68. package/views/admin/roster.ejs +58 -0
  69. package/views/admin/rules.ejs +71 -0
  70. package/views/admin/schedule.ejs +50 -0
  71. package/views/admin/settings.ejs +53 -0
  72. package/views/error.ejs +7 -0
  73. package/views/instructor/roster.ejs +27 -0
  74. package/views/instructor/schedule.ejs +21 -0
  75. package/views/login.ejs +11 -0
  76. package/views/partials/foot.ejs +11 -0
  77. package/views/partials/head.ejs +54 -0
  78. package/views/public/book_result.ejs +26 -0
  79. package/views/public/buy.ejs +37 -0
  80. package/views/public/buy_manual.ejs +17 -0
  81. package/views/public/buy_thanks.ejs +7 -0
  82. package/views/public/class.ejs +29 -0
  83. package/views/public/magic_request.ejs +21 -0
  84. package/views/public/me.ejs +47 -0
  85. package/views/public/schedule.ejs +49 -0
  86. package/views/setup.ejs +53 -0
@@ -0,0 +1,261 @@
1
+ // Core booking engine. Pure DB logic — no email/HTTP side effects. Callers get
2
+ // back event objects (e.g. promoted bookings) and decide whether to notify.
3
+ // All mutating operations run inside a single better-sqlite3 transaction, so
4
+ // capacity checks and credit movements are atomic.
5
+
6
+ import { getSetting } from '../db/index.js';
7
+ import { localDateStr, hoursBetween, addDays } from '../lib/time.js';
8
+
9
+ export class BookingError extends Error {
10
+ constructor(code, message) {
11
+ super(message);
12
+ this.code = code;
13
+ }
14
+ }
15
+
16
+ const OCCUPYING = "('booked','attended','no_show')"; // statuses that hold a spot
17
+
18
+ function bookedCount(db, instanceId) {
19
+ return db.prepare(
20
+ `SELECT COUNT(*) c FROM bookings WHERE class_instance_id = ? AND status IN ${OCCUPYING}`
21
+ ).get(instanceId).c;
22
+ }
23
+
24
+ /** Lazy monthly-credit cycle reset for a credit-based membership. */
25
+ function refreshMembershipCycle(db, m, todayLocal) {
26
+ if (m.unlimited || !m.credits_per_month) return m;
27
+ let cycleStart = m.cycle_started_on || m.started_on;
28
+ let changed = false;
29
+ // advance cycle start by whole months until it is the current cycle
30
+ while (addMonths(cycleStart, 1) <= todayLocal) {
31
+ cycleStart = addMonths(cycleStart, 1);
32
+ changed = true;
33
+ }
34
+ if (changed) {
35
+ db.prepare('UPDATE memberships SET cycle_started_on = ?, credits_used_this_cycle = 0 WHERE id = ?')
36
+ .run(cycleStart, m.id);
37
+ m = { ...m, cycle_started_on: cycleStart, credits_used_this_cycle: 0 };
38
+ } else if (!m.cycle_started_on) {
39
+ db.prepare('UPDATE memberships SET cycle_started_on = ? WHERE id = ?').run(cycleStart, m.id);
40
+ m = { ...m, cycle_started_on: cycleStart };
41
+ }
42
+ return m;
43
+ }
44
+
45
+ function addMonths(dateStr, n) {
46
+ const [y, m, d] = dateStr.split('-').map(Number);
47
+ const dt = new Date(Date.UTC(y, m - 1 + n, 1));
48
+ const lastDay = new Date(Date.UTC(dt.getUTCFullYear(), dt.getUTCMonth() + 1, 0)).getUTCDate();
49
+ dt.setUTCDate(Math.min(d, lastDay));
50
+ return dt.toISOString().slice(0, 10);
51
+ }
52
+
53
+ /**
54
+ * Decide how a client would pay for a class, per spec priority:
55
+ * active membership → soonest-expiring pack with credits → drop-in.
56
+ * Returns { paidWith, passId?, membershipId? } without mutating anything.
57
+ */
58
+ export function resolvePayment(db, clientId, instance, { now } = {}) {
59
+ const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
60
+ const nowIso = now || new Date().toISOString();
61
+ const today = localDateStr(tz, nowIso);
62
+ const classDay = localDateStr(tz, instance.starts_at);
63
+ const creditsNeeded = instance.credits_required ?? 1;
64
+
65
+ // 1. active membership
66
+ const memberships = db.prepare(
67
+ "SELECT * FROM memberships WHERE client_id = ? AND status = 'active' ORDER BY unlimited DESC, id"
68
+ ).all(clientId);
69
+ for (let m of memberships) {
70
+ if (m.unlimited) return { paidWith: 'membership', membershipId: m.id };
71
+ m = refreshMembershipCycle(db, m, today);
72
+ if ((m.credits_per_month || 0) - m.credits_used_this_cycle >= creditsNeeded) {
73
+ return { paidWith: 'membership', membershipId: m.id };
74
+ }
75
+ }
76
+
77
+ // 2. pack with credits, soonest-expiring first (NULL expiry sorts last),
78
+ // must not be expired before the class date
79
+ const pass = db.prepare(
80
+ `SELECT * FROM passes
81
+ WHERE client_id = ? AND credits_remaining >= ?
82
+ AND (expires_on IS NULL OR expires_on >= ?)
83
+ ORDER BY (expires_on IS NULL), expires_on, id LIMIT 1`
84
+ ).get(clientId, creditsNeeded, classDay > today ? classDay : today);
85
+ if (pass) return { paidWith: 'pack', passId: pass.id };
86
+
87
+ // 3. drop-in (caller decides online vs manual)
88
+ return { paidWith: 'drop_in' };
89
+ }
90
+
91
+ function deduct(db, resolution, creditsNeeded) {
92
+ if (resolution.paidWith === 'pack') {
93
+ const r = db.prepare(
94
+ 'UPDATE passes SET credits_remaining = credits_remaining - ? WHERE id = ? AND credits_remaining >= ?'
95
+ ).run(creditsNeeded, resolution.passId, creditsNeeded);
96
+ if (r.changes !== 1) throw new BookingError('no_credits', 'Pass no longer has enough credits');
97
+ } else if (resolution.paidWith === 'membership') {
98
+ db.prepare(
99
+ 'UPDATE memberships SET credits_used_this_cycle = credits_used_this_cycle + ? WHERE id = ? AND unlimited = 0'
100
+ ).run(creditsNeeded, resolution.membershipId);
101
+ }
102
+ }
103
+
104
+ function refund(db, booking, creditsNeeded) {
105
+ if (booking.paid_with === 'pack' && booking.pass_id) {
106
+ db.prepare('UPDATE passes SET credits_remaining = credits_remaining + ? WHERE id = ?')
107
+ .run(creditsNeeded, booking.pass_id);
108
+ } else if (booking.paid_with === 'membership' && booking.membership_id) {
109
+ db.prepare(
110
+ 'UPDATE memberships SET credits_used_this_cycle = MAX(0, credits_used_this_cycle - ?) WHERE id = ? AND unlimited = 0'
111
+ ).run(creditsNeeded, booking.membership_id);
112
+ }
113
+ }
114
+
115
+ function loadInstance(db, instanceId) {
116
+ const inst = db.prepare(
117
+ `SELECT ci.*, ct.credits_required, ct.name AS class_name, ct.drop_in_price_cents
118
+ FROM class_instances ci JOIN class_types ct ON ct.id = ci.class_type_id
119
+ WHERE ci.id = ?`
120
+ ).get(instanceId);
121
+ if (!inst) throw new BookingError('not_found', 'Class not found');
122
+ return inst;
123
+ }
124
+
125
+ /**
126
+ * Book a client into a class instance.
127
+ * options: { paidWith } to force ('drop_in_online'|'drop_in_manual'|'comp'),
128
+ * { now } ISO override for tests, { allowPast } for admin walk-ins.
129
+ * Returns the booking row plus { waitlisted: bool }.
130
+ */
131
+ export function book(db, clientId, instanceId, options = {}) {
132
+ const nowIso = options.now || new Date().toISOString();
133
+ return db.transaction(() => {
134
+ const inst = loadInstance(db, instanceId);
135
+ if (inst.status !== 'scheduled') throw new BookingError('class_cancelled', 'Class is cancelled');
136
+ if (!options.allowPast && inst.starts_at <= nowIso) {
137
+ throw new BookingError('in_past', 'Class has already started');
138
+ }
139
+ const dup = db.prepare(
140
+ "SELECT id FROM bookings WHERE class_instance_id = ? AND client_id = ? AND status != 'cancelled'"
141
+ ).get(instanceId, clientId);
142
+ if (dup) throw new BookingError('already_booked', 'Client already has a booking for this class');
143
+
144
+ const creditsNeeded = inst.credits_required ?? 1;
145
+ let resolution;
146
+ if (options.paidWith && options.paidWith !== 'auto') {
147
+ resolution = { paidWith: options.paidWith };
148
+ } else {
149
+ resolution = resolvePayment(db, clientId, inst, { now: nowIso });
150
+ if (resolution.paidWith === 'drop_in') {
151
+ resolution.paidWith = options.dropInMode === 'online' ? 'drop_in_online' : 'drop_in_manual';
152
+ }
153
+ }
154
+
155
+ const full = bookedCount(db, instanceId) >= inst.capacity;
156
+ const status = full ? 'waitlist' : 'booked';
157
+ // Credits are deducted at booking time for confirmed spots only; waitlisted
158
+ // bookings deduct when promoted (spec is silent — pragmatic choice).
159
+ if (status === 'booked') deduct(db, resolution, creditsNeeded);
160
+
161
+ const info = db.prepare(
162
+ `INSERT INTO bookings (class_instance_id, client_id, status, paid_with, pass_id, membership_id, created_at)
163
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
164
+ ).run(instanceId, clientId, status, resolution.paidWith,
165
+ status === 'booked' ? resolution.passId ?? null : null,
166
+ status === 'booked' ? resolution.membershipId ?? null : null,
167
+ nowIso);
168
+ const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(info.lastInsertRowid);
169
+ return { booking, waitlisted: full, instance: inst };
170
+ })();
171
+ }
172
+
173
+ /** Promote the first FIFO waitlisted booking of an instance. Internal + reused by cancelBooking. */
174
+ function promoteNext(db, inst, nowIso) {
175
+ const next = db.prepare(
176
+ "SELECT * FROM bookings WHERE class_instance_id = ? AND status = 'waitlist' ORDER BY created_at, id LIMIT 1"
177
+ ).get(inst.id);
178
+ if (!next) return null;
179
+ const creditsNeeded = inst.credits_required ?? 1;
180
+ // Re-resolve payment at promotion time (their pass may have expired meanwhile).
181
+ let resolution = resolvePayment(db, next.client_id, inst, { now: nowIso });
182
+ if (resolution.paidWith === 'drop_in') resolution.paidWith = 'drop_in_manual';
183
+ deduct(db, resolution, creditsNeeded);
184
+ db.prepare(
185
+ 'UPDATE bookings SET status = ?, paid_with = ?, pass_id = ?, membership_id = ? WHERE id = ?'
186
+ ).run('booked', resolution.paidWith, resolution.passId ?? null, resolution.membershipId ?? null, next.id);
187
+ return db.prepare('SELECT * FROM bookings WHERE id = ?').get(next.id);
188
+ }
189
+
190
+ /**
191
+ * Cancel a booking. Within the cancellation window → credit refunded; late →
192
+ * per policy (default forfeit). Auto-promotes the first waitlisted booking.
193
+ * Returns { booking, refunded, late, promoted } (promoted = booking row or null).
194
+ */
195
+ export function cancelBooking(db, bookingId, options = {}) {
196
+ const nowIso = options.now || new Date().toISOString();
197
+ return db.transaction(() => {
198
+ const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(bookingId);
199
+ if (!booking) throw new BookingError('not_found', 'Booking not found');
200
+ if (!['booked', 'waitlist'].includes(booking.status)) {
201
+ throw new BookingError('not_cancellable', `Cannot cancel a ${booking.status} booking`);
202
+ }
203
+ const inst = loadInstance(db, booking.class_instance_id);
204
+ const windowHours = Number(getSetting(db, 'cancellation_window_hours', '12'));
205
+ const policy = getSetting(db, 'late_cancel_policy', 'forfeit');
206
+ const hoursToStart = hoursBetween(nowIso, inst.starts_at);
207
+ const late = hoursToStart < windowHours;
208
+ const wasBooked = booking.status === 'booked';
209
+ const creditsNeeded = inst.credits_required ?? 1;
210
+
211
+ let refunded = false;
212
+ if (wasBooked && (!late || policy === 'refund' || options.forceRefund)) {
213
+ refund(db, booking, creditsNeeded);
214
+ refunded = booking.paid_with === 'pack' || booking.paid_with === 'membership';
215
+ }
216
+ db.prepare("UPDATE bookings SET status = 'cancelled', cancelled_at = ? WHERE id = ?")
217
+ .run(nowIso, bookingId);
218
+
219
+ // A freed confirmed spot promotes the first waitlisted booking.
220
+ let promoted = null;
221
+ if (wasBooked && bookedCount(db, inst.id) < inst.capacity) {
222
+ promoted = promoteNext(db, inst, nowIso);
223
+ }
224
+ return { booking: { ...booking, status: 'cancelled', cancelled_at: nowIso }, refunded, late, promoted, instance: inst };
225
+ })();
226
+ }
227
+
228
+ /** Staff roster actions. */
229
+ export function markAttendance(db, bookingId, status) {
230
+ if (!['attended', 'no_show', 'booked'].includes(status)) {
231
+ throw new BookingError('bad_status', 'Invalid attendance status');
232
+ }
233
+ const r = db.prepare(
234
+ "UPDATE bookings SET status = ? WHERE id = ? AND status IN ('booked','attended','no_show')"
235
+ ).run(status, bookingId);
236
+ if (r.changes !== 1) throw new BookingError('not_found', 'Booking not found or not markable');
237
+ }
238
+
239
+ /**
240
+ * Cancel a whole class: refund credits of every confirmed booking (always,
241
+ * regardless of window — the studio cancelled), cancel all active bookings,
242
+ * mark the instance cancelled. Returns affected bookings for notification.
243
+ */
244
+ export function cancelClass(db, instanceId, options = {}) {
245
+ const nowIso = options.now || new Date().toISOString();
246
+ return db.transaction(() => {
247
+ const inst = loadInstance(db, instanceId);
248
+ if (inst.status === 'cancelled') throw new BookingError('already_cancelled', 'Class already cancelled');
249
+ const creditsNeeded = inst.credits_required ?? 1;
250
+ const affected = db.prepare(
251
+ "SELECT * FROM bookings WHERE class_instance_id = ? AND status IN ('booked','waitlist')"
252
+ ).all(instanceId);
253
+ for (const b of affected) {
254
+ if (b.status === 'booked') refund(db, b, creditsNeeded);
255
+ db.prepare("UPDATE bookings SET status = 'cancelled', cancelled_at = ? WHERE id = ?")
256
+ .run(nowIso, b.id);
257
+ }
258
+ db.prepare("UPDATE class_instances SET status = 'cancelled' WHERE id = ?").run(instanceId);
259
+ return { instance: inst, affected };
260
+ })();
261
+ }
@@ -0,0 +1,201 @@
1
+ // Mindbody CSV importer core. Used by scripts/import-mindbody.mjs and the
2
+ // admin import page. Idempotent by client email: re-running updates, never
3
+ // duplicates. No external CSV dependency — small RFC-4180-ish parser below.
4
+
5
+ /** Parse CSV text into an array of row objects keyed by header. Handles quoted
6
+ * fields, embedded commas/newlines, and doubled quotes. */
7
+ export function parseCsv(text) {
8
+ const rows = [];
9
+ let field = '', row = [], inQuotes = false;
10
+ const src = String(text).replace(/^/, ''); // strip BOM
11
+ for (let i = 0; i < src.length; i++) {
12
+ const ch = src[i];
13
+ if (inQuotes) {
14
+ if (ch === '"') {
15
+ if (src[i + 1] === '"') { field += '"'; i++; } else inQuotes = false;
16
+ } else field += ch;
17
+ } else if (ch === '"') {
18
+ inQuotes = true;
19
+ } else if (ch === ',') {
20
+ row.push(field); field = '';
21
+ } else if (ch === '\n' || ch === '\r') {
22
+ if (ch === '\r' && src[i + 1] === '\n') i++;
23
+ row.push(field); field = '';
24
+ if (row.length > 1 || row[0] !== '') rows.push(row);
25
+ row = [];
26
+ } else field += ch;
27
+ }
28
+ if (field !== '' || row.length) { row.push(field); if (row.length > 1 || row[0] !== '') rows.push(row); }
29
+ if (!rows.length) return [];
30
+ const headers = rows[0].map((h) => h.trim());
31
+ return rows.slice(1).map((r) => {
32
+ const obj = {};
33
+ headers.forEach((h, idx) => { obj[h] = (r[idx] ?? '').trim(); });
34
+ return obj;
35
+ });
36
+ }
37
+
38
+ // Default column mappings for standard Mindbody exports. Override via a JSON
39
+ // mapping file ({ clients: {...}, passes: {...} }).
40
+ export const DEFAULT_MAPPING = {
41
+ clients: {
42
+ firstName: ['First Name', 'FirstName', 'First'],
43
+ lastName: ['Last Name', 'LastName', 'Last'],
44
+ email: ['Email', 'Email Address', 'E-mail'],
45
+ phone: ['Phone', 'Mobile Phone', 'Cell Phone', 'Home Phone'],
46
+ notes: ['Notes'],
47
+ waiverSigned: ['Liability Waiver', 'Waiver Signed', 'Liability Release'],
48
+ },
49
+ passes: {
50
+ email: ['Email', 'Client Email', 'Email Address'],
51
+ clientName: ['Client', 'Client Name', 'Name'],
52
+ passName: ['Pricing Option', 'Service Name', 'Pricing Option Name'],
53
+ remaining: ['Remaining', 'Sessions Remaining', 'Count Remaining', 'Remaining Sessions'],
54
+ total: ['Total', 'Session Count', 'Total Sessions'],
55
+ expiration: ['Expiration', 'Expiration Date', 'Exp Date', 'Expires'],
56
+ pricePaid: ['Price Paid', 'Amount Paid', 'Price'],
57
+ },
58
+ };
59
+
60
+ function pick(row, candidates) {
61
+ for (const c of candidates) {
62
+ if (c in row && row[c] !== '') return row[c];
63
+ }
64
+ // case-insensitive fallback
65
+ const lower = Object.fromEntries(Object.entries(row).map(([k, v]) => [k.toLowerCase(), v]));
66
+ for (const c of candidates) {
67
+ const v = lower[c.toLowerCase()];
68
+ if (v !== undefined && v !== '') return v;
69
+ }
70
+ return '';
71
+ }
72
+
73
+ function normalizeDate(s) {
74
+ if (!s) return null;
75
+ const t = s.trim();
76
+ let m = t.match(/^(\d{4})-(\d{2})-(\d{2})/); // ISO
77
+ if (m) return `${m[1]}-${m[2]}-${m[3]}`;
78
+ m = t.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); // Mindbody M/D/YYYY
79
+ if (m) return `${m[3]}-${m[1].padStart(2, '0')}-${m[2].padStart(2, '0')}`;
80
+ return null;
81
+ }
82
+
83
+ function parseMoneyCents(s) {
84
+ if (!s) return 0;
85
+ const n = Number(String(s).replace(/[^0-9.\-]/g, ''));
86
+ return Number.isFinite(n) ? Math.round(n * 100) : 0;
87
+ }
88
+
89
+ /**
90
+ * Import Mindbody clients CSV. Returns { created, updated, skipped, rows:[...] }.
91
+ * dryRun: report what would happen without writing.
92
+ */
93
+ export function importClients(db, csvText, { mapping = DEFAULT_MAPPING.clients, dryRun = false } = {}) {
94
+ const rows = parseCsv(csvText);
95
+ const result = { created: 0, updated: 0, skipped: 0, rows: [] };
96
+ const work = db.transaction(() => {
97
+ for (const row of rows) {
98
+ const email = pick(row, mapping.email).toLowerCase();
99
+ if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
100
+ result.skipped++;
101
+ result.rows.push({ action: 'skip', reason: 'missing/invalid email', row });
102
+ continue;
103
+ }
104
+ const first = pick(row, mapping.firstName);
105
+ const last = pick(row, mapping.lastName);
106
+ const name = `${first} ${last}`.trim() || email.split('@')[0];
107
+ const phone = pick(row, mapping.phone);
108
+ const notes = pick(row, mapping.notes);
109
+ const waiverRaw = pick(row, mapping.waiverSigned).toLowerCase();
110
+ const waiverSigned = ['yes', 'y', 'true', 'signed', '1'].includes(waiverRaw);
111
+
112
+ const existing = db.prepare('SELECT * FROM clients WHERE email = ?').get(email);
113
+ if (existing) {
114
+ if (!dryRun) {
115
+ db.prepare(
116
+ `UPDATE clients SET name = ?, phone = CASE WHEN ? != '' THEN ? ELSE phone END,
117
+ notes = CASE WHEN ? != '' THEN ? ELSE notes END,
118
+ waiver_signed_at = COALESCE(waiver_signed_at, ?)
119
+ WHERE id = ?`
120
+ ).run(name, phone, phone, notes, notes,
121
+ waiverSigned ? new Date().toISOString() : null, existing.id);
122
+ }
123
+ result.updated++;
124
+ result.rows.push({ action: 'update', email, name });
125
+ } else {
126
+ if (!dryRun) {
127
+ db.prepare(
128
+ "INSERT INTO clients (name, email, phone, notes, waiver_signed_at, source) VALUES (?, ?, ?, ?, ?, 'import')"
129
+ ).run(name, email, phone, notes, waiverSigned ? new Date().toISOString() : null);
130
+ }
131
+ result.created++;
132
+ result.rows.push({ action: 'create', email, name });
133
+ }
134
+ }
135
+ });
136
+ work();
137
+ return result;
138
+ }
139
+
140
+ /**
141
+ * Import Mindbody pricing-option/pass CSV. Clients must exist (or are created
142
+ * as bare records from the email). Idempotent: a pass identified by
143
+ * (client, name, expiration) is updated in place, never duplicated.
144
+ */
145
+ export function importPasses(db, csvText, { mapping = DEFAULT_MAPPING.passes, dryRun = false } = {}) {
146
+ const rows = parseCsv(csvText);
147
+ const result = { created: 0, updated: 0, skipped: 0, clientsCreated: 0, rows: [] };
148
+ const work = db.transaction(() => {
149
+ for (const row of rows) {
150
+ const email = pick(row, mapping.email).toLowerCase();
151
+ if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
152
+ result.skipped++;
153
+ result.rows.push({ action: 'skip', reason: 'missing/invalid email', row });
154
+ continue;
155
+ }
156
+ const passName = pick(row, mapping.passName) || 'Imported pass';
157
+ const remaining = Math.max(0, parseInt(pick(row, mapping.remaining), 10) || 0);
158
+ const total = Math.max(remaining, parseInt(pick(row, mapping.total), 10) || remaining);
159
+ const expiresOn = normalizeDate(pick(row, mapping.expiration));
160
+ const priceCents = parseMoneyCents(pick(row, mapping.pricePaid));
161
+
162
+ let client = db.prepare('SELECT * FROM clients WHERE email = ?').get(email);
163
+ if (!client) {
164
+ const name = pick(row, mapping.clientName) || email.split('@')[0];
165
+ if (!dryRun) {
166
+ const info = db.prepare(
167
+ "INSERT INTO clients (name, email, source) VALUES (?, ?, 'import')"
168
+ ).run(name, email);
169
+ client = { id: info.lastInsertRowid };
170
+ } else {
171
+ client = { id: null };
172
+ }
173
+ result.clientsCreated++;
174
+ }
175
+
176
+ const existing = client.id === null ? null : db.prepare(
177
+ `SELECT * FROM passes WHERE client_id = ? AND name = ? AND source = 'import'
178
+ AND (expires_on IS ? OR expires_on = ?)`
179
+ ).get(client.id, passName, expiresOn, expiresOn);
180
+ if (existing) {
181
+ if (!dryRun) {
182
+ db.prepare('UPDATE passes SET credits_remaining = ?, credits_total = ? WHERE id = ?')
183
+ .run(remaining, total, existing.id);
184
+ }
185
+ result.updated++;
186
+ result.rows.push({ action: 'update', email, passName, remaining });
187
+ } else {
188
+ if (!dryRun) {
189
+ db.prepare(
190
+ `INSERT INTO passes (client_id, name, credits_total, credits_remaining, expires_on, price_paid_cents, source)
191
+ VALUES (?, ?, ?, ?, ?, ?, 'import')`
192
+ ).run(client.id, passName, total, remaining, expiresOn, priceCents);
193
+ }
194
+ result.created++;
195
+ result.rows.push({ action: 'create', email, passName, remaining });
196
+ }
197
+ }
198
+ });
199
+ work();
200
+ return result;
201
+ }
@@ -0,0 +1,92 @@
1
+ // Email delivery. SMTP via nodemailer when configured; otherwise every message
2
+ // is written to data/outbox/*.eml so flows stay fully testable offline.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ export function createMailer({ env = process.env, outboxDir } = {}) {
7
+ const smtpConfigured = Boolean(env.SMTP_HOST);
8
+ const outbox = outboxDir || path.join(process.cwd(), 'data', 'outbox');
9
+ let transportPromise = null;
10
+
11
+ async function getTransport() {
12
+ if (!transportPromise) {
13
+ const nodemailer = (await import('nodemailer')).default;
14
+ transportPromise = Promise.resolve(nodemailer.createTransport({
15
+ host: env.SMTP_HOST,
16
+ port: Number(env.SMTP_PORT || 587),
17
+ secure: Number(env.SMTP_PORT) === 465,
18
+ auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
19
+ }));
20
+ }
21
+ return transportPromise;
22
+ }
23
+
24
+ return {
25
+ smtpConfigured,
26
+ /** Fire-and-forget safe: never throws, logs failures. */
27
+ async send({ to, subject, text }) {
28
+ const from = env.SMTP_FROM || 'studio-os@localhost';
29
+ try {
30
+ if (smtpConfigured) {
31
+ const t = await getTransport();
32
+ await t.sendMail({ from, to, subject, text });
33
+ } else {
34
+ fs.mkdirSync(outbox, { recursive: true });
35
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
36
+ const safeTo = String(to).replace(/[^a-z0-9@._-]/gi, '_');
37
+ const eml = [
38
+ `From: ${from}`, `To: ${to}`, `Subject: ${subject}`,
39
+ `Date: ${new Date().toUTCString()}`, 'MIME-Version: 1.0',
40
+ 'Content-Type: text/plain; charset=utf-8', '', text,
41
+ ].join('\r\n');
42
+ fs.writeFileSync(path.join(outbox, `${stamp}_${safeTo}.eml`), eml);
43
+ }
44
+ return true;
45
+ } catch (err) {
46
+ console.error('[mailer] send failed:', err.message);
47
+ return false;
48
+ }
49
+ },
50
+ };
51
+ }
52
+
53
+ // --- templates (plain text, keep it simple and readable) -------------------
54
+
55
+ export const emails = {
56
+ bookingConfirmation({ studio, clientName, className, when, paidWith, magicUrl }) {
57
+ return {
58
+ subject: `Booked: ${className} — ${when}`,
59
+ text: `Hi ${clientName},\n\nYou're booked for ${className} on ${when} at ${studio}.\nPayment: ${paidWith}.\n\nManage your bookings: ${magicUrl}\n\nSee you there!\n${studio}`,
60
+ };
61
+ },
62
+ waitlisted({ studio, clientName, className, when, magicUrl }) {
63
+ return {
64
+ subject: `Waitlisted: ${className} — ${when}`,
65
+ text: `Hi ${clientName},\n\nThe ${className} class on ${when} is full — you're on the waitlist. We'll email you if a spot opens.\n\nManage your bookings: ${magicUrl}\n\n${studio}`,
66
+ };
67
+ },
68
+ cancellation({ studio, clientName, className, when, refunded }) {
69
+ return {
70
+ subject: `Cancelled: ${className} — ${when}`,
71
+ text: `Hi ${clientName},\n\nYour booking for ${className} on ${when} was cancelled.${refunded ? ' Your credit has been returned.' : ''}\n\n${studio}`,
72
+ };
73
+ },
74
+ waitlistPromotion({ studio, clientName, className, when, magicUrl }) {
75
+ return {
76
+ subject: `You're in! ${className} — ${when}`,
77
+ text: `Hi ${clientName},\n\nA spot opened up: you're now booked for ${className} on ${when}.\n\nCan't make it? Cancel here: ${magicUrl}\n\n${studio}`,
78
+ };
79
+ },
80
+ classCancelled({ studio, clientName, className, when }) {
81
+ return {
82
+ subject: `Class cancelled: ${className} — ${when}`,
83
+ text: `Hi ${clientName},\n\nSorry — ${className} on ${when} has been cancelled by the studio. Any credit used has been returned.\n\n${studio}`,
84
+ };
85
+ },
86
+ magicLink({ studio, clientName, magicUrl }) {
87
+ return {
88
+ subject: `${studio}: your booking management link`,
89
+ text: `Hi ${clientName},\n\nUse this link to view and manage your bookings:\n${magicUrl}\n\nThe link is valid for 7 days.\n\n${studio}`,
90
+ };
91
+ },
92
+ };
@@ -0,0 +1,71 @@
1
+ // Rolling instance generator: materializes schedule_rules into class_instances
2
+ // over a horizon (default 8 weeks). Idempotent — keyed on (rule_id, starts_at).
3
+
4
+ import { getSetting } from '../db/index.js';
5
+ import { zonedToUtc, localDateStr, addDays, weekdayOf } from '../lib/time.js';
6
+
7
+ /**
8
+ * Generate class_instances for all active rules from today through
9
+ * today + horizonWeeks*7 (studio-local days). Never touches existing rows.
10
+ * Returns number of instances created.
11
+ */
12
+ export function generateInstances(db, { now } = {}) {
13
+ const tz = getSetting(db, 'timezone', 'Asia/Hong_Kong');
14
+ const horizonWeeks = Number(getSetting(db, 'horizon_weeks', '8'));
15
+ const today = localDateStr(tz, now || new Date());
16
+ const rules = db.prepare(
17
+ `SELECT sr.*, ct.capacity AS type_capacity, ct.duration_min
18
+ FROM schedule_rules sr JOIN class_types ct ON ct.id = sr.class_type_id
19
+ WHERE sr.active = 1 AND ct.active = 1`
20
+ ).all();
21
+ if (!rules.length) return 0;
22
+
23
+ const insert = db.prepare(
24
+ `INSERT OR IGNORE INTO class_instances
25
+ (class_type_id, instructor_id, rule_id, starts_at, duration_min, capacity, status)
26
+ VALUES (?, ?, ?, ?, ?, ?, 'scheduled')`
27
+ );
28
+ let created = 0;
29
+ db.transaction(() => {
30
+ for (const rule of rules) {
31
+ const [hh, mm] = rule.start_time.split(':').map(Number);
32
+ for (let i = 0; i < horizonWeeks * 7; i++) {
33
+ const day = addDays(today, i);
34
+ if (weekdayOf(day) !== rule.weekday) continue;
35
+ if (rule.active_from && day < rule.active_from) continue;
36
+ if (rule.active_until && day > rule.active_until) continue;
37
+ const [y, m, d] = day.split('-').map(Number);
38
+ const startsAt = zonedToUtc(tz, y, m, d, hh, mm).toISOString().replace(/\.\d{3}Z$/, 'Z');
39
+ const r = insert.run(
40
+ rule.class_type_id, rule.instructor_id, rule.id, startsAt,
41
+ rule.duration_min, rule.capacity_override ?? rule.type_capacity
42
+ );
43
+ created += r.changes;
44
+ }
45
+ }
46
+ })();
47
+ return created;
48
+ }
49
+
50
+ /** Upcoming scheduled instances joined with type + instructor, for listings. */
51
+ export function upcomingInstances(db, { days = 14, from, classTypeId, instructorId, includeCancelled = false } = {}) {
52
+ const fromIso = from || new Date().toISOString();
53
+ const toIso = new Date(new Date(fromIso).getTime() + days * 86400000).toISOString();
54
+ let sql = `
55
+ SELECT ci.*, ct.name AS class_name, ct.color, ct.credits_required, ct.drop_in_price_cents,
56
+ ct.description AS class_description, i.name AS instructor_name,
57
+ (SELECT COUNT(*) FROM bookings b WHERE b.class_instance_id = ci.id
58
+ AND b.status IN ('booked','attended','no_show')) AS booked_count,
59
+ (SELECT COUNT(*) FROM bookings b WHERE b.class_instance_id = ci.id
60
+ AND b.status = 'waitlist') AS waitlist_count
61
+ FROM class_instances ci
62
+ JOIN class_types ct ON ct.id = ci.class_type_id
63
+ LEFT JOIN instructors i ON i.id = ci.instructor_id
64
+ WHERE ci.starts_at >= ? AND ci.starts_at < ?`;
65
+ const params = [fromIso, toIso];
66
+ if (!includeCancelled) sql += " AND ci.status = 'scheduled'";
67
+ if (classTypeId) { sql += ' AND ci.class_type_id = ?'; params.push(classTypeId); }
68
+ if (instructorId) { sql += ' AND ci.instructor_id = ?'; params.push(instructorId); }
69
+ sql += ' ORDER BY ci.starts_at';
70
+ return db.prepare(sql).all(...params);
71
+ }