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.
- package/.dockerignore +7 -0
- package/.env.example +34 -0
- package/CHANGELOG.md +86 -0
- package/Dockerfile +25 -0
- package/LICENSE +21 -0
- package/PROGRESS.md +60 -0
- package/README.md +253 -0
- package/SPEC.md +130 -0
- package/docker-compose.yml +26 -0
- package/docs/screenshots/admin-client.png +0 -0
- package/docs/screenshots/admin-dashboard.png +0 -0
- package/docs/screenshots/admin-reports.png +0 -0
- package/docs/screenshots/admin-roster.png +0 -0
- package/docs/screenshots/admin-schedule.png +0 -0
- package/docs/screenshots/public-booking.png +0 -0
- package/docs/screenshots/public-buy.png +0 -0
- package/docs/screenshots/public-schedule.png +0 -0
- package/package.json +54 -0
- package/public/css/custom.css +40 -0
- package/public/js/icon.svg +4 -0
- package/public/js/sw.js +23 -0
- package/public/manifest.json +17 -0
- package/public/vendor/htmx.min.js +1 -0
- package/public/vendor/pico.min.css +4 -0
- package/scripts/import-mindbody.mjs +109 -0
- package/scripts/seed.mjs +147 -0
- package/src/app.js +141 -0
- package/src/db/002-instructor-role.sql +26 -0
- package/src/db/index.js +82 -0
- package/src/db/schema.sql +164 -0
- package/src/lib/ratelimit.js +50 -0
- package/src/lib/time.js +87 -0
- package/src/routes/admin.js +692 -0
- package/src/routes/auth.js +34 -0
- package/src/routes/instructor.js +82 -0
- package/src/routes/me.js +101 -0
- package/src/routes/public.js +215 -0
- package/src/routes/setup.js +55 -0
- package/src/routes/webhooks.js +28 -0
- package/src/server.js +29 -0
- package/src/services/auth.js +81 -0
- package/src/services/booking.js +261 -0
- package/src/services/importer.js +201 -0
- package/src/services/mailer.js +92 -0
- package/src/services/schedule.js +71 -0
- package/src/services/stripe.js +169 -0
- package/test/booking.test.js +346 -0
- package/test/fixtures/mindbody-clients.csv +9 -0
- package/test/fixtures/mindbody-passes.csv +8 -0
- package/test/helpers.js +59 -0
- package/test/importer.test.js +173 -0
- package/test/instructor.test.js +217 -0
- package/test/ratelimit.test.js +123 -0
- package/test/schedule.test.js +102 -0
- package/test/stripe.test.js +249 -0
- package/test/views.test.js +38 -0
- package/test/web.test.js +392 -0
- package/views/admin/class_types.ejs +45 -0
- package/views/admin/client.ejs +149 -0
- package/views/admin/clients.ejs +33 -0
- package/views/admin/dashboard.ejs +55 -0
- package/views/admin/import.ejs +39 -0
- package/views/admin/instructor_classes.ejs +21 -0
- package/views/admin/instructor_new.ejs +15 -0
- package/views/admin/instructors.ejs +49 -0
- package/views/admin/products.ejs +83 -0
- package/views/admin/reports.ejs +34 -0
- package/views/admin/roster.ejs +58 -0
- package/views/admin/rules.ejs +71 -0
- package/views/admin/schedule.ejs +50 -0
- package/views/admin/settings.ejs +53 -0
- package/views/error.ejs +7 -0
- package/views/instructor/roster.ejs +27 -0
- package/views/instructor/schedule.ejs +21 -0
- package/views/login.ejs +11 -0
- package/views/partials/foot.ejs +11 -0
- package/views/partials/head.ejs +54 -0
- package/views/public/book_result.ejs +26 -0
- package/views/public/buy.ejs +37 -0
- package/views/public/buy_manual.ejs +17 -0
- package/views/public/buy_thanks.ejs +7 -0
- package/views/public/class.ejs +29 -0
- package/views/public/magic_request.ejs +21 -0
- package/views/public/me.ejs +47 -0
- package/views/public/schedule.ejs +49 -0
- package/views/setup.ejs +53 -0
package/.dockerignore
ADDED
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
|
+
|  |  |
|
|
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
|
+

|
|
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
|
+
|  |  |
|
|
209
|
+
|
|
210
|
+
| Schedule management | Client profile | Reports |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
|  |  |  |
|
|
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:
|