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
PromoRedemptionor touch the Stripe coupon pipeline for this. - Reusable byproduct:
Resource.isPubliclyVisibleandUser.artistNameare 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:
- Zod-validate; honeypot bounce.
- Reject if
!isMmsCompResource(resourceId)→ 400. - Resolve user via
resolveStubUserFromStaff(matches existing users by email; creates stub if new). PatchartistNameonto the user record (set if not already set; do not overwrite). withTransaction → ReservationCreationService.commitReservation({ paymentMethod: 'FREE', allowFreeContext: 'mms-comp', userId, resourceId, startTime, endTime, ... }).after(...)side-effects: Acuity create, confirmation email, setup-link email for new stubs.- 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 viagrepfor hourly resource fetches. - Customer-facing booking endpoint (
POST /api/reservations) rejects when target resourceisPubliclyVisible: 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 byisMmsCompResource(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 fromMMS_COMP.resourceIds).
Frontend gate
The booking modal is free-only in comp mode:
/lp/make-music-salemrenders<HourlyBookingWithPicker compMode locationPreset={MG10_CHERRY_CITY} />- Room picker fetches from
/api/mms-comp/resources(not the normal location-resources endpoint) HourlyBookingContentincompMode:- Hides
PaymentMethodSelectorand the cart $ total (shows "Free" tag) - CTA label = Book free
- Posts to
/api/mms-comp/reservations UserInfoFormincompModeshows theartistNamefield (required). Prefilled when authed from session.
Captured artist data
| Field | Required | Storage |
|---|---|---|
| First name | ✓ | User.firstName (existing) |
| Last name | ✓ | User.lastName (existing) |
| ✓ | 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):
- Aaron + CM pick N physical rooms at MG10 Cherry City that won't have an active monthly tenant Jun 15–21.
- 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). - On each: set
name(e.g., "MMS Hourly 1/2/3"),hourlyPriceCents: 0,isPubliclyVisible: false, validacuityCalendarId,peakHoursStart/Endif used, location timezone. - In Acuity: block each room calendar outside the comp dates (2026-06-15 → 2026-06-21).
- 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.
- Drop the new resource UUIDs into
MMS_COMP.resourceIds. Commit + deploy. - Set
NEXT_PUBLIC_MMS_COMP_TOKEN+NEXT_PUBLIC_MG10_LOCATION_IDin Vercel; share the/comp/<token>URL with the festival's artist roster. - Smoke-test: book a comp slot end-to-end as a fresh email.
After window closes (on or after 2026-06-22):
- LP automatically swaps to post-event content via the hardcoded date constant in
page.tsx. - Either soft-delete or
status: INACTIVE+isPubliclyVisible: falsethe comp resources (keeps the audit trail; they're already hidden from public listings). - Empty
MMS_COMP.resourceIdsand deleteapps/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).validatePaymentMethodwith eachallowFreeContextvalue + default. - API integration:
POST /api/mms-comp/reservationswith 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/reservationsrejects them;GET /api/mms-comp/resourcesreturns them. - Frontend unit:
HourlyBookingContentincompModedoesn't renderPaymentMethodSelector, posts to/api/mms-comp/reservations, shows "Free" not a money total.UserInfoFormshowsartistNamefield. - 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.