Skip to content

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.isPubliclyVisible migrations
  • [ ] 2. MMS_COMP const + isMmsCompResource
  • [ ] 3. validatePaymentMethod accepts FREE on opt-in allowFreeContext
  • [ ] 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 in POST /api/reservations
  • [ ] 7. UserInfoForm + account/profile form — artistName field
  • [ ] 8. HourlyBookingContentcompMode prop, "Book free" CTA, posts to comp endpoint
  • [ ] 9. HourlyBookingWithPickercompMode prop, MG10 preset, comp resource list
  • [ ] 10. /lp/make-music-salem page — 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

Work breakdown

1. Schema: User.artistName + Resource.isPubliclyVisible migrations

Files

  • apps/api/prisma/schema.prisma
  • apps/api/prisma/migrations/<timestamp>_user_artist_name_resource_public_visibility/migration.sql

Changes

  • User: add artistName String? @map("artist_name"). Nullable. No default.
  • Resource: add isPubliclyVisible Boolean @default(true) @map("is_publicly_visible"). Default true so all existing resources stay visible — matches codebase boolean convention (isActive, isAvailable, isRequired are all positive defaults).
  • Run pnpm db-migration (per project skill) to generate.
  • Spot-check migration SQL adds two columns; is_publicly_visible backfills to true for 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.ts
  • apps/api/src/services/scheduling/ReservationCreationService.ts
  • Associated tests

Changes

  • validatePaymentMethod (line 117): add optional 4th argument allowFreeContext?: 'tour' | 'dedicated' | 'mms-comp'. When paymentMethod === 'FREE' and allowFreeContext is set, accept; otherwise keep the existing rejection.
  • validateReservation propagates the option to validatePaymentMethod.
  • ReservationCreationService.commitReservation accepts allowFreeContext and 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 artistName onto 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 matching Resource rows (deletedAt: null, status ACTIVE). Returns the shape HourlyBookingWithPicker consumes: 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 stay isPubliclyVisible: false and 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.ts
  • apps/api/src/app/api/resources/available/route.ts
  • apps/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/api at code time
  • apps/api/src/app/api/reservations/route.ts (rejection path)
  • Associated tests

Changes

  • List endpoints: add isPubliclyVisible: true to the Prisma where. Permanent — no time-conditional logic, no MMS-awareness needed at the call site.
  • POST /api/reservations: after resolving the target resource, reject when resource.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 commitReservation directly, which doesn't have this guard — isMmsCompResource(id) is the gate that allows it through.
  • Tests: each modified list endpoint excludes isPubliclyVisible: false resources; POST /api/reservations rejects 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.tsx
  • apps/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: extend UserInfo type with artistName?: string. Add a MusicalNoteIcon-prefixed input, rendered when showArtistName?: boolean is true and required when requireArtistName?: boolean is true. Update isValid and handleSubmit.
  • Account/profile form: add artistName field — permanent, always visible, optional. This is user-facing UX beyond MMS.
  • Tests: UserInfoForm hides the field by default; visible when prop set; required when requireArtistName. Profile form persists artistName via the existing update-profile endpoint (add to the schema there).

Spec coverage: Captured artist data

8. HourlyBookingContentcompMode prop

Files

  • apps/web/src/components/organisms/booking/HourlyBookingContent.tsx
  • apps/web/src/components/organisms/booking/HourlyBookingContent.test.tsx

Changes

  • Add compMode?: boolean to props.
  • When compMode:
  • Skip loadPackages effect (no balance/credit fetch)
  • Hide PaymentMethodSelector and cart $ totals; render "Free" tag in CartSummary
  • Skip the isProfileComplete / Stripe checkout-session branch entirely
  • CTA label = Book free
  • On submit: POST to /api/mms-comp/reservations with { identity fields..., resourceId, startTime, endTime, timezone }; on 201, route to /reservations/confirmation with the returned reservationId
  • Pass showArtistName + requireArtistName to the scheduler's UserInfoForm (via HourlyBookingScheduler prop drilling; small forward change there if needed)
  • Prefill identity fields from session when authed
  • Tests: in compMode, PaymentMethodSelector not 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. HourlyBookingWithPickercompMode prop

Files

  • apps/web/src/components/organisms/booking/HourlyBookingWithPicker.tsx
  • apps/web/src/components/organisms/booking/HourlyBookingWithPicker.test.tsx

Changes

  • Add compMode?: boolean + locationPreset?: { id, slug, ... } props.
  • When compMode:
  • Skip LocationPicker entirely (locationPreset always passed — MG10 Cherry City)
  • StudioPicker fetch URL switches to /mms-comp/resources (the existing hardcoded /locations/${id}/resources/public?resourceType=STUDIO_HOURLY URL becomes conditional)
  • StudioPicker hides the From $X/hr price chip (rooms return 0¢; "From $0/hr" looks broken)
  • Force-show studio picker even if a resourceId is passed (artists pick fresh each time)
  • Pass compMode through to HourlyBookingContent
  • 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 constant const POST_EVENT_AT = new Date('2026-06-22T00:00:00-07:00'). Branch on Date.now() >= POST_EVENT_AT.getTime() → render MmsPostEventContent, else render MmsLandingContent. 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 one setIsOpen(true) handler.
  • MmsPostEventContent.tsx: "Thanks for playing Make Music Salem 2026", soft CTA → /lp/cherry-city.
  • Out of scope (follow-up cleanup): refactor MarketLandingContent.tsx to drop its hand-rolled <section> tags in favor of Section + 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.ts
  • apps/web/src/app/(landing)/lp/make-music-salem/MmsLandingContent.tsx
  • apps/web/src/components/organisms/booking/HourlyBookingContent.tsx
  • apps/web/src/components/organisms/booking/HourlyBookingWithPicker.tsx

Changes

  • Add trackMmsLpView(), trackMmsModalOpened(), trackMmsCompBooked({ resourceId, durationMinutes, startTime, isNewUser, artistName }).
  • Wire pageview in MmsLandingContent (client-side useEffect).
  • Wire modal-open when the booking modal opens in compMode.
  • Wire trackMmsCompBooked in 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/reservations with a fresh email → assert: User created (stub) with artistName set, Reservation created with paymentMethod: '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.resourceIds and 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)

  1. Flip rooms back to STUDIO_MONTHLY, isPubliclyVisible: true
  2. Empty MMS_COMP.resourceIds (or delete the const file entirely)
  3. Delete apps/api/src/app/api/mms-comp/
  4. Delete apps/web/src/app/(landing)/lp/make-music-salem/ (or leave with redirect to /lp/cherry-city)
  5. Strip compMode from HourlyBookingContent / HourlyBookingWithPicker if not reused elsewhere
  6. Keep User.artistName + Resource.isPubliclyVisible permanently — they're general primitives