Skip to content

Lead Market Attribution (resolve city to location on inquiries)

Status: spec (ready to build) Owner: Aaron Related: marketing-scorecard-attribution · analytics.cold_start_scorecard · apps/api/src/app/api/contact/route.ts · apps/api/src/app/api/leads/email-capture/route.ts · apps/api/src/services/contact/InquiryService.ts

Problem

The cold-start scorecard grids the funnel by location_id (per location/month). Some front-of-funnel inquiries land with location_id = null, so they can't be read per market even when we know the market. Verified 2026-06-25: a Google-attributed inquiry in the last 30 days had no location, and the on-site fills we trace are only as market-attributable as location_id lets them be.

This is not a missing-capture problem. We already collect the signal; we just don't resolve it:

  • leads/email-capture requires locationId (404s without one), so it always carries a location. No gap here.
  • contact (the studio-interest / general contact form) collects city and locationIds, stores city in metadata, and even does location.findMany({ where: { city } }) to route to the community manager, but it only sets inquiry.location_id when exactly one location is explicitly selected (locationIds?.length === 1 ? locationIds[0] : null, route.ts:205). A submission with a city but no explicit single location keeps location_id = null, with the market sitting unused in metadata.city.

So a "Salem" studio-interest inquiry with no specific studio chosen is invisible to the per-location scorecard, even though we know it's Salem.

Goal

Every front-of-funnel inquiry carries its market when the market is unambiguous, using the signal already captured (page context and the city field). No geo-IP.

Non-goals

  • Geo-IP. It reports device location, not intent, and is unreliable for the one cut that matters (Salem vs Portland, same state, ~45 min apart, heavy mobile/carrier IPs). Page/explicit signals beat it. Geo-IP only ever as a last-resort, low-confidence fallback, and not in this scope.
  • Forcing a market onto account-level conversions (credit purchases, promo redemptions). Those are not market actions at purchase time; attribute them at the point of use (the booking's location), not by tagging the purchase.
  • Multi-location-city precision. Portland has 9 locations, so a "Portland" city can't resolve to one location_id. That's fine: Portland is stabilized, not a cold-start market. Keep city in metadata for market-level reads; do not guess a single location.

Design

WS1 — Resolve unambiguous city to location_id on the contact route (the fix)

In apps/api/src/app/api/contact/route.ts, when no single explicit location is provided but a city is, resolve the city to a location: if the city maps to exactly one active (deletedAt: null) location, set submissionData.locationId to it. If it maps to zero or many, leave location_id null (the existing behavior) and keep city in metadata.

  • Reuse the city lookup the route already performs for CM routing (location.findMany({ where: { city, deletedAt: null } })); it currently selects only the community manager, so add id to the select and set locationId when the result length is exactly 1.
  • Single-location metros resolve automatically: today Salem (Cherry City / MG10); tomorrow St. Louis and any new single-building market. Portland stays null by design (9 locations).
  • Leave the locationIds?.length === 1 branch unchanged (an explicit single selection still wins). City resolution is only the fallback when no single explicit location is set.

Acceptance: a studio-interest / contact submission with city = "Salem" and no explicit location produces an inquiry with location_id = MG10's id; a city = "Portland" submission keeps location_id = null and metadata.city = "Portland"; an explicit single-location submission is unchanged; email-capture is untouched. Unit/integration test in contact/route.test.ts covers the single-location-city resolve and the multi-location-city no-op.

WS2 — Keep market readable for multi-location metros (light, mostly verification)

city is already written to metadata on the contact route; confirm it is set on every studio-interest/contact path so market-level analysis is possible even when location_id is null. No new column. If per-market reads for multi-location metros ever matter, expose metadata.city as a dimension in analytics then; defer until there's a cold-start market with multiple locations (there is none today).

Acceptance: every contact-form inquiry with a known city has metadata.city set; no schema change.

Signal priority (the rule, for any future form work)

  1. Page / route context — the market-specific landing page or route the form was submitted from. Deterministic, free, intent-based. Preferred.
  2. Explicit fieldlocationIds (single) or city. Already collected on these forms.
  3. Campaign / UTM — market-specific campaigns carry it (e.g. Google Salem search); broad campaigns (PMax) do not.
  4. Geo-IP — last resort only, flagged low-confidence. Not in scope here.

Risks

  • City must match a location's city value. The route matches on equality, so the form's city must come from a known set (it already feeds the same lookup for CM routing). Free-text or mismatched city silently fails to resolve, same as today, no regression.
  • A city gaining a second location would stop resolving (count > 1 → null). That's correct behavior, not a bug; revisit market modeling if/when a second cold-start building opens in one city.
  • Do not retro-backfill historical null-location inquiries from metadata as part of this change; if useful, that's a separate one-off analytics backfill (resolve metadata.city to a single location for past rows), not a code path.