Skip to content

Make Music Salem — Free Rehearsal Comp

Free hourly rehearsal access at MG10 Cherry City for artists playing the Make Music Salem festival (Sun 2026-06-21). A handful of currently-empty STUDIO_MONTHLY rooms are flipped to STUDIO_HOURLY for the comp period, marked as privately visible (a new generic Resource capability), and surfaced behind a dedicated /lp/make-music-salem landing page. Authed-or-stub artists book free via a modal-based location/room picker that reuses the existing hourly scheduler but skips payment. No promo code, no per-account cap — gate is (a) the unannounced LP, (b) auth/stub-signup, (c) physical room capacity, (d) the Acuity calendar (which blocks the room outside the comp dates), and (e) a server-side MMS_COMP.resourceIds allowlist on the comp FREE booking path.

What this is (and is not)

  • Is: an operational one-off — flip a handful of rooms to STUDIO_HOURLY + private, drop their IDs into a hardcoded allowlist, ship an LP + a parallel public booking endpoint, take it down after the festival.
  • Is not: a generic festival-comp primitive, a coupon system, a promo-code engine, a credit-grant flow. We will not extend PromoRedemption or touch the Stripe coupon pipeline for this.
  • Reusable byproduct: Resource.isPubliclyVisible and User.artistName are both proper schema additions and stay after the event. They're useful primitives on their own (private studios, artist profiles).

Architecture in one breath

Acuity calendars + ops timing control when these rooms can be booked. Resource.isPubliclyVisible controls whether they appear in public listings. MMS_COMP.resourceIds controls which rooms the FREE path accepts. No server-side time-window check; the comp dates are an operational concept (LP copy, ops flip schedule, Acuity calendar blocks), not a runtime gate.

Locked decisions

Decision Value
Festival date Sunday 2026-06-21 (Father's Day / summer solstice / Make Music Day)
Comp dates (ops) 2026-06-15 (Mon) → 2026-06-21 (Sun), inclusive, MG10 timezone — enforced via Acuity calendar
Location MG10 Cherry City (Salem, OR) — single location
Gate model Honor-system + auth/stub-signup (no per-account hour cap)
Server gate isMmsCompResource(resourceId) check on the /api/mms-comp/reservations FREE path
Visibility New generic Resource.isPubliclyVisible: Boolean @default(true); comp rooms set to false
Entry point /lp/make-music-salem — its own page, not under [marketSlug]
Resource handling Ops: flip empty STUDIO_MONTHLY → STUDIO_HOURLY + isPubliclyVisible: false before window, flip back after
Room model Pick a specific room each time (existing HourlyBookingWithPicker UX)
Auth flow Stub-user form (PR #704 pattern); prefilled when authed
Quick-signup fields First + last name, email, phone, artist name
Artist-name storage User.artistName (new nullable column, also exposed on account/profile form)
Comp-rooms public listing Permanently hidden via isPubliclyVisible: false (no time-conditional filter)
Comp-rooms paid path POST /api/reservations rejects hidden resources (defense — keeps them MMS-only)
Hourly price on flipped rooms $0 (FREE path ignores it; defense against any direct hit)
Post-event LP state Replace with thank-you + soft CTA into paid hourly; triggered by a hardcoded date in page.tsx
Tracking events LP view, booking-modal opened, mms_comp_booked (custom)
Reusability LP + endpoints + const removed after event; isPubliclyVisible + artistName stay

Booking flow

Artist lands on /lp/make-music-salem
  → reads "what / who / when"
  → clicks "Book free rehearsal"
  → MODAL: HourlyBookingWithPicker, compMode=true, location preset to MG10 Cherry City
      → room picker — fetched from /api/mms-comp/resources (the only place these rooms surface)
      → time-slot picker (existing HourlyBookingScheduler; Acuity calendar limits to comp dates)
      → identity form (first, last, email, phone, artist name) — prefilled if authed
      → "Book free" CTA (NO PaymentMethodSelector, NO cart $ total — shows "Free" tag)
      → POST /api/mms-comp/reservations
      → confirmation screen
      → setup-link email for new stub users (existing pattern)

Single source of truth

// apps/api/src/services/scheduling/mms-comp.ts (new file)
// Hardcoded allowlist of comp-eligible resources. Ops populates these UUIDs
// pre-deploy after picking rooms. Empty post-event = MMS booking path is dead.
export const MMS_COMP = {
  resourceIds: new Set<string>([
    // 'uuid-room-1',
    // 'uuid-room-2',
  ]),
}

export function isMmsCompResource(resourceId: string | null | undefined): boolean {
  return !!resourceId && MMS_COMP.resourceIds.has(resourceId)
}

That's it. No window check, no isMmsCompBooking. Acuity gates time; this const gates eligibility.

Server gate — parallel endpoint pair

Rather than widen POST /api/reservations FREE acceptance, mirror the POST /api/tours pattern with two new public endpoints. Tours already solve "anonymous guest → stub user → reservation"; copying it keeps the comp surface isolated and makes post-event teardown a directory delete.

POST /api/mms-comp/reservations

Public (no auth required). Accepts:

{
  firstName, lastName, email, phone, artistName, // identity
  resourceId, startTime, endTime, timezone,       // booking
  leadEventId?, smsMarketingConsent?, website?    // tracking + honeypot
}

Flow:

  1. Zod-validate; honeypot bounce.
  2. Reject if !isMmsCompResource(resourceId) → 400.
  3. Resolve user via resolveStubUserFromStaff (matches existing users by email; creates stub if new). Patch artistName onto the user record (set if not already set; do not overwrite).
  4. withTransaction → ReservationCreationService.commitReservation({ paymentMethod: 'FREE', allowFreeContext: 'mms-comp', userId, resourceId, startTime, endTime, ... }).
  5. after(...) side-effects: Acuity create, confirmation email, setup-link email for new stubs.
  6. 201 with { reservationId, isNewUser }.

Validator widening: validatePaymentMethod (ReservationValidationService.ts:117) currently rejects FREE for any resource-bearing reservation (today's tour/dedicated-room FREE paths bypass this validator entirely — they don't go through validateReservation). Add an opt-in allowFreeContext: 'tour' | 'dedicated' | 'mms-comp' parameter so only callers that pass it can submit FREE on a resource.

GET /api/mms-comp/resources

Public (no auth). Returns resources where id ∈ MMS_COMP.resourceIds, deletedAt IS NULL, ACTIVE. Same shape HourlyBookingWithPicker already consumes. Empty set → empty list. The comp-resource endpoint is the only place these rooms surface to customers.

Public visibility — generic Resource.isPubliclyVisible flag

New column: Resource.isPubliclyVisible: Boolean @default(true). Applied permanently (no time-conditional logic):

  • Customer-facing list endpoints filter where: { isPubliclyVisible: true }. Concrete endpoint list TBD at plan time via grep for hourly resource fetches.
  • Customer-facing booking endpoint (POST /api/reservations) rejects when target resource isPubliclyVisible: false. Defense: even if someone reconstructs the URL with a hidden resource ID, they can't book it via the paid path. The comp endpoint (POST /api/mms-comp/reservations) gates by isMmsCompResource(id) instead, which is what allows it through.
  • Staff/admin endpoints are unchanged. Staff sees every resource everywhere.
  • MMS comp endpoint (GET /api/mms-comp/resources) bypasses the filter (it explicitly fetches by ID from MMS_COMP.resourceIds).

Frontend gate

The booking modal is free-only in comp mode:

  • /lp/make-music-salem renders <HourlyBookingWithPicker compMode locationPreset={MG10_CHERRY_CITY} />
  • Room picker fetches from /api/mms-comp/resources (not the normal location-resources endpoint)
  • HourlyBookingContent in compMode:
  • Hides PaymentMethodSelector and the cart $ total (shows "Free" tag)
  • CTA label = Book free
  • Posts to /api/mms-comp/reservations
  • UserInfoForm in compMode shows the artistName field (required). Prefilled when authed from session.

Captured artist data

Field Required Storage
First name User.firstName (existing)
Last name User.lastName (existing)
Email User.email (existing)
Phone User.phone (existing)
Artist name User.artistName (new nullable col, set-once UX)

artistName is added to the regular profile/account form too — this is a permanent user-facing field, not MMS-specific. Comp booking sets it if not already populated; does not overwrite existing values.

Post-event artist list = select * from users where id in (select user_id from reservations where resource_id in <MMS_COMP.resourceIds>).

Resource ops checklist (manual)

Before window opens (≥1 day before 2026-06-15):

  1. Aaron + CM pick N physical rooms at MG10 Cherry City that won't have an active monthly tenant Jun 15–21.
  2. Create N new STUDIO_HOURLY resources (don't flip existing STUDIO_MONTHLY records — keeps monthly listings full during festival week, and avoids the (locationId, resourceType, localSeq) unique-constraint headache on flips).
  3. On each: set name (e.g., "MMS Hourly 1/2/3"), hourlyPriceCents: 0, isPubliclyVisible: false, valid acuityCalendarId, peakHoursStart/End if used, location timezone.
  4. In Acuity: block each room calendar outside the comp dates (2026-06-15 → 2026-06-21).
  5. Coordinate with staff: no monthly lockouts on the physical rooms during Jun 15–21. The system won't enforce this — it's an ops agreement.
  6. Drop the new resource UUIDs into MMS_COMP.resourceIds. Commit + deploy.
  7. Set NEXT_PUBLIC_MMS_COMP_TOKEN + NEXT_PUBLIC_MG10_LOCATION_ID in Vercel; share the /comp/<token> URL with the festival's artist roster.
  8. Smoke-test: book a comp slot end-to-end as a fresh email.

After window closes (on or after 2026-06-22):

  1. LP automatically swaps to post-event content via the hardcoded date constant in page.tsx.
  2. Either soft-delete or status: INACTIVE + isPubliclyVisible: false the comp resources (keeps the audit trail; they're already hidden from public listings).
  3. Empty MMS_COMP.resourceIds and delete apps/api/src/app/api/mms-comp/ + apps/web/src/app/(landing)/comp/ in a follow-up PR.

Local seed: cd apps/api && pnpm tsx scripts/mms-comp.ts seed-local creates 3 hourly rooms at MG10 with shared Acuity calendar 14091958, prints UUIDs to paste into the const.

Tracking events

Event Surface Trigger
mms_lp_view Posthog Page load on /lp/make-music-salem
mms_modal_opened Posthog User clicks "Book free rehearsal" CTA → modal opens
mms_comp_booked Posthog Successful POST /api/mms-comp/reservations → 201

mms_comp_booked properties: resourceId, durationMinutes, startTime, isNewUser, artistName.

We also fire the existing hourly_booked event for parity; the custom event is additive.

Post-event LP state

After a hardcoded POST_EVENT_AT date in page.tsx (e.g. 2026-06-22T00:00:00-07:00), the same URL renders:

  • "Thanks for playing Make Music Salem 2026. We hope you found a new rehearsal home."
  • Soft CTA → /lp/cherry-city (or the live MG10 hourly LP at that point)
  • Optional: photos / artist list / festival recap

Implemented as a Date.now() >= POST_EVENT_AT branch in page.tsx. Zero ops toggle needed; rolls over automatically. The constant lives on the web side only — no shared package, no api dependency.

File map (anticipated)

Path Purpose
apps/api/prisma/schema.prisma + migration User.artistName, Resource.isPubliclyVisible
apps/api/src/services/scheduling/mms-comp.ts (new) MMS_COMP.resourceIds + isMmsCompResource — single source of truth
apps/api/src/services/scheduling/ReservationValidationService.ts Add allowFreeContext parameter to validatePaymentMethod
apps/api/src/services/scheduling/ReservationCreationService.ts Plumb allowFreeContext through
apps/api/src/app/api/mms-comp/reservations/route.ts (new) POST — guest signup → free reservation
apps/api/src/app/api/mms-comp/resources/route.ts (new) GET — public list of comp-eligible resources
Various customer-facing resource list endpoints Filter isPubliclyVisible: true
apps/api/src/app/api/reservations/route.ts Reject isPubliclyVisible: false resources (defense)
apps/web/src/app/(landing)/lp/make-music-salem/page.tsx (new) LP, conditional live vs post-event render
apps/web/src/app/(landing)/lp/make-music-salem/MmsLandingContent.tsx (new) LP body + CTA → booking modal
apps/web/src/app/(landing)/lp/make-music-salem/MmsPostEventContent.tsx (new) Post-event thank-you + paid-hourly CTA
apps/web/src/components/organisms/booking/HourlyBookingWithPicker.tsx Extend with compMode?: boolean
apps/web/src/components/organisms/booking/HourlyBookingContent.tsx compMode: hide payment, force FREE submit, post to comp endpoint
apps/web/src/components/molecules/scheduling/UserInfoForm.tsx Add artistName field (props gate visibility/required)
Account/profile form component Add artistName field — permanent UX, not MMS-only
apps/web/src/lib/tracking.ts Add trackMmsLpView, trackMmsModalOpened, trackMmsCompBooked

Testing strategy

  • Server unit: isMmsCompResource (truthy ID, falsy ID, null, undefined). validatePaymentMethod with each allowFreeContext value + default.
  • API integration: POST /api/mms-comp/reservations with a comp resource succeeds; with a non-comp resource → 400; honeypot bounces; existing user (no stub creation); new user gets setup-link email queued.
  • Visibility: customer-facing resource list endpoints exclude isPubliclyVisible: false; POST /api/reservations rejects them; GET /api/mms-comp/resources returns them.
  • Frontend unit: HourlyBookingContent in compMode doesn't render PaymentMethodSelector, posts to /api/mms-comp/reservations, shows "Free" not a money total. UserInfoForm shows artistName field.
  • Manual smoke: end-to-end as a fresh email — LP → modal → form → book → confirmation → setup-link email received.

Known gaps

Only one remains:

  • Exact resource IDs at MG10 Cherry City — TBD. Ops picks N empty STUDIO_MONTHLY rooms, flips them, drops UUIDs into MMS_COMP.resourceIds. Value, not design.

Everything else is locked. Concrete customer-facing endpoint paths for the isPubliclyVisible filter are enumerated at code time via a grep in plan item 6.