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
package/.dockerignore ADDED
@@ -0,0 +1,7 @@
1
+ node_modules
2
+ data
3
+ .git
4
+ .env
5
+ test
6
+ *.log
7
+ PROGRESS.md
package/.env.example ADDED
@@ -0,0 +1,34 @@
1
+ # Studio OS configuration. Copy to .env and edit. Everything is optional;
2
+ # the app runs fully offline with no env at all.
3
+
4
+ # Port for the HTTP server (default 3000)
5
+ #PORT=3000
6
+
7
+ # Path to the SQLite database (default data/studio.db)
8
+ #DB_PATH=data/studio.db
9
+
10
+ # Secret used to sign session cookies and magic-link tokens.
11
+ # Auto-generated and persisted in the settings table on first boot if unset.
12
+ #APP_SECRET=change-me
13
+
14
+ # Public base URL used in emails/magic links (default http://localhost:PORT)
15
+ #BASE_URL=https://booking.example.com
16
+
17
+ # Set when running behind a reverse proxy (Caddy/nginx/Traefik) so rate
18
+ # limiting keys on the first X-Forwarded-For hop. Leave unset otherwise —
19
+ # the header is client-spoofable without a trusted proxy in front.
20
+ #TRUST_PROXY=1
21
+
22
+ # --- SMTP (optional). When unset, emails are written to data/outbox/*.eml
23
+ # and magic links are shown on screen.
24
+ #SMTP_HOST=smtp.example.com
25
+ #SMTP_PORT=587
26
+ #SMTP_USER=user
27
+ #SMTP_PASS=pass
28
+ #SMTP_FROM="My Studio <noreply@example.com>"
29
+
30
+ # --- Stripe (optional, bring-your-own account). When unset, purchase flows
31
+ # fall back to "pay at studio" and manual activation.
32
+ #STRIPE_SECRET_KEY=sk_test_...
33
+ #STRIPE_WEBHOOK_SECRET=whsec_...
34
+ #STRIPE_PUBLISHABLE_KEY=pk_test_...
package/CHANGELOG.md ADDED
@@ -0,0 +1,86 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-07-28
4
+
5
+ - **Instructor logins.** New `instructor` user role alongside the existing
6
+ owner/staff (admin) roles — existing accounts are untouched by the
7
+ migration (v2 rebuilds the `users` CHECK; SQLite cannot alter one in
8
+ place). Instructors sign in on the same staff login form (`/login` now
9
+ aliases `/admin/login`) and land on their own portal:
10
+ - `GET /instructor/schedule` — the classes they are assigned to, from
11
+ today onward, sorted by date
12
+ - `GET /instructor/classes/:id/roster` — attendee list with status
13
+ (booked / checked-in / no-show / cancelled), class time and capacity;
14
+ client emails are *not* exposed to instructors
15
+ - `POST /instructor/classes/:id/checkin/:booking_id` — check an attendee
16
+ in (same `markAttendance` business logic as the admin roster)
17
+ - Route guards: every `/admin/*` route returns **403** for instructor
18
+ accounts; rosters and check-in return **403** for classes the
19
+ instructor is not assigned to.
20
+ - **Admin: instructor account management.** The Instructors page gains an
21
+ "Instructor logins" section; `/admin/instructors/new` creates an account
22
+ (name, email, password ≥ 8 chars, role `instructor`);
23
+ `/admin/instructors/:id/classes` assigns/unassigns upcoming classes via
24
+ checkboxes (many-to-many `instructor_class_assignments` table, cascade
25
+ on account/instance deletion; past assignments are left untouched).
26
+ - 12 new tests (guards, scoping, check-in, admin CRUD, migration; the four
27
+ new views covered by the integrity suite) — 110 total, no network.
28
+ Upgrade path verified against a real v1 database file.
29
+
30
+ ## 0.1.0-hardening — 2026-07-28
31
+
32
+ - **Fix: CSRF inputs injected inside attribute values.** The hardening pass had
33
+ inserted the hidden `_csrf` input at the wrong offset in 22 forms whose
34
+ `action` contained an EJS expression, corrupting the submit URL and leaking
35
+ markup as visible text. All POST forms now carry the token immediately after
36
+ the form open tag, enforced by a new static views-integrity test suite
37
+ (`test/views.test.js`), and verified with a real in-browser form submission.
38
+ - **README screenshots**: 8 captured views of the seeded demo studio (public
39
+ schedule/booking/buy, admin dashboard/schedule/roster/client/reports).
40
+ - **Docker verified end-to-end**: `docker compose build && up -d` → setup
41
+ wizard, admin class creation, public schedule and a guest booking all
42
+ exercised against the running container, then `down -v`. Fixed
43
+ `docker-compose.yml`: an `environment:` key with only commented entries is
44
+ invalid YAML for compose ("must be a mapping") — the example block is now
45
+ fully commented out.
46
+ - **Rate limiting**: dependency-free in-memory fixed-window limiter
47
+ (`src/lib/ratelimit.js`), keyed per client IP + route: magic-link requests
48
+ 5/15 min, admin login 10/15 min, public booking/buy POSTs 30/15 min;
49
+ friendly 429 with `Retry-After`. `X-Forwarded-For` is honored only when
50
+ `TRUST_PROXY` is set. Single-process counters (reset on restart) — see the
51
+ new README Security section.
52
+ - **CSRF protection**: session-bound random token (stored in the signed
53
+ cookie-session, no deprecated `csurf` dependency), hidden `_csrf` input on
54
+ every state-changing form (public booking/buy/magic-link, client cancel,
55
+ setup wizard, admin login and all admin forms); POST/PUT/DELETE/PATCH
56
+ without a valid token → 403. `/webhooks/stripe` exempt (Stripe-signature
57
+ verified raw body instead). `x-csrf-token` header accepted as an
58
+ alternative to the form field.
59
+
60
+ ## 0.1.0 — 2026-07-27
61
+
62
+ First release. Single-studio, self-hosted, bring-your-own Stripe.
63
+
64
+ - Public schedule (14-day view, filters), class pages, guest booking with
65
+ waiver capture, returning-client booking by email
66
+ - Booking engine: membership → soonest-expiring pack → drop-in payment
67
+ resolution; atomic capacity; FIFO waitlist with auto-promotion;
68
+ cancellation-window refunds; late-cancel forfeit/refund policy; attendance
69
+ - Client self-service via HMAC magic links (no client passwords): view
70
+ upcoming bookings, cancel within policy
71
+ - Rolling 8-week schedule materialization from weekly rules (boot + daily +
72
+ on change), one-off classes, class cancellation with notify + refund
73
+ - Admin: dashboard (today's rosters, week revenue, expiring passes), class
74
+ types / instructors / weekly rules CRUD, roster check-in/no-show/walk-in,
75
+ client profiles (manual passes, memberships, payments, waiver, magic link),
76
+ products (packs + membership plans), revenue & attendance reports + CSV,
77
+ settings, SQLite backup via `VACUUM INTO`
78
+ - Buy page: Stripe Checkout for packs (one-time) and memberships
79
+ (subscription) with signature-verified, idempotent webhook fulfillment;
80
+ full manual "pay at studio" fallback when Stripe is unconfigured
81
+ - Email via SMTP, or `data/outbox/*.eml` + on-screen links when unset
82
+ - Mindbody CSV importer (clients + pricing options): CLI + admin page,
83
+ JSON column mapping with defaults, dry-run, idempotent by email
84
+ - Seed script (`npm run seed`), Dockerfile + docker-compose, PWA manifest +
85
+ service worker, vendored htmx/Pico.css (no CDN), 67 tests (no network)
86
+
package/Dockerfile ADDED
@@ -0,0 +1,25 @@
1
+ # Studio OS — single-container deployment.
2
+ # better-sqlite3 ships prebuilt binaries for glibc (node:*-slim), so no
3
+ # build toolchain is needed.
4
+ FROM node:22-slim
5
+
6
+ ENV NODE_ENV=production
7
+ WORKDIR /app
8
+
9
+ # Install dependencies first for layer caching.
10
+ COPY package.json package-lock.json ./
11
+ RUN npm ci --omit=dev
12
+
13
+ COPY src ./src
14
+ COPY views ./views
15
+ COPY public ./public
16
+ COPY scripts ./scripts
17
+
18
+ # SQLite database + email outbox live here — mount a volume to persist.
19
+ ENV DB_PATH=/app/data/studio.db
20
+ VOLUME /app/data
21
+
22
+ ENV PORT=3000
23
+ EXPOSE 3000
24
+
25
+ CMD ["node", "src/server.js"]
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Studio OS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/PROGRESS.md ADDED
@@ -0,0 +1,60 @@
1
+ # Studio OS — build progress
2
+
3
+ Authoritative spec: `SPEC.md`. Node used: **v22.18.0** (better-sqlite3 11.x prebuilt, SQLite 3.49.2 — no compile needed).
4
+ Run tests: `npm test`. Start: `npm start` (PORT env, default 3000).
5
+
6
+ ## Done
7
+ - [x] Scaffold: package.json (studio-os, MIT, type:module), deps installed, htmx 1.9.12 + pico.css 2.0.6 vendored in `public/vendor/` (commit 2cb4364)
8
+ - [x] DB: `src/db/schema.sql` (migration v1), `src/db/index.js` (WAL, FK on, migrations, settings helpers, audit), `src/lib/time.js` (Intl-based tz conversion, no tz lib)
9
+ - [x] Core booking engine `src/services/booking.js`: payment resolution (membership → soonest-expiring pack → drop-in), atomic capacity via transactions, waitlist FIFO + promotion (re-resolves payment at promotion), cancellation window refund / late-cancel forfeit (policy setting), cancelClass full refund, attendance marking
10
+ - [x] Rolling schedule generator `src/services/schedule.js` (8-week horizon, idempotent on (rule_id, starts_at))
11
+ - [x] Tests: 31 passing (`test/booking.test.js`, `test/schedule.test.js`) covering every SPEC booking rule incl. capacity race, FIFO promotion, credit deduct/refund, soonest-expiring pack selection, tz materialization (commit fadbc47)
12
+ - [x] Auth service (bcryptjs + HMAC magic-link tokens), mailer (SMTP or `data/outbox/*.eml` fallback + all templates), Stripe service (isolated, injectable client, idempotent fulfillment by `payments.stripe_session_id` UNIQUE)
13
+ - [x] App skeleton `src/app.js` (webhook raw-body mounted before parsers, cookie-session, setup gate, view locals) + `src/server.js` (boot + daily generator)
14
+ - [x] Routes: setup wizard, staff login/logout, webhooks, public (schedule/class/book with waiver/buy/magic-link request), `src/routes/me.js` (magic-link self-service + cancel)
15
+ - [x] Admin routes + full EJS UI (public + admin), PWA assets (manifest, sw.js, custom.css), 16 supertest web tests (commits 897ad8e, 7449c52)
16
+ - [x] Mindbody importer: `src/services/importer.js` (CSV parser, mapping defaults + overrides, idempotent by email), `scripts/import-mindbody.mjs` CLI (--clients/--passes/--mapping/--db/--dry-run), realistic fixtures `test/fixtures/mindbody-{clients,passes}.csv` (edge cases: missing email, quoted commas, uppercase email, malformed row), admin `/admin/import` page wired, 8 importer tests + 1 web test (56 total green)
17
+
18
+ - [x] Stripe webhook tests `test/stripe.test.js` (11 tests, mock client, no network): signature reject, pack/membership/drop-in fulfillment, replay idempotency, subscription cancel, 500-retry on unknown product, checkout session params (67 total green)
19
+ - [x] `scripts/seed.mjs` (demo studio: 3 instructors, 4 class types, 8 weekly rules → 64 instances, products, 4 clients w/ passes+membership, 2×2 demo bookings; refuses non-empty DB without --force; login owner@example.com/studio-demo), Dockerfile (node:22-slim, VOLUME /app/data) + .dockerignore + docker-compose.yml (studio-data volume), README.md (quickstart Docker+Node, Mindbody migration guide, Stripe guide, limitations, roadmap), CHANGELOG.md. NOTE: docker build not run at the time — verified later, see the hardening pass below.
20
+
21
+ ## Self-verification (2026-07-27, item 8) — ALL PASSED
22
+ Live server on PORT=3791, DB_PATH=data/verify-studio.db (deleted after), Node v22.18.0:
23
+ 1. `npm test` → **67/67 pass, 0 fail** (booking 21 + schedule 10 + web 17 + importer 8 + stripe 11).
24
+ 2. Fresh empty DB boot: `GET /` → 302 `Location: /setup`; `GET /setup` → 200 "Welcome to Studio OS".
25
+ 3. `node scripts/seed.mjs` → 3 instructors, 4 class types, 8 rules → 64 instances, products, 4 clients, 4 demo bookings. Re-run without `--force` correctly refused (exit 1).
26
+ 4. Seeded boot: `GET /` → 200; schedule lists 20 upcoming instances (Vinyasa/Pilates/Handstand/Yin) with "spots left".
27
+ 5. Booking round-trip: `POST /class/33/book` (new guest, waiver_agree=1) → 200 "Booked"; duplicate re-POST did NOT create a second booking; admin login (`POST /admin/login` → 302) then `GET /admin/instances/33` roster shows the client, paid_with `drop_in_manual` ("pay at studio"), check-in buttons.
28
+ 6. Magic-link cancel: `POST /magic-link` → on-screen `/me?token=...` (SMTP off); `GET /me?token` → 200 listing the booking; `POST /me/cancel/5` → 302 back to /me, flash "cancelled", list empty; DB row: status='cancelled', cancelled_at set; roster no longer shows the client.
29
+ 7. Server stopped cleanly. NOT verified: `docker build` ($0/no-network guardrail — Dockerfile untested).
30
+
31
+ ## Hardening pass (2026-07-28)
32
+ - [x] CSRF protection: random per-session token stored in the cookie-session, exposed as `res.locals.csrfToken`, hidden `_csrf` input added to all 36 POST forms (public book/buy/magic-link, /me cancel, setup, admin login/logout and every admin form). Middleware in `src/app.js` rejects POST/PUT/DELETE/PATCH with missing/mismatched token (403, timing-safe compare; `x-csrf-token` header also accepted). `/webhooks/stripe` exempt (mounted before the session layer + explicit path guard; authenticated by Stripe signature instead). Tests updated to cookie agents + `csrfToken()` helper; 2 explicit tests (no/wrong token → 403 + nothing written; valid token works; webhook stays exempt). 69/69 green.
33
+
34
+ - [x] Docker verification (2026-07-28, Docker Desktop 4.44.2 / Engine 28.3.2, linux/amd64): `docker compose build` succeeded first try — `node:22-slim` + better-sqlite3 prebuilt glibc binary, no python3/make/g++ stage needed. Found + fixed a real compose bug: `environment:` list containing only comments parses as null → "must be a mapping" validation error; block now fully commented in mapping form. Live run: `docker compose up -d` → `GET /` 302 `/setup` on fresh volume → completed setup wizard via curl (CSRF cookie+token round-trip) → admin one-off class POST → public schedule renders Yoga Flow → guest booking on `/class/1` → "Booked"; booking POST without CSRF token → 403 in-container. `docker compose down -v` clean. Verified commands recorded in README.
35
+ - [x] Rate limiting: `src/lib/ratelimit.js` (dependency-free in-memory fixed window, key = client IP + matched route pattern, injectable clock, X-Forwarded-For honored only when `TRUST_PROXY` env set). Wired in `src/app.js` before the CSRF check: `/magic-link` 5/15min, `/admin/login` 10/15min, booking/buy POSTs (`/class/:id/book`, `/buy/pack/:id`, `/buy/membership/:id`) 30/15min → friendly 429 + Retry-After. 4 tests in `test/ratelimit.test.js` (trigger, window reset via fake clock — no sleeps, XFF spoof/trust behavior). README limitations updated → new Security section; `TRUST_PROXY` documented in `.env.example`. 73/73 green. NOTE: limiter is per-process and resets on restart (documented).
36
+
37
+ ## v0.2.0 — instructor logins (2026-07-28) — SHIPPED, ALL VERIFIED
38
+ - [x] Migration v2 (`src/db/002-instructor-role.sql`): users role CHECK rebuilt to allow 'instructor' (owner/staff untouched — verified against a real v1 file DB: both users kept, v2 applied, instructor insert OK); new `instructor_class_assignments (instructor_id→users, class_id→class_instances, PK both, ON DELETE CASCADE)` + idx_ica_class.
39
+ - [x] Guards (`src/services/auth.js`): `requireAdmin` (admin router; instructor → 403, anon → 302 login), `requireInstructor` (instructor portal; admins → /admin). Login (`src/routes/auth.js`): `/login` aliases `/admin/login`; POST redirects by role (instructor → /instructor/schedule).
40
+ - [x] Instructor portal (`src/routes/instructor.js` + `views/instructor/{schedule,roster}.ejs`): schedule (assigned classes from studio-local today, sorted by date), roster (attendee + status, 'attended' displayed as 'checked-in'; NO client emails exposed), POST checkin (assignment-guarded, booking must belong to the class, reuses `markAttendance`).
41
+ - [x] Admin UI: Instructors page gains logins section; `/admin/instructors/new` (GET+POST, password ≥8, UNIQUE-email friendly error); `/admin/instructors/:id/classes` checkbox assignment (rewrites only the displayed upcoming window; past assignments kept). Routes declared BEFORE `POST /instructors/:id` so 'new' isn't captured as :id.
42
+ - [x] Tests: `test/instructor.test.js` (8) + 4 new view-integrity tests = **110/110 green** (`npm test`).
43
+ - [x] Live E2E on PORT=3799 (seeded DB, curl, then killed + scratch DB deleted): owner created kim@studio.test, assigned class 25 of 2; instructor login → 302 /instructor/schedule; schedule showed only Mat Pilates #25; /admin → 403; roster #9 → 403; roster #25 → 200; check-in booking 1 → Alice Chan 'checked-in'; check-in on #9 → 403; POST without CSRF → 403.
44
+ - [x] package.json 0.2.0, CHANGELOG 0.2.0 entry, README (feature bullet + walkthrough step 6, roadmap line removed).
45
+
46
+ ## Next
47
+ - v0.2.0 complete. Optional future: docker rebuild sanity check on next release; screenshots of the instructor portal for README.
48
+
49
+ ## Pragmatic choices where SPEC is silent (decided, noted per guardrail)
50
+ - **pack_products table added** (schema v1): SPEC's buy page sells "class packs" but only defines per-client `passes`; a purchasable catalogue was needed. Same for drop-in pending payments → `payments.status` ('paid'|'pending').
51
+ - **Waitlisted bookings do not deduct credits**; deduction happens at promotion (same tx), payment re-resolved at promotion time.
52
+ - **Drop-in online**: booking is created immediately; payment row pending; Stripe checkout (kind=drop_in metadata) marks it paid via webhook. Capacity is not held hostage to checkout completion.
53
+ - **Sessions**: cookie-session (signed cookie) — SPEC allows either. app_secret auto-generated into settings, overridable via APP_SECRET env.
54
+ - ~~No CSRF tokens in v0.1 (same-site=lax cookies); listed in README limitations.~~ Added in the 2026-07-28 hardening pass (session-bound `_csrf` token, no csurf dependency).
55
+ - `npm test` uses glob `"test/*.test.js"` — `node --test test/` breaks under Git Bash path mangling on Windows.
56
+
57
+ ## How to resume
58
+ - `cd D:\Repos\ideas\studio-os && npm test` (expect 31+ green) and `git log --oneline` to see where things stand.
59
+ - Work top-down through “Next”. Commit after each numbered item at minimum.
60
+ - Guardrails: stay inside this folder; $0 / no network at boot or in tests; no remotes/publishing; Stripe/SMTP env-gated.
package/README.md ADDED
@@ -0,0 +1,253 @@
1
+ # Studio OS
2
+
3
+ Self-hosted booking, class-pack and membership management for boutique studios —
4
+ yoga, pilates, martial arts, climbing, dance, small gyms. The Mindbody escape hatch.
5
+
6
+ **The structural difference no SaaS rival can copy:** payments run on *your own*
7
+ Stripe account. Studio OS never touches the money — no processing markup, no
8
+ per-booking fees, no contracts. Run it in one Docker container or with plain
9
+ Node on a $5 VPS or a spare machine at the studio.
10
+
11
+ - Public schedule + booking with waiver capture — clients need no passwords
12
+ (signed magic links for self-service cancellation)
13
+ - Class packs (credits), memberships (unlimited or N/month), drop-ins
14
+ - Atomic capacity, FIFO waitlist with auto-promotion, cancellation-window policy
15
+ - Admin: rosters + check-in, clients, manual sales, revenue/attendance reports + CSV,
16
+ one-click SQLite backup
17
+ - Instructor logins: per-class assignments, a scoped schedule + roster portal
18
+ with attendee check-in, zero admin access
19
+ - Mindbody CSV importer (clients + pricing options), idempotent and dry-runnable
20
+ - Stripe Checkout for packs and membership subscriptions (optional), SMTP email
21
+ (optional — without it, emails land in `data/outbox/` and links show on screen)
22
+ - Everything server-rendered (EJS + HTMX + Pico.css, all vendored — no CDN, no
23
+ build step, works offline), SQLite in one file, PWA manifest + service worker
24
+
25
+ ## 5-minute quickstart
26
+
27
+ ### Docker
28
+
29
+ ```sh
30
+ git clone <this repo> studio-os && cd studio-os
31
+ docker compose up -d
32
+ # open http://localhost:3000 → setup wizard creates your studio + owner login
33
+ ```
34
+
35
+ The database persists in the `studio-data` volume. Configure SMTP/Stripe by
36
+ uncommenting the `environment:` block in `docker-compose.yml`.
37
+
38
+ Verified end-to-end (Docker Engine 28.x, linux/amd64, `node:22-slim` base —
39
+ better-sqlite3 uses its prebuilt glibc binary, no compiler stage needed):
40
+
41
+ ```sh
42
+ docker compose build
43
+ docker compose up -d
44
+ curl -i http://localhost:3000/ # 302 → /setup on a fresh volume
45
+ # setup wizard → admin one-off class → public schedule → guest booking: all OK
46
+ docker compose down -v # stop + remove the data volume
47
+ ```
48
+
49
+ ### Bare Node (Node 20+)
50
+
51
+ ```sh
52
+ git clone <this repo> studio-os && cd studio-os
53
+ npm install
54
+ npm start
55
+ # open http://localhost:3000 → setup wizard
56
+ ```
57
+
58
+ The database is a single file at `data/studio.db` (override with `DB_PATH`).
59
+ Copy `.env.example` to `.env` to configure port, SMTP, Stripe. Nothing is
60
+ required — with zero config the app runs fully offline.
61
+
62
+ Want to look around with data first?
63
+
64
+ ```sh
65
+ npm run seed # demo studio: classes, weekly schedule, clients, passes
66
+ npm start # admin login: owner@example.com / studio-demo
67
+ ```
68
+
69
+ ### First-run walkthrough
70
+
71
+ 1. Setup wizard: studio name, timezone, currency, owner account.
72
+ 2. Admin → Class types: create your classes (duration, capacity, drop-in price, credits).
73
+ 3. Admin → Schedule: add weekly rules — instances materialize on a rolling
74
+ 8-week horizon automatically (at boot, daily, and on rule changes).
75
+ 4. Share the public schedule (`/`). Clients book with just name + email and sign
76
+ the waiver on first booking.
77
+ 5. Sell packs/memberships from a client's profile (manual), or connect Stripe
78
+ for online purchase.
79
+ 6. Optional: Admin → Instructors → "Add instructor login", then tick the
80
+ classes they teach. Instructors sign in on the same staff login page and get
81
+ a portal with just their schedule and rosters (with check-in) — no admin
82
+ access.
83
+
84
+ ## Migrating from Mindbody
85
+
86
+ You need two CSV exports from Mindbody:
87
+
88
+ 1. **Clients** — Reports → Clients (or Clients → Export). Standard columns:
89
+ `First Name, Last Name, Email, Mobile Phone, Notes, Liability Waiver, ...`
90
+ 2. **Pricing options** (remaining passes) — Reports → Pricing Options /
91
+ "Remaining sessions". Standard columns:
92
+ `Client, Email, Pricing Option, Remaining, Total, Expiration, Price Paid, ...`
93
+
94
+ Then either paste the CSVs into **Admin → Import** (dry-run checkbox included),
95
+ or run the CLI:
96
+
97
+ ```sh
98
+ # preview first — prints every row's action, writes nothing
99
+ node scripts/import-mindbody.mjs --clients clients.csv --passes pricing.csv --dry-run
100
+
101
+ # real import
102
+ node scripts/import-mindbody.mjs --clients clients.csv --passes pricing.csv
103
+ ```
104
+
105
+ Behavior:
106
+
107
+ - **Idempotent by email** — re-running updates existing clients/passes, never
108
+ duplicates. Safe to re-export from Mindbody and re-import on cutover day.
109
+ - Rows without a valid email are skipped and reported (Mindbody allows
110
+ email-less clients; Studio OS keys everything on email).
111
+ - Passes for emails not in the clients file auto-create a bare client record.
112
+ - A signed "Liability Waiver = Yes" column marks the waiver as signed so
113
+ clients aren't re-prompted.
114
+ - Non-standard column names? Pass `--mapping mapping.json`:
115
+
116
+ ```json
117
+ {
118
+ "clients": { "email": ["E-Mail-Adresse"], "firstName": ["Vorname"] },
119
+ "passes": { "remaining": ["Sessions Left"] }
120
+ }
121
+ ```
122
+
123
+ Keys you provide override the defaults; everything else keeps sane defaults
124
+ (see `DEFAULT_MAPPING` in `src/services/importer.js`). Dates accept `M/D/YYYY`
125
+ and ISO; money accepts `$1,500.00`-style strings.
126
+
127
+ ## Stripe setup (optional, bring-your-own account)
128
+
129
+ Without Stripe, every purchase flow falls back to "pay at studio / FPS" and you
130
+ activate purchases manually from the client profile — fully usable cash-only.
131
+
132
+ 1. Create a [Stripe](https://stripe.com) account (test mode is fine to start).
133
+ 2. Set env vars (`.env` or compose environment):
134
+ - `STRIPE_SECRET_KEY` — Developers → API keys
135
+ - `STRIPE_PUBLISHABLE_KEY`
136
+ - `STRIPE_WEBHOOK_SECRET` — see step 3
137
+ 3. Add a webhook endpoint in Stripe: `https://your-domain/webhooks/stripe`,
138
+ events `checkout.session.completed` and `customer.subscription.deleted`.
139
+ Copy the signing secret into `STRIPE_WEBHOOK_SECRET`.
140
+ (Local testing: `stripe listen --forward-to localhost:3000/webhooks/stripe`.)
141
+ 4. **Class packs** sell immediately via Checkout (price taken from the product;
142
+ optionally paste a Stripe Price ID on the product for Stripe-side pricing).
143
+ 5. **Memberships** are Stripe subscriptions: create a recurring Price in Stripe,
144
+ paste its `price_...` id into the plan in Admin → Products.
145
+
146
+ Fulfillment is idempotent (keyed on the Checkout session id) — Stripe's webhook
147
+ retries can't double-credit. Card data never touches your server; clients pay on
148
+ Stripe-hosted Checkout. Set `BASE_URL` so success/cancel redirects and emailed
149
+ links use your public domain.
150
+
151
+ ## Email (optional)
152
+
153
+ Set `SMTP_HOST` (+ `SMTP_PORT`/`SMTP_USER`/`SMTP_PASS`/`SMTP_FROM`) to send
154
+ booking confirmations, cancellation notices, waitlist promotions and magic
155
+ links. Without SMTP, every email is written to `data/outbox/` as a `.eml` file
156
+ and magic links are shown directly in the UI — everything remains testable.
157
+
158
+ ## Security
159
+
160
+ What's protected out of the box:
161
+
162
+ - **CSRF**: every state-changing form carries a session-bound token; POSTs
163
+ without a valid token get a 403. The Stripe webhook is exempt — it is
164
+ authenticated by Stripe's signature over the raw body instead.
165
+ - **Rate limiting** (in-memory fixed window, per IP + route): magic-link
166
+ requests 5/15 min, admin login 10/15 min, public booking/buy POSTs
167
+ 30/15 min. Over the limit → friendly 429. Behind a reverse proxy, set
168
+ `TRUST_PROXY=1` so limits key on the first `X-Forwarded-For` hop; without
169
+ it that header is ignored (it's spoofable).
170
+ - Passwords are bcrypt-hashed; client self-service uses expiring HMAC-signed
171
+ magic links (no client passwords); sessions are signed `SameSite=Lax`
172
+ `HttpOnly` cookies; webhook fulfillment is idempotent.
173
+
174
+ What's *not* there yet — plan accordingly:
175
+
176
+ - **No 2FA** on staff logins.
177
+ - The rate limiter is **single-instance and in-memory**: counters are
178
+ per-process and reset on restart. Fine for the one-container target; a
179
+ multi-instance deployment needs a shared store (or limit at the proxy).
180
+ - **HTTPS is your reverse proxy's job** — run behind Caddy/Traefik/nginx.
181
+
182
+ ## Backups
183
+
184
+ Admin → Settings → **Download backup** produces a consistent snapshot via
185
+ SQLite `VACUUM INTO`. Or just copy `data/studio.db` while the app is stopped.
186
+ It's one file — cron it anywhere.
187
+
188
+ ## Screenshots
189
+
190
+ **Public booking site** — what your clients see. No account needed; the waiver is
191
+ collected on first booking, and returning clients are matched by email so membership
192
+ or pack credits apply automatically.
193
+
194
+ | Schedule | Booking a class |
195
+ |---|---|
196
+ | ![Public schedule](docs/screenshots/public-schedule.png) | ![Booking form](docs/screenshots/public-booking.png) |
197
+
198
+ **Buy page** — class packs and memberships, on *your* Stripe account (or pay-at-studio
199
+ if you haven't connected Stripe).
200
+
201
+ ![Buy page](docs/screenshots/public-buy.png)
202
+
203
+ **Admin** — dashboard, schedule, class roster with one-tap check-in, client profiles,
204
+ and revenue reports.
205
+
206
+ | Dashboard | Roster & check-in |
207
+ |---|---|
208
+ | ![Admin dashboard](docs/screenshots/admin-dashboard.png) | ![Roster](docs/screenshots/admin-roster.png) |
209
+
210
+ | Schedule management | Client profile | Reports |
211
+ |---|---|---|
212
+ | ![Schedule](docs/screenshots/admin-schedule.png) | ![Client](docs/screenshots/admin-client.png) | ![Reports](docs/screenshots/admin-reports.png) |
213
+
214
+ ## Development
215
+
216
+ ```sh
217
+ npm install
218
+ npm test # node:test + supertest, no network
219
+ npm run dev # --watch mode
220
+ ```
221
+
222
+ Stack: Node 20+, Express, better-sqlite3 (WAL), EJS + HTMX + Pico.css
223
+ (vendored). No frontend build. Tests cover the booking engine (capacity races,
224
+ waitlist FIFO, credit deduct/refund, late-cancel policy), schedule
225
+ materialization across timezones, the web flows, importer idempotency, and
226
+ Stripe webhook fulfillment against a mock client.
227
+
228
+ ## Honest v0.1 limitations
229
+
230
+ - **Single studio, single location, one timezone.**
231
+ - See [Security](#security) for what is and isn't covered (no 2FA;
232
+ single-instance in-memory rate limiter; HTTPS via your reverse proxy).
233
+ - Membership renewal bookkeeping is driven by Stripe webhooks; cash memberships
234
+ need manual renewal (mark paid each cycle).
235
+ - Monthly-credit memberships reset on a simple cycle from `cycle_started_on`;
236
+ no proration.
237
+ - Emails are plain text; no branded HTML templates yet.
238
+ - English UI only (currency is a setting — no hardcoded symbols).
239
+ - Reports are month-granularity tables + CSV; no charts.
240
+
241
+ ## Roadmap
242
+
243
+ - Multi-location support
244
+ - Branded/installable PWA per studio (custom colors, icon, name)
245
+ - SMS reminders (Twilio-compatible, bring-your-own account)
246
+ - Recurring cash membership invoicing + renewal reminders
247
+ - Retail/POS, gift cards, video — explicitly out of scope for now, as are staff
248
+ payroll, per-instructor payouts, marketing automation, native apps, GDPR
249
+ export tooling and i18n.
250
+
251
+ ## License
252
+
253
+ MIT
package/SPEC.md ADDED
@@ -0,0 +1,130 @@
1
+ # Studio OS — self-hosted studio management ("the Mindbody escape hatch")
2
+
3
+ Open-source, self-hosted booking/membership/class-pack management for boutique studios
4
+ (yoga, pilates, martial arts, climbing, dance, small gyms). The structural wedge no SaaS
5
+ rival can copy: **payments run on the operator's OWN Stripe account** — we never touch the
6
+ money, charge no processing markup, no per-booking fees, no contracts.
7
+
8
+ Target user: a studio owner currently paying Mindbody $139–599/mo + ~3.5% forced processing.
9
+ Deployment target: one Docker container (or `npm start`) on a $5 VPS or a spare machine.
10
+
11
+ ## v0.1 scope (MUST all work)
12
+
13
+ ### Stack
14
+ - Node 20+, Express, better-sqlite3 (WAL mode), server-rendered EJS templates + HTMX
15
+ (vendored locally, NO CDN at runtime), vendored Pico.css + one small custom stylesheet.
16
+ No frontend build step. No paid APIs. Everything works offline except Stripe/SMTP.
17
+ - `node:test` + supertest for tests. Dockerfile + docker-compose.yml. `.env` config
18
+ (dotenv), `data/studio.db` SQLite file.
19
+ - License MIT. Repo layout: `src/` (server), `src/routes/`, `src/db/` (schema+migrations+queries),
20
+ `src/services/`, `views/`, `public/` (css/js/manifest), `test/`, `scripts/` (seed, import),
21
+ `README.md`, `CHANGELOG.md`.
22
+
23
+ ### Domain model (SQLite tables)
24
+ - `settings` (key/value): studio name, timezone (default Asia/Hong_Kong), currency (default HKD),
25
+ cancellation window hours (default 12), waiver markdown text, booking policy flags.
26
+ - `users`: operator/staff logins (email, argon2/bcrypt hash, role: owner|staff). Session auth
27
+ (cookie, SQLite session store or signed cookie). First-run setup wizard creates the owner.
28
+ - `instructors`: name, bio, active.
29
+ - `class_types`: name, description, duration min, default capacity, drop-in price cents,
30
+ credits required (default 1), color.
31
+ - `schedule_rules`: weekly recurring template (class_type, instructor, weekday, start time,
32
+ capacity override, active from/until) → materialized into `class_instances` on a rolling
33
+ 8-week horizon (idempotent generator run at boot + daily + on rule change).
34
+ - `class_instances`: datetime, class_type, instructor, capacity, status (scheduled|cancelled),
35
+ notes. One-off instances can be created directly.
36
+ - `clients`: name, email (unique), phone, notes, waiver_signed_at, source (manual|import|self).
37
+ - `passes` (class packs): client, name, credits_total, credits_remaining, expires_on,
38
+ price_paid_cents, source (purchase|import|manual).
39
+ - `memberships`: client, plan name, status (active|paused|cancelled), started_on, renews_on,
40
+ stripe_subscription_id nullable, unlimited flag or credits/month.
41
+ - `membership_plans`: name, price cents, interval (month), unlimited or N credits/month,
42
+ stripe_price_id nullable.
43
+ - `bookings`: class_instance, client, status (booked|waitlist|cancelled|attended|no_show),
44
+ paid_with (pack|membership|drop_in_online|drop_in_manual|comp), pass_id nullable,
45
+ created_at, cancelled_at. Unique (class_instance, client) among non-cancelled.
46
+ - `payments`: client, amount cents, currency, method (stripe|cash|fps|other), reference,
47
+ what (drop_in|pack|membership), stripe_session_id nullable, created_at.
48
+ - `audit_log`: who, action, entity, at — append-only, for operator trust.
49
+
50
+ ### Booking rules (core logic — must be tested)
51
+ - Book with: active membership (unlimited or monthly credits) → else pack with credits
52
+ (soonest-expiring first) → else drop-in (Stripe checkout if configured, else "pay at studio"
53
+ which records a pending manual payment).
54
+ - Capacity enforced atomically (SQLite transaction). Full class → waitlist (FIFO). A
55
+ cancellation auto-promotes the first waitlisted booking and (if SMTP on) emails them.
56
+ - Cancel ≥ cancellation-window hours before start → credit refunded to pack / no charge.
57
+ Late cancel → configurable: forfeit credit (default) or refund. No-show marking by staff.
58
+ - Credits are deducted at booking time, refunded on eligible cancellation, in the same
59
+ transaction as the booking row.
60
+
61
+ ### Public site (no login needed)
62
+ - `/` public schedule: next 14 days, filter by class type/instructor, studio branding from settings.
63
+ - Class page → "Book" → enter name+email (or returning-client email). First booking shows the
64
+ waiver (markdown rendered) with required checkbox; `waiver_signed_at` stored.
65
+ - Client self-service via signed magic links (HMAC token in email / shown on screen when SMTP
66
+ is off): view own upcoming bookings, cancel within policy. NO client passwords in v0.1.
67
+ - Buy page: class packs and membership plans. With Stripe configured → Stripe Checkout
68
+ (packs = one-time, memberships = subscription); webhook `/webhooks/stripe` (signature-verified)
69
+ fulfills: creates pass / activates membership + payment row. Without Stripe → instructions page
70
+ ("pay at studio / FPS") and operator activates manually.
71
+ - PWA: manifest.json + minimal service worker (cache static assets), mobile-first layout.
72
+
73
+ ### Admin (login required)
74
+ - Dashboard: today's classes with live roster counts, week revenue total, expiring passes list.
75
+ - Schedule management: CRUD class types, instructors, weekly rules, one-off classes,
76
+ cancel a class (auto-notifies + refunds credits of all bookings).
77
+ - Roster per class: check-in (attended), no-show, add walk-in client, see paid_with per booking.
78
+ - Clients: search, profile (bookings, passes, memberships, payments), add pass/membership
79
+ manually, record manual payment, edit waiver status, notes.
80
+ - Reports: revenue by month (table), attendance by class type, CSV export of any report.
81
+ - Settings: studio profile, currency, cancellation window, waiver text (markdown editor),
82
+ Stripe keys status (from env, read-only display), SMTP status, backup button (downloads
83
+ a copy of studio.db via SQLite `VACUUM INTO`).
84
+
85
+ ### Mindbody CSV importer
86
+ `scripts/import-mindbody.mjs` + admin UI page. Accepts the standard Mindbody exports:
87
+ - Clients export (First Name, Last Name, Email, Phone, ...) → clients.
88
+ - "Pricing options"/pass export (Client, Pricing Option, Remaining, Expiration, ...) → passes.
89
+ Column mapping via a JSON mapping file with sane defaults; unknown columns ignored; dry-run
90
+ mode prints what would be created; idempotent by email (re-running updates, never duplicates).
91
+ Ship a `test/fixtures/mindbody-*.csv` set of realistic fixtures (invented data, realistic shape).
92
+
93
+ ### Email (optional)
94
+ SMTP via nodemailer, env-configured. When unset: emails are written to `data/outbox/` as .eml
95
+ files and magic links are surfaced in the UI (so everything is testable without SMTP).
96
+ Templates: booking confirmation, cancellation, waitlist promotion, magic link.
97
+
98
+ ### Stripe (optional, BYO account)
99
+ Env: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PUBLISHABLE_KEY. When unset, all
100
+ purchase flows fall back to manual/"pay at studio" and the admin sees a "Stripe not connected"
101
+ hint. When set (test mode is fine): packs via Checkout one-time, memberships via Checkout
102
+ subscription using `membership_plans.stripe_price_id` (admin field to paste a Price ID),
103
+ webhook fulfillment, and payment rows with the Stripe session id. NEVER store card data.
104
+ Write the integration against the official `stripe` npm package; all Stripe calls isolated in
105
+ `src/services/stripe.js` and fully mockable — tests must not hit the network.
106
+
107
+ ### Non-negotiables
108
+ - First-run experience: `npm install && npm start` → setup wizard (studio name, owner account,
109
+ timezone/currency) → seeded example class types optional. `npm run seed` creates a demo studio.
110
+ - Every core booking rule covered by tests (capacity race, waitlist promotion, credit
111
+ deduct/refund, late-cancel forfeit, importer idempotency, webhook fulfillment with mocked
112
+ Stripe, magic-link auth). `npm test` green.
113
+ - No network calls at boot or in tests. No telemetry. No mocks/placeholders in shipped code paths.
114
+ - All timestamps stored UTC, rendered in the studio timezone.
115
+ - README: 5-minute quickstart (Docker + bare Node), Mindbody migration guide (step-by-step:
116
+ which exports to download, how to run the importer), Stripe setup guide, screenshots section
117
+ placeholder, honest v0.1 limitations list, roadmap (multi-location, branded PWA, SMS).
118
+
119
+ ### Out of scope for v0.1 (list in README roadmap; do NOT build now)
120
+ Multi-location, staff payroll, retail/POS, gift cards, video/livestream, marketing automation,
121
+ native apps, per-instructor payouts, GDPR export tooling, i18n (English UI only, but no
122
+ hardcoded currency symbols — use the currency setting).
123
+
124
+ ## Definition of done for v0.1
125
+ `npm test` green; `npm start` boots on a clean machine; a full manual walkthrough works:
126
+ setup wizard → create class type + weekly rule → instances appear on public schedule →
127
+ guest books with waiver → operator sells a 10-pack manually → client books with credit →
128
+ cancel refunds credit → roster check-in → revenue report shows the payments → Mindbody
129
+ fixture import creates clients+passes → magic link lets the client cancel a booking.
130
+ Docker build succeeds. README complete.
@@ -0,0 +1,26 @@
1
+ services:
2
+ studio:
3
+ build: .
4
+ ports:
5
+ - "3000:3000"
6
+ volumes:
7
+ - studio-data:/app/data
8
+ # Everything is optional — see .env.example. Uncomment the block and the
9
+ # variables you use. (An `environment:` key with only comments under it is
10
+ # invalid YAML for compose — keep the key commented until you need it.)
11
+ #environment:
12
+ # BASE_URL: https://booking.example.com
13
+ # APP_SECRET: change-me
14
+ # TRUST_PROXY: "1"
15
+ # SMTP_HOST: smtp.example.com
16
+ # SMTP_PORT: "587"
17
+ # SMTP_USER: user
18
+ # SMTP_PASS: pass
19
+ # SMTP_FROM: My Studio <noreply@example.com>
20
+ # STRIPE_SECRET_KEY: sk_live_...
21
+ # STRIPE_WEBHOOK_SECRET: whsec_...
22
+ # STRIPE_PUBLISHABLE_KEY: pk_live_...
23
+ restart: unless-stopped
24
+
25
+ volumes:
26
+ studio-data: