Make Music Salem — Implementation Plan
Status: in-progress
Implements spec.md.
Goal
Ship /lp/make-music-salem + a parallel public booking endpoint pair that takes festival artists from "I want to rehearse free" to a confirmed paymentMethod: 'FREE' hourly reservation at MG10 Cherry City. The rooms are made privately visible (a new generic Resource.isPubliclyVisible capability) so they only surface via the MMS LP. Acuity calendars + ops timing control when they're bookable; MMS_COMP.resourceIds controls which rooms the FREE path accepts; no server-side time-window logic.
Status
- [ ] 1. Schema:
User.artistName+Resource.isPubliclyVisiblemigrations - [ ] 2.
MMS_COMPconst +isMmsCompResource - [ ] 3.
validatePaymentMethodaccepts FREE on opt-inallowFreeContext - [ ] 4.
POST /api/mms-comp/reservations— guest-signup → free reservation - [ ] 5.
GET /api/mms-comp/resources— public list of comp-eligible resources - [ ] 6. Enforce
isPubliclyVisible: filter customer-facing listings + reject inPOST /api/reservations - [ ] 7.
UserInfoForm+ account/profile form —artistNamefield - [ ] 8.
HourlyBookingContent—compModeprop, "Book free" CTA, posts to comp endpoint - [ ] 9.
HourlyBookingWithPicker—compModeprop, MG10 preset, comp resource list - [ ] 10.
/lp/make-music-salempage — pre-event LP + post-event date branch - [ ] 11. Tracking events —
mms_lp_view,mms_modal_opened,mms_comp_booked - [ ] 12. End-to-end smoke test
Driving citations
- Spec Locked decisions — what we promised to ship
- Spec Architecture in one breath — Acuity gates time;
isPubliclyVisiblegates listing;MMS_COMP.resourceIdsgates the FREE path - Spec Single source of truth — the
MMS_COMPconst, reduced to resourceIds only - Spec Server gate — parallel endpoint pair mirroring
/api/tours - Spec Public visibility — permanent column, no time gate
- Spec Booking flow — the artist's path through the UI
- Spec Resource ops checklist — manual ops sandwich
Work breakdown
1. Schema: User.artistName + Resource.isPubliclyVisible migrations
Files
apps/api/prisma/schema.prismaapps/api/prisma/migrations/<timestamp>_user_artist_name_resource_public_visibility/migration.sql
Changes
User: addartistName String? @map("artist_name"). Nullable. No default.Resource: addisPubliclyVisible Boolean @default(true) @map("is_publicly_visible"). Defaulttrueso all existing resources stay visible — matches codebase boolean convention (isActive,isAvailable,isRequiredare all positive defaults).- Run
pnpm db-migration(per project skill) to generate. - Spot-check migration SQL adds two columns;
is_publicly_visiblebackfills totruefor existing rows.
Spec coverage: Captured artist data, Public visibility
2. MMS_COMP const + isMmsCompResource
Files
apps/api/src/services/scheduling/mms-comp.ts(new)apps/api/src/services/scheduling/mms-comp.test.ts(new)
Changes
- Export
MMS_COMP = { resourceIds: new Set<string>() }. Empty in code; ops populates pre-deploy. - Export
isMmsCompResource(id: string | null | undefined): boolean. - Unit tests: empty set always false; populated set true for member, false for non-member; null/undefined safe.
Spec coverage: Single source of truth
3. validatePaymentMethod accepts FREE on opt-in allowFreeContext
Files
apps/api/src/services/scheduling/ReservationValidationService.tsapps/api/src/services/scheduling/ReservationCreationService.ts- Associated tests
Changes
validatePaymentMethod(line 117): add optional 4th argumentallowFreeContext?: 'tour' | 'dedicated' | 'mms-comp'. WhenpaymentMethod === 'FREE'andallowFreeContextis set, accept; otherwise keep the existing rejection.validateReservationpropagates the option tovalidatePaymentMethod.ReservationCreationService.commitReservationacceptsallowFreeContextand forwards it.- Tests: FREE without context rejected (current behavior); FREE with
'mms-comp'accepted; CREDIT/MONEY unaffected.
Note on spec/plan reconciliation: spec previously claimed validatePaymentMethod already allowed FREE for tours/dedicated. Verified false — those paths bypass validateReservation entirely. Spec rewritten to match.
Spec coverage: Server gate
4. POST /api/mms-comp/reservations — guest-signup → free reservation
Files
apps/api/src/app/api/mms-comp/reservations/route.ts(new)apps/api/src/app/api/mms-comp/reservations/route.test.ts(new)packages/shared-schemas/src/...—createMmsCompBookingSchema
Changes
- Schema:
firstName,lastName,email,phone,artistName,resourceId,startTime,endTime,timezone,leadEventId?,smsMarketingConsent?,website?(honeypot). - Handler structure mirrors
apps/api/src/app/api/tours/route.ts: - Zod-validate
- Honeypot bounce → 200 silent
if (!isMmsCompResource(resourceId))→ 400 "Resource not eligible for comp booking"- Resolve user via
resolveStubUserFromStaff({ firstName, lastName, email, phone })(handles existing-user match + new-stub creation) - Patch
artistNameonto the User if currently null — do not overwrite an existing value withTransaction → ReservationCreationService.commitReservation({ paymentMethod: 'FREE', allowFreeContext: 'mms-comp', userId, resourceId, startTime, endTime, timezone })after(...): Acuity create, confirmation email, setup-link email for new stubs- 201
{ reservationId, isNewUser } - Public route — no auth required. Same as
/api/tours. - Tests: happy path (new user); happy path (existing user — artistName patched); happy path (existing user with artistName — not overwritten); non-comp-resource 400; honeypot bounce; Acuity failure rollback; conflicting slot 409.
Spec coverage: Booking flow, Server gate, Captured artist data
5. GET /api/mms-comp/resources — public list
Files
apps/api/src/app/api/mms-comp/resources/route.ts(new)apps/api/src/app/api/mms-comp/resources/route.test.ts(new)
Changes
- Public GET (no auth). Reads
MMS_COMP.resourceIds, fetches matchingResourcerows (deletedAt: null, status ACTIVE). Returns the shapeHourlyBookingWithPickerconsumes: id, name, description, size, capacity, coverImageUrl, images,displayPriceCardCents: 0,basePriceCents: 0, isAvailable, location: { id, name, address, timezone, ... }. - Empty
resourceIds→{ data: { items: [] } }. LP renders gracefully (empty state copy). - This endpoint does not filter by
isPubliclyVisible— it explicitly fetches by ID from the allowlist. Comp rooms can stayisPubliclyVisible: falseand still appear here. - Tests: empty set → empty list; populated set → correct shape; ignores resources not in the set even if visible; includes hidden resources from the set.
Spec coverage: Server gate, Frontend gate
6. Enforce isPubliclyVisible across customer-facing endpoints
Files
apps/api/src/app/api/resources/public/route.tsapps/api/src/app/api/resources/available/route.tsapps/api/src/app/api/locations/[id]/resources/route.ts- Any other customer-facing endpoint surfaced by
grep -rn "STUDIO_HOURLY\|hourly.*resources\|listResources" apps/api/src/app/apiat code time apps/api/src/app/api/reservations/route.ts(rejection path)- Associated tests
Changes
- List endpoints: add
isPubliclyVisible: trueto the Prismawhere. Permanent — no time-conditional logic, no MMS-awareness needed at the call site. POST /api/reservations: after resolving the target resource, reject whenresource.isPubliclyVisible === false→ 400 "Resource not available for public booking". This is defense — keeps hidden resources unbook-able via the paid path even if a URL is reconstructed.- Staff/admin endpoints are not touched. Staff sees and books everything.
- The MMS comp endpoint (item 4) submits via
commitReservationdirectly, which doesn't have this guard —isMmsCompResource(id)is the gate that allows it through. - Tests: each modified list endpoint excludes
isPubliclyVisible: falseresources;POST /api/reservationsrejects them with 400; default-visible resources continue to work normally.
Spec coverage: Public visibility
7. UserInfoForm + account/profile form — artistName field
Files
apps/web/src/components/molecules/scheduling/UserInfoForm.tsxapps/web/src/components/molecules/scheduling/UserInfoForm.test.tsx- Account/profile form component (located at code time — likely
apps/web/src/app/(authed)/account/...or similar) - Associated test
Changes
UserInfoForm: extendUserInfotype withartistName?: string. Add aMusicalNoteIcon-prefixed input, rendered whenshowArtistName?: booleanis true and required whenrequireArtistName?: booleanis true. UpdateisValidandhandleSubmit.- Account/profile form: add
artistNamefield — permanent, always visible, optional. This is user-facing UX beyond MMS. - Tests:
UserInfoFormhides the field by default; visible when prop set; required whenrequireArtistName. Profile form persistsartistNamevia the existing update-profile endpoint (add to the schema there).
Spec coverage: Captured artist data
8. HourlyBookingContent — compMode prop
Files
apps/web/src/components/organisms/booking/HourlyBookingContent.tsxapps/web/src/components/organisms/booking/HourlyBookingContent.test.tsx
Changes
- Add
compMode?: booleanto props. - When
compMode: - Skip
loadPackageseffect (no balance/credit fetch) - Hide
PaymentMethodSelectorand cart $ totals; render "Free" tag inCartSummary - Skip the
isProfileComplete/ Stripe checkout-session branch entirely - CTA label = Book free
- On submit: POST to
/api/mms-comp/reservationswith{ identity fields..., resourceId, startTime, endTime, timezone }; on 201, route to/reservations/confirmationwith the returnedreservationId - Pass
showArtistName+requireArtistNameto the scheduler'sUserInfoForm(viaHourlyBookingSchedulerprop drilling; small forward change there if needed) - Prefill identity fields from session when authed
- Tests: in
compMode,PaymentMethodSelectornot rendered; submit posts to/api/mms-comp/reservations; CTA reads "Book free"; cart shows "Free"; authed prefill works.
Spec coverage: Booking flow, Frontend gate
9. HourlyBookingWithPicker — compMode prop
Files
apps/web/src/components/organisms/booking/HourlyBookingWithPicker.tsxapps/web/src/components/organisms/booking/HourlyBookingWithPicker.test.tsx
Changes
- Add
compMode?: boolean+locationPreset?: { id, slug, ... }props. - When
compMode: - Skip
LocationPickerentirely (locationPreset always passed — MG10 Cherry City) StudioPickerfetch URL switches to/mms-comp/resources(the existing hardcoded/locations/${id}/resources/public?resourceType=STUDIO_HOURLYURL becomes conditional)StudioPickerhides theFrom $X/hrprice chip (rooms return 0¢; "From $0/hr" looks broken)- Force-show studio picker even if a
resourceIdis passed (artists pick fresh each time) - Pass
compModethrough toHourlyBookingContent - Tests: comp mode hits
/mms-comp/resources; price chip hidden; non-comp behavior unchanged; empty resource list renders the existing empty state.
Spec coverage: Frontend gate
10. /lp/make-music-salem page — pre-event LP + post-event date branch
Files
apps/web/src/app/(landing)/lp/make-music-salem/page.tsx(new)apps/web/src/app/(landing)/lp/make-music-salem/MmsLandingContent.tsx(new)apps/web/src/app/(landing)/lp/make-music-salem/MmsPostEventContent.tsx(new)- Associated tests
Changes
page.tsx: server component. Local constantconst POST_EVENT_AT = new Date('2026-06-22T00:00:00-07:00'). Branch onDate.now() >= POST_EVENT_AT.getTime()→ renderMmsPostEventContent, else renderMmsLandingContent. Constant lives on the web side only — no shared package, no api dep.- SEO metadata:
robots: { index: false, follow: false }(LP is unannounced).
Wireframe (locked):
┌─────────────────────────────────────────────────────────────────┐
│ [MG header — existing] │
├─────────────────────────────────────────────────────────────────┤
│ HERO bg: /images/marketing/hourly-marketing.jpg (TBD-final) │
│ │
│ Playing Make Music Salem? │
│ Rehearse on us. │
│ │
│ Free rooms at MG10 Cherry City · Mon Jun 15 – Sun Jun 21 │
│ │
│ [ Book free rehearsal → ] │
│ │
│ For artists playing June 21. Honor system, no code. │
├─────────────────────────────────────────────────────────────────┤
│ HOW IT WORKS (reuse StepsSection) │
│ 1. Pick a room + slot │
│ 2. Walk in — keycode entry │
│ 3. Drums/amps/PA waiting, just bring instruments │
├─────────────────────────────────────────────────────────────────┤
│ WHAT YOU GET (reuse BenefitsGridSection, 4 tiles) │
│ • Backline ready • No noise restrictions │
│ • Climate control • Soundproofed │
├─────────────────────────────────────────────────────────────────┤
│ THE SPACE │
│ [studio photo] MG10 Cherry City, 444 Liberty St NE │
│ Walkable to downtown performance sites │
├─────────────────────────────────────────────────────────────────┤
│ FAQ (reuse FAQSection) │
│ Who can book? · How many sessions? · What do I bring? │
│ Parking? · Can I bring my whole band? · After the festival? │
├─────────────────────────────────────────────────────────────────┤
│ CLOSING CTA (full-bleed image bg) │
│ See you on June 21. │
│ [ Book free rehearsal → ] │
└─────────────────────────────────────────────────────────────────┘
Cut vs /lp/[marketSlug]: tour form (hero + closing), 3-fork "ways to play", LocationsNearYou, testimonials, hero video. Single offer, single location, scheduler-forward.
Composition (no new templates — reuse existing primitives):
| Wireframe section | Component |
|---|---|
| Hero | ContentFirstHero with eyebrow, title, subtitle, primaryCTA.onPress=openModal, image=/images/marketing/hourly-marketing.jpg |
| How it works | StepsSection (3 steps) |
| What you get | BenefitsGridSection (4 tiles — pruned from the 6 used on market LPs) |
| The space | bespoke 6-line block inside <Section> (photo + address + walkable blurb) |
| FAQ | FAQSection |
| Closing CTA | CTASection (title + buttons → opens same modal) |
| Booking modal | Modal + ModalContent/Body/Header + ModalWrapper + CmHelpFooter (same shape as MarketLandingContent's HourlyForkCard modal at lines 513–553) |
MmsLandingContent.tsx: orchestrates the composition above. Modal state local. Both CTAs (hero + closing) share onesetIsOpen(true)handler.MmsPostEventContent.tsx: "Thanks for playing Make Music Salem 2026", soft CTA →/lp/cherry-city.- Out of scope (follow-up cleanup): refactor
MarketLandingContent.tsxto drop its hand-rolled<section>tags in favor ofSection+ the section organisms. Existing debt; not blocking MMS. - Tests: pre-date renders landing; post-date renders thank-you; CTA opens modal.
Spec coverage: Booking flow, Post-event LP state
11. Tracking events
Files
apps/web/src/lib/tracking.tsapps/web/src/app/(landing)/lp/make-music-salem/MmsLandingContent.tsxapps/web/src/components/organisms/booking/HourlyBookingContent.tsxapps/web/src/components/organisms/booking/HourlyBookingWithPicker.tsx
Changes
- Add
trackMmsLpView(),trackMmsModalOpened(),trackMmsCompBooked({ resourceId, durationMinutes, startTime, isNewUser, artistName }). - Wire pageview in
MmsLandingContent(client-sideuseEffect). - Wire modal-open when the booking modal opens in compMode.
- Wire
trackMmsCompBookedin the 201 success path of the comp submit.
Spec coverage: Tracking events
12. End-to-end smoke test
Files
apps/api/src/app/api/mms-comp/reservations/route.smoke.test.ts(new) — or fold into the route's integration test in item 4
Changes
- Seed a Resource, add its id to
MMS_COMP.resourceIds(test override), POST/api/mms-comp/reservationswith a fresh email → assert: User created (stub) withartistNameset, Reservation created withpaymentMethod: 'FREE', Acuity service called, setup-link email side-effect queued. - Non-comp-resource case: same body but a Resource not in the set → 400.
- Hidden-resource via paid path: seed
Resource.isPubliclyVisible: false, POST/api/reservations→ 400.
Spec coverage: Testing strategy
Out of scope (deliberately)
- Per-account hour cap (honor-system per spec)
- Generalized "FreeAccessGrant" model or coupon abstraction
- Promo-code or Stripe-coupon integration
- Staff admin UI for toggling
isPubliclyVisible(can be done via psql or a future small feature — not blocking the festival) - Listing every customer-facing endpoint up-front — item 6 enumerates concretely via grep at code time
Done criteria
- All 12 status items checked
- Smoke test passes end-to-end
- Ops has populated
MMS_COMP.resourceIdsand flipped rooms (resourceType + isPubliclyVisible + Acuity calendar) at least 1 day before 2026-06-15 - Aaron can explain back: how the comp endpoint gates FREE, how hidden resources stay off public listings, and what happens automatically on 2026-06-22 (LP swap)
Post-event teardown (future work, not part of this plan)
- Flip rooms back to STUDIO_MONTHLY,
isPubliclyVisible: true - Empty
MMS_COMP.resourceIds(or delete the const file entirely) - Delete
apps/api/src/app/api/mms-comp/ - Delete
apps/web/src/app/(landing)/lp/make-music-salem/(or leave with redirect to/lp/cherry-city) - Strip
compModefromHourlyBookingContent/HourlyBookingWithPickerif not reused elsewhere - Keep
User.artistName+Resource.isPubliclyVisiblepermanently — they're general primitives