Comp Reservations — Generic Free-Booking Primitive
A single public endpoint and Offer-row-driven configuration for any zero-cost reservation campaign. Replaces the one-off mms-comp pattern with a reusable primitive: marketers (or engineers) create an Offer of type=FREE_BOOKING, and the same endpoint, validation, stub-user, and tracking machinery handle every comp use case. First two campaigns on the new primitive: MMS Salem 2026 (migrated from the existing dedicated endpoint) and Salem First-Hour-Free (new, supports the Phase-1 paid Meta funnel rebuild — see paid-meta.md for context).
What this is (and is not)
- Is: a data-driven comp primitive. New comp campaign = new
Offerrow + a marketing LP that calls the same endpoint. No code change per campaign. - Is: the back-end for any free-hour, free-tour, partner-comp, refer-a-friend, or festival-comp marketing motion we run in 2026+.
- Is not: a credit-grant flow (that's
type=CREDITviaCreditRedemptionService— unchanged). - Is not: a Stripe coupon, discount, or money-applied-to-real-payment path. Comp =
paymentMethod: 'FREE', $0 charged, no Stripe touch. - Replaces:
/api/mms-comp/reservations,services/scheduling/mms-comp.ts(MMS_COMP.resourceIdshardcoded allowlist),AllowFreeContext = 'mms-comp'. All migrated.
Architecture in one breath
Offer model carries the eligibility config (resource scope, location scope, max duration, per-user redemption cap, validity window). A single public endpoint loads the Offer by code, validates the request against the Offer's config, resolves a stub user by email, and writes the reservation with paymentMethod: 'FREE' + allowFreeContext: 'comp'. PromoRedemption enforces 1-per-user when configured; multiple-per-user is achieved by setting maxRedemptionsPerUser = null.
An offer can additionally set requiresVerifiedPhone, which layers a cross-identity redemption cap on top of the per-user one: PromoRedemption.phone (unique per offer) caps redemptions per phone number regardless of which stub/member account redeems, closing the free-email-address loophole in the per-user cap alone. Proving phone possession for unauthenticated guests, and the redemption-cap mechanics, are a separate capability — see ADR-024 and features/guest-phone-verification/spec.md. CHERRY_CITY_FIRST_PRACTICE is the first offer with the flag on.
Locked decisions
| Decision | Value |
|---|---|
| Endpoint path | POST /api/comp/reservations (public, no auth) |
| Identity model | Stub user resolved by email (resolveStubUserFromStaff from existing infra) |
| Required fields | email, offerCode, resourceId, startTime, endTime, timezone, appointmentTypeId |
| Optional fields | firstName, lastName, phone, artistName, smsMarketingConsent, website (honeypot), attribution |
| Payment | paymentMethod: 'FREE' + new allowFreeContext: 'comp' |
| Dedup primitive | PromoRedemption (@@unique([userId, offerId])) plus, when requiresVerifiedPhone is set, @@unique([offerId, phone]) — the cross-identity cap (ADR-024) |
| Per-user cap | Offer.maxRedemptionsPerUser — null = unlimited (MMS pattern), 1 = first-hour pattern |
| Phone gate | Offer.requiresVerifiedPhone — guests prove possession via OTP (phoneVerificationId), members pass via their mirrored verified phone; see ADR-024 |
| Duration cap | Offer.maxDurationMinutes — null = no cap |
| Resource scope | Offer.allowedResourceIds (null = any) AND Offer.allowedResourceTypes (null = any matching STUDIO_HOURLY by default) |
| Location scope | Offer.allowedLocationIds (null = any) |
| Validity window | Offer.startsAt / Offer.endsAt — existing fields, unchanged |
| Already-redeemed UX | 409 CONFLICT with { error: 'ALREADY_REDEEMED', message }. Frontend triggers friendly modal, copy says "one per person" (accurate now that the cap can span accounts via phone). |
| Post-commit side-effects | Marketing consent record + account-setup-link email (same as MMS today) |
| Tracking | hourly-booked PostHog event w/ isComp: true, offerCode; Meta CAPI Lead (not Purchase) — value=0 Purchase events are the documented noise per paid-meta.md |
Schema delta
enum OfferType {
CREDIT
PREPAID_MARKETING
SUBSCRIPTION_UPSELL
REFERRAL
INSURANCE_UPSELL
FREE_BOOKING
}
model Offer {
// ... existing fields unchanged
type OfferType @default(CREDIT) // was: String
// FREE_BOOKING-specific (empty array / null = no restriction; ignored for non-FREE_BOOKING types)
maxDurationMinutes Int? @map("max_duration_minutes")
maxRedemptionsPerUser Int? @map("max_redemptions_per_user") // null = unlimited, 1 = first-hour pattern
allowedResourceIds String[] @default([]) @map("allowed_resource_ids") @db.Uuid
allowedResourceTypes ResourceType[] @default([]) @map("allowed_resource_types")
allowedLocationIds String[] @default([]) @map("allowed_location_ids") @db.Uuid
}
Migration is backward-compatible. The first 5 enum values mirror the values already in offers.type (CREDIT, PREPAID_MARKETING, SUBSCRIPTION_UPSELL, REFERRAL, INSURANCE_UPSELL); the migration uses ALTER COLUMN ... USING so any row with an unknown value would loudly fail rather than silently downgrade. Array fields use empty-array as the "no restriction" sentinel rather than null (Prisma doesn't support nullable arrays).
A later migration (ADR-024) adds Offer.requiresVerifiedPhone (default false), PromoRedemption.phone (nullable, @@unique([offerId, phone])), and a standalone PhoneVerification model for possession proofs. See features/guest-phone-verification/spec.md for that schema in full.
Validation layer
// apps/api/src/services/scheduling/ReservationValidationService.ts
export type AllowFreeContext = 'tour' | 'dedicated' | 'comp' // was: ... | 'mms-comp'
validatePaymentMethod allowlist check unchanged in structure; 'mms-comp' literal becomes 'comp'.
Endpoint
POST /api/comp/reservations
Request body (Zod schema):
{
offerCode: string // required
email: string // required (used to resolve/create stub user)
resourceId: string // required
startTime: string // ISO
endTime: string // ISO
timezone: string
appointmentTypeId: string
firstName?: string
lastName?: string
phone?: string
artistName?: string
smsMarketingConsent?: boolean
website?: string // honeypot — silently 201 if present
attribution?: AttributionSchema
}
Server flow (mirrors mms-comp route, with Offer-driven gates):
- Honeypot: if
websitepresent → return201 { reservationId: 'declined' }, no side effects - Load
Offerbycode; requiretype='FREE_BOOKING',deletedAt: null, withinstartsAt/endsAtwindow (or null on either) - Validate resource:
- Load by
resourceId, requiredeletedAt: null - Require
resource.isPubliclyVisibleORresourceIdlisted onoffer.allowedResourceIds(hidden resources, e.g. lockout rooms temporarily flipped to hourly for MMS, are only bookable when explicitly allowlisted) - If
offer.allowedResourceIdsis non-empty: require membership - If
offer.allowedResourceTypesis non-empty: requireresource.resourceTypein list - If
offer.allowedLocationIdsis non-empty: requireresource.locationIdin list - Validate duration:
endTime - startTime <= offer.maxDurationMinutes * 60_000(skip if null) - Validate timing:
startTime > now,endTime > startTime - Resolve stub user via
resolveStubUserFromStaff({ firstName, lastName, email, phone }) - Inside tx:
ensureStubUserRow(stub, tx)- If
offer.maxRedemptionsPerUser != null: checkPromoRedemption.findUnique({ userId_offerId }); if exists → throwAppError('ALREADY_REDEEMED', ErrorType.CONFLICT, 409) ReservationCreationService.commitReservation({ ... paymentMethod: 'FREE' }, tx, { allowFreeContext: 'comp' })- If
offer.maxRedemptionsPerUser != null:tx.promoRedemption.create({ data: { userId: stub.userId, offerId: offer.id } }) - Update
User.artistNameif provided and currently null (MMS parity) - After commit (via
after()): ConsentService.recordMarketingOptIns(...)ReservationSideEffectService.executeCreationSideEffects(...)- If new stub user:
sendAccountSetupForUser(...) - Write
conversion_attributionsrow withsourceType: 'INQUIRY'andutmCampaign/utmContentfromattributionpayload
Error responses
| Code | Reason |
|---|---|
| 400 | Invalid input, expired offer, resource not allowed, duration over cap |
| 404 | Offer code unknown, resource not found |
| 409 | ALREADY_REDEEMED — user has used this offer's quota; or Acuity slot taken |
| 500 | Internal — captured to Sentry; auth user row rolled back via rollbackCreatedAuthUser |
Initial Offer rows (seeded as part of the migration)
| code | type | allowedResourceIds | allowedResourceTypes | allowedLocationIds | maxRedemptionsPerUser | maxDurationMinutes | startsAt | endsAt |
|---|---|---|---|---|---|---|---|---|
MMS_SALEM_2026 |
FREE_BOOKING | [<3 UUIDs>] (from existing MMS_COMP.resourceIds) |
null | null | null (unlimited) | null | 2026-06-15T07:00Z | 2026-06-22T07:00Z |
CHERRY_CITY_FIRST_PRACTICE |
FREE_BOOKING | null | [STUDIO_HOURLY] |
[<MG10 Cherry City UUID>] |
1 | 240 | now | null |
Renamed 2026-05 from
CHERRY_CITY_FREE_HOUR(60-min cap) →CHERRY_CITY_FIRST_PRACTICE(240-min cap) to support the "First practice is on us — up to 4 hours" positioning. Old offer row is soft-deleted by the seed script; old LP slug 301s to the new one.
Seed via a one-off script apps/api/scripts/marketing/seed-comp-offers.ts. Run on prod once, post-migration.
Frontend
Booking flow
Comp booking runs on the /book page (scheduler refactor, PR #880): /book?type=hourly&offer=<code>. BookClient reads the offer query param and passes it as compOfferCode into BookingFlow, which drives comp mode through HourlyBranch. The pre-refactor modal stack (HourlyBookingWithPicker compMode, HourlyBookingContent, ModalWrapper) is deleted.
In comp mode:
- Public page, no login required; with no
resourceparam the guest picks a studio in the nativeStudioStepgrid, which shows the studios' location (name, city/state, address) above the picker — comp links arrive from anywhere with no market context - Details form requires
firstName + lastName + email; phone optional; artistName collected only when the offer'sCOMP_OFFER_PRESENTATIONentry (apps/web/src/lib/comp-offers.ts) setscollectArtistName(MMS only) - No payment step, no cart $ total, no per-slot price chips, no peak/off-peak hint
- Durations are filtered to
offer.maxDurationMinutes(currently 240 forCHERRY_CITY_FIRST_PRACTICE) viauseAppointmentTypes - Submit POSTs to
/api/comp/reservationswith the form data +offerCode+ stored UTM attribution (useHourlyCheckout) - Studio list comes from
/api/comp/resources?offerCode=<code>instead of the public resources endpoint (useHourlyStudios) - When the offer has
requiresVerifiedPhone(as reported by/api/comp/resources), phone becomes required and submit interposes an OTP code step before the booking call; see features/guest-phone-verification/spec.md for the possession-proof flow, member-routing interstitial, andphoneVerificationIdhandoff - On
409 ALREADY_REDEEMED: opens<AlreadyRedeemedModal />— copy says "one per person" (not "one per email"), since the cap can span accounts sharing a phone; CTA falls through to normal paid hourly booking - On success, renders the shared
ReservationConfirmationClient(the same card authed/paid bookings use) instead of a hand-rolled comp-only panel — fed from in-memory flow state since guests can't hit the session-gated/reservations/confirmationroute.freePaymentLabel: 'Promotional offer'replaces the subscription-style amount copy; guests see the account-setup notice in theaccountSetupNoticeslot (same pattern as lockout invitations) instead of the auth-only footer buttons, and gain Add to Calendar - Open item: the old modal's
CmHelpFooter(tel: link to the location CM) has no/booksuccessor;useHourlyResourcestill fetches the CM fields but nothing renders them
Markets / LPs
| LP route | Offer code | Notes |
|---|---|---|
/lp/mms2026 |
MMS_SALEM_2026 |
Consolidated MMS 2026 LP (restructured from /lp/make-music-salem in #710). Free Practice Week CTA navigates to /book?type=hourly&offer=MMS_SALEM_2026; Green Room registration is the primary CTA. Post-event teardown is date-gated in page.tsx. |
/lp/salem/free-first-practice (moved 2026-07 from /lp/cherry-city-first-practice, itself renamed from /lp/cherry-city-free-hour — both 301) |
CHERRY_CITY_FIRST_PRACTICE |
Dedicated offer LP nested under the market slug, driven by MARKET_COMP_OFFERS (see free-first-practice). Content-first hero, "Claim your free practice" CTA navigates to /book?type=hourly&offer=<code>. Market-LP hero integration shelved. |
Tracking
hourly-bookedPostHog event: extended withisComp: boolean+offerCode: stringproperties (defaults false / empty) so dashboards can filter comp bookings.comp_bookedPostHog event: distinct event fired from the comp checkout path only — carriesreservationId,resourceId,durationMinutes,startTime,isNewUser,offerCode. (Renamed from the legacymms_comp_bookedevent.)- Meta CAPI: fire Lead (not Purchase) for comp bookings —
apps/api/src/lib/meta/track-comp-lead.ts, called from the endpoint after commit.value: 0,content_name: 'comp-hourly',content_category: offer.code.
Email chain for stub users
A returning-but-never-claimed user can't open /account/reservations/<id> directly — they have no password and the page is auth-gated. To handle this:
- After a successful comp booking, the endpoint detects unclaimed users via
isStubUser(user)(a check againstinvitedAt + setupCompletedAt + lastLoginAtfrom@mg/shared-utils). This covers both brand-new users and returning users from prior comps who never claimed. - For unclaimed users only, the endpoint generates two one-time Supabase recovery tokens. One overrides the
viewReservationUrlOverridein the booking confirmation email's "view reservation" CTA; the other powers the account-setup email's primary CTA. Both deep-link through/auth/confirm?token_hash=…&type=recovery&next=/auth/setup-account?returnTo=/account/reservations/<id>. - Fully claimed users (have
setupCompletedAtorlastLoginAt) get the standard auth-walled URL and no setup email. They log in normally to view the reservation.
Magic-link-for-claimed-users decision (intentional scope choice): generating a recovery token for a fully claimed user would let them one-click view the reservation, but a booking-confirmation-email interception would then give an attacker full access to an account with payment methods + history. The current scope (magic link only for unclaimed stubs) trades a small login-friction cost for a meaningfully smaller blast radius. Reconsider if user friction shows up in support tickets.
Migration plan
Order matters — keep MMS bookable throughout. MMS is 2026-06-21; this work ships before then but does not block MMS.
- Schema migration (db-migration skill). New enum value, new columns, all nullable/backward-compatible. CREDIT path untouched.
Offer.typedata migration. Existing stringtype='CREDIT'values continue to work; new enum is a superset. Run viaprisma migrate diffagainst the shadow DB.AllowFreeContextenum +validatePaymentMethodupdate. Adds'comp', keeps'mms-comp'temporarily as an alias for one deploy window.- New
/api/comp/reservationsendpoint built alongside existing/api/mms-comp/reservations. Both live in prod simultaneously. - Seed
MMS_SALEM_2026+CHERRY_CITY_FIRST_PRACTICEOffer rows via one-off script. /lp/make-music-salemLP updated to call new endpoint withcompMode + compOfferCode="MMS_SALEM_2026" + collectArtistName. Smoke-test in staging w/ live Acuity calendar./lp/cherry-city-first-practiceLP shipped withcompMode + compOfferCode="CHERRY_CITY_FIRST_PRACTICE"(no artist name). New ad creative cuts over (Use Existing Post mechanic per Lesson #6).- Form-trim PR (parallel, no comp dependency):
UserInfoForm.tsx+TourRequestForm.tsxmake phone + last name optional. - Deprecation sweep (one deploy window after #6 verified): delete
/api/mms-comp/reservations,services/scheduling/mms-comp.ts,'mms-comp'value inAllowFreeContext. Removemms-comp/reservationsroute file. Update tests. - Email nurture sequence (separate PR, after #1–#9 in prod): Resend templates triggered by
Reservationcreation w/allowFreeContext: 'comp'. 5-touch sequence perpaid-meta.mdplan.
Open items
- Conversion attribution
sourceType. Comp bookings currently fitINQUIRYsemantically (top-of-funnel lead). If reporting needs cleaner separation, add'COMP_BOOKING'toConversionSourceTypeenum in a follow-up. Not blocking. - Resend sequence content. Spec'd separately. Doesn't block this primitive shipping.
AlreadyRedeemedModal. Visual + copy. Spec'd in the LP rebuild design pass.
Related
- ADR-024 and features/guest-phone-verification/spec.md —
requiresVerifiedPhonegate, possession-proof endpoints, cross-identity redemption cap docs/marketing/paid-meta.md— Phase-1 funnel rebuild contextdocs/features/make-music-salem-comp/spec.md— original MMS spec being subsumeddocs/features/cherry-city-ppc-prospecting/spec.md— paid Meta TOFU contextapps/api/src/services/scheduling/ReservationValidationService.ts—AllowFreeContextlives hereapps/api/src/app/api/mms-comp/reservations/route.ts— template for the new endpointapps/api/src/services/auth/createStubUserFromStaff.ts— stub user infra