Offers Targeting + Web App Wiring โ Design โ
Date: 2026-04-30 Scope: services/api/merchants/, apps/web/, apps/admin/, packages/shared/, tooling/scripts/Closes: #139, #321, #347Builds on: 2026-04-25-offers-crud-admin-design.md (sub-project #1)
Goal โ
Wire real merchant offers from Firestore into the consumer web app with server-side targeting (geofence + audience), consume all four placement types (hero, inline, chat, feed) on the user-facing surfaces, and clean up the stub-era plumbing left behind from sub-project #1.
After this change:
- The web app reads offers from a new public-auth endpoint on the merchants-api, not from a mock generator.
- Offers are filtered server-side by geofence and audience before reaching the client.
chatandfeedplacements render inChat.jsxandRecentActivityView.jsx(AdSlotcomponents are already built; they're just not yet wired).AdSlot,OfferCards, andofferNormalizermove to@lantern/sharedso admin and web stop maintaining duplicates.- The stub
OfferFormandsetMerchantFakeOffersplumbing in the web app are removed. - The merchants-api openapi.json correctly documents all routes (existing and new).
- A separate venue-picker bug surfaced during scoping is fixed.
Why now โ
Sub-project #1 (2026-04-25-offers-crud-admin-design.md:296-303) explicitly deferred this work as sub-project #2 with three named items: wire web app to real API, extract shared components, implement targeting enforcement. All three are still outstanding and #139 is the canonical tracking issue. Doing this now also clears spec drift in the merchants-api openapi.json (discovered while scoping; only /health was documented even though five offer routes are live) and a venue-picker bug that's blocking merchant onboarding for venues whose Firestore document IDs sort beyond the picker's hard limit(500) cap.
Architecture โ
1. New public-auth route on merchants-api โ
Add a sibling route to the existing merchant-scoped routes. The current service mounts everything under /merchants/:merchantId with both verifyFirebaseToken and requireMerchantAccess middleware. The new route runs only verifyFirebaseToken โ any signed-in app user can read offers nearby โ but must not be reachable through the merchant-scoped chain.
Updated mount in services/api/merchants/src/index.js:
// Public read route โ auth only, no merchant scoping
app.use('/offers', verifyFirebaseToken, publicOffersRouter)
// Existing merchant-scoped CRUD
app.use('/merchants/:merchantId', verifyFirebaseToken, requireMerchantAccess, offersRouter)New route file: services/api/merchants/src/routes/publicOffers.js
| Method | Path | Purpose |
|---|---|---|
GET | /offers/active | List active offers near the user, filtered by audience |
Query parameters:
| Param | Type | Required | Notes |
|---|---|---|---|
lat | number | yes | User's latitude |
lng | number | yes | User's longitude |
placement | string | no | Filter by placement (hero/inline/chat/feed); omitted โ all |
Response shape:
{
"offers": [
{
"id": "abc123",
"merchantId": "...",
"venueId": "...",
"title": "20% off brunch",
"description": "Show this offer...",
"placement": "hero",
"targetAudience": "nearby",
"radius": 800,
"expiresAt": "2026-05-15T00:00:00.000Z",
"showDisclaimerWhileSuppliesLast": false,
"venue": { "id": "...", "name": "...", "lat": 32.7, "lng": -117.1, "category": "restaurant", "lanternCount": 3 },
"distanceMeters": 412
}
],
"total": 1
}Per-offer venue is hydrated server-side from the venues collection so the client doesn't need a second roundtrip. distanceMeters is the computed haversine distance for client-side sorting/display. lanternCount is read from the venue document's existing aggregate counter.
Response excludes:
merchantIdis included (useful for analytics and future surfaces).budget,per_user_limit,createdByare not included โ internal fields not meant for the consumer surface.
2. Server-side filtering โ
The endpoint computes the visible offer set in this order:
- Status filter:
status === 'active'ANDexpiresAt > now. - Geofence filter: haversine distance from
(lat, lng)tovenue.{lat,lng}โคoffer.radius. - Audience filter: evaluate per-offer (see below).
- Optional placement filter: if
?placement=โฆprovided, retain only matching offers.
The filter pipeline is server-side because:
- It centralizes audience semantics (one place to evolve).
- It avoids leaking inactive/expired offers and other merchants' draft data over the wire.
- Future redemption logic (per-user-limit, fraud) will live in the same service โ keeping reads here matches that direction.
Implementation note: for the pilot scale (~hundreds of active offers in San Diego), a single Firestore query for status == 'active' followed by in-process geofence + audience filtering is acceptable. A geohash index on offer venues is out of scope; revisit if active-offer count exceeds ~5,000.
3. Audience semantics โ
| Value | UI label | v1 server check |
|---|---|---|
nearby | Nearby Users (1.5mi) | Geofence only โ already enforced upstream by step 2 |
lantern | Active Lantern Holders | Geofence AND user has an active lantern at offer.venueId (queries lanterns where userId == req.user.uid AND venueId == offer.venueId AND status == 'active') |
new | New Users | Geofence AND users/{uid}.createdAt > now โ 7 days (threshold is a constant in audience.js, can be tuned per-pilot) |
frequent | Frequent Visitors | v1 fallback: enforced as nearby. Proper enforcement requires a per-(user, venue) lantern counter that doesn't exist today (the lanterns collection has a 48-hour TTL, so it can't answer "lit โฅ3 times historically"). Filed as a follow-up issue (see Out of Scope). |
Why frequent is deferred to a follow-up: building a counter requires either a Cloud Function trigger on lanterns.onCreate to increment users/{uid}.lanternCounts[venueId], or a denormalized aggregate. Both expand scope meaningfully (deploy job, backfill migration, ongoing maintenance). The scope-discipline call is to ship nearby/lantern/new enforcement now โ covering the audiences that map cleanly to existing data โ and track the frequent counter separately so it gets the design attention it deserves.
frequent-audience offers will still be visible (treated like nearby); they just won't be filtered to repeat visitors yet. Merchant-facing copy doesn't promise "only frequent users will see this" so this is a tightening that won't break expectations. Documented in the audience helper file's JSDoc.
4. Audience filter helper โ
New module: services/api/merchants/src/lib/audience.js
/**
* Returns true if the user qualifies for an offer's targetAudience.
* Geofence is enforced upstream; this helper covers identity-based filters.
*
* @param {string} audience - 'nearby' | 'lantern' | 'frequent' | 'new'
* @param {Object} userContext - { uid, accountAgeDays, hasActiveLanternAtVenue }
* @param {Object} offer - The offer document
* @returns {boolean}
*/
export function passesAudience(audience, userContext, offer) { ... }The endpoint pre-computes userContext once per request:
accountAgeDays: derived fromusers/{uid}.createdAthasActiveLanternAtVenue: a single batched query for all(uid, offer.venueId)pairs in the candidate set
The batched lantern query avoids N+1 reads across offers.
5. Web app: replace offerService.js with API consumer โ
apps/web/src/lib/offerService.js is rewritten as a thin client.
Removed:
setMerchantFakeOffers(and the singleton it manages)- The category-matched mock offer generator
- Random
lanternCountgeneration - Hardcoded
mockOffers.slice(0, 3)logic
New:
fetchActiveOffers({ lat, lng, placement? })โ callsGET /offers/activeviaapiClient.authRequest, returns the offer list directlygetHeroOffer(offers)โ picks highest-priority offer withplacement === 'hero'from the fetched list (priority ordering preserved if the field exists; falls back to expiry-soonest)getOffersByVenue(offers)โ groups offers byvenueIdfor inline renderinggetVenueOffer(venueId, offers)โ returns the highest-priority offer for a specific venue
The functions take an already-fetched offer list as input rather than refetching, so the caller controls when network requests happen.
Caller changes:
| File | Change |
|---|---|
| apps/web/src/components/dashboard/HomeView.jsx | Replace getActiveOffers(venues) mock call with a useEffect that calls fetchActiveOffers() on mount and on user-location change > 100m. Memoize heroOffer and offersByVenue from the fetched list. |
| apps/web/src/components/dashboard/VenueView.jsx | Pull from the same fetched offer list (lifted to a parent or accessed via a small offers context). |
| apps/web/src/screens/merchant/MerchantDashboard.jsx | Remove the setMerchantFakeOffers(fakeAds) call (line 243). |
The fetch happens once per dashboard mount with subsequent refetches gated on:
- User location moved > 100m (if a location-watcher is already in place; otherwise this gate is dropped and we rely on visibility refetch only โ verify at implementation time)
- Tab returns to foreground (
visibilitychangeevent)
No onSnapshot real-time listener โ offers don't change minute-to-minute and onSnapshot would require client-readable Firestore rules that we'd rather avoid.
6. Wire chat and feed placements โ
ChatOfferPill and FeedOfferCard exist in apps/web/src/components/dashboard/OfferCards.jsx (lines 19, 40), shipped by PR #322, but unused on consumer surfaces. This work imports them where they belong.
Chat surface:
- File: identify the chat screen (likely
apps/web/src/screens/chats/Chat.jsxor similar; locate at implementation time). - Behavior: when a user is in a chat tied to a specific venue (via
chat.venueIdor the connection's anchor venue), render anAdSlotfor any active offer at that venue withplacement === 'chat'. One offer max โ pick the highest priority. - Position: top of the chat thread, above the message list, with sticky positioning matching the existing chat header pattern.
- Empty case: render nothing (no slot reservation).
Feed surface:
- File: apps/web/src/components/dashboard/RecentActivityView.jsx
- Behavior: when activity rows are tied to a venue with an active
placement === 'feed'offer, interleave aFeedOfferCardafter the activity row. At most oneFeedOfferCardper scroll session per venue (track via aSetof seen venueIds in component state). - Empty case: render nothing.
Both surfaces fetch from the same offer list returned by fetchActiveOffers() โ no separate API calls. The placement query param is used when the surface only needs one type, to keep response payloads tight.
7. Extract to @lantern/shared โ
Three modules currently duplicated between apps/admin/ and apps/web/ move to packages/shared/:
| Source A (admin) | Source B (web) | New location |
|---|---|---|
apps/admin/src/components/offers/OfferCards.jsx (admin-restyled copy) | apps/web/src/components/dashboard/OfferCards.jsx | packages/shared/components/OfferCards.jsx |
apps/admin/src/components/offers/AdSlot.jsx | apps/web/src/components/dashboard/AdSlot.jsx | packages/shared/components/AdSlot.jsx |
apps/admin/src/shared/lib/offerNormalizer.js | apps/web/src/lib/offerNormalizer.js | packages/shared/lib/offerNormalizer.js |
Styling reconciliation: the admin copy uses the admin portal's CSS variables; the web copy uses Tailwind. Resolution: the shared component accepts a theme prop or uses CSS variable names that both apps define. Concrete approach picked at implementation time after comparing the two copies side-by-side; if reconciliation is non-trivial, fall back to keeping admin and web copies separate and only extracting offerNormalizer.js (pure logic, no styles) for now. A pragmatic note in the implementation plan calls this out.
Storybook: existing stories in apps/web/src/screens/dashboard/ (HeroOfferCard.stories.jsx, OfferPill.stories.jsx) are updated to import from the shared package.
8. Stub cleanup in web app โ
- Delete
apps/web/src/screens/merchant/OfferForm.jsx(functionality lives in the admin Offers tab now per sub-project #1). - Delete
apps/web/src/screens/merchant/OfferForm.stories.jsx. - Remove the
setMerchantFakeOffersexport fromofferService.jsand its only caller inMerchantDashboard.jsx. - Remove the merchant-form route from
apps/web/src/App.jsxif it exists (e.g.,#/merchant/new).
9. Venue picker bug fix โ
apps/admin/src/components/venues/AdminVenuePicker.jsx:41 does qLimit(500) with no orderBy, so any venue past doc-ID position 500 is invisible (e.g., "Swan Bar"). Fix:
- Schema: add
nameLower(string, lowercased) to all venue documents. - Backfill: one-shot script
tooling/scripts/backfill-venue-name-lower.jsthat reads all venues, setsnameLoweron documents missing it. Idempotent. - Write path: every code path that writes a venue's
namealso writesnameLower. Inventory and update:apps/web/src/lib/seedVenues.jsservices/api/venues/src/routes/import.jsapps/web/src/lib/osmImportService.js- Any direct
addDoc(collection(db, 'venues'))call (search at implementation time)
- Picker query rewrite: replace the
limit(500)fetch-all-and-filter approach with a Firestore range query:jsconst q = query( collection(db, 'venues'), orderBy('nameLower'), startAt(searchTerm.toLowerCase()), endAt(searchTerm.toLowerCase() + '\uf8ff'), // High Unicode codepoint bounds the prefix range qLimit(50) ) - Empty-search behavior: preserve current "show nothing until user types" โ Firestore prefix query naturally returns nothing for empty input.
The denormalization adds maintenance cost (two fields to keep in sync) but unblocks scaling to thousands of venues without a search service. CLAUDE.md gets a one-line note in the venue-onboarding section reminding writers to update nameLower alongside name.
10. Merchants-api openapi.json populated โ
Add paths entries to services/api/merchants/openapi.json for all six routes that exist or are being added:
| Path | Method |
|---|---|
/health | GET (already documented) |
/offers/active | GET (new โ public read) |
/merchants/{merchantId}/offers | GET, POST |
/merchants/{merchantId}/offers/{offerId} | GET, PUT, DELETE |
Schema definitions for the offer object, error responses, and the audience enum are added to components.schemas. The admin portal's API Reference page picks these up automatically via the registered service entry.
11. OpenAPI sync linter (fulfills #347) โ
New script: tooling/scripts/lint.openapi-sync.js
For every service registered in packages/shared/services/index.js:
- Load the service's
openapi.jsonand collect declared(method, path)pairs. - Walk the service's
src/index.jsforapp.use('<prefix>', router)mounts andsrc/routes/**forrouter.<method>('<subpath>', โฆ)declarations. Combine into full(method, fullPath)pairs. - Compare and fail with a per-direction report:
- Implemented but undocumented: routes in code missing from spec
- Documented but unimplemented: spec paths not served by code
- Path-parameter normalization:
:idand{id}are equivalent for the comparison.
Wiring into validation:
- Add a section to tooling/scripts/validate.js that runs the linter as part of the default
npm run validate. - Support
npm run validate -- --scope openapifor targeted runs.
Out of scope for this script: validating response shapes, status codes, or request bodies. Path ร method is the v1 contract.
CLAUDE.md "Linter Organization" section gets a one-line entry pointing at the new script.
Data flow โ
โโ Web app dashboard mount โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ useGeolocation โ { lat, lng } โ
โ โ โ
โ fetchActiveOffers({ lat, lng }) โ
โ โ HTTP GET /offers/active?lat=&lng= โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโ merchants-api: GET /offers/active โโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ verifyFirebaseToken โ req.user.uid โ
โ โ โ
โ Firestore: offers where status='active' AND expiresAt>now โ
โ โ โ
โ Hydrate venue data (batch get from venues by venueId) โ
โ โ โ
โ Filter: distance(user, venue) โค offer.radius โ
โ โ โ
โ Build userContext (accountAge, activeLanterns batch) โ
โ โ โ
โ Filter: passesAudience(offer.targetAudience, ctx, offer) โ
โ โ โ
โ Optional ?placement= filter โ
โ โ โ
โ Return [{ ...offer, venue, distanceMeters }, ...] โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโ Web app render โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ HomeView: โ
โ getHeroOffer(offers) โ <HeroOfferCard> โ
โ getOffersByVenue(offers) โ per-venue <OfferPill> โ
โ Chat.jsx (when in venue-anchored chat): โ
โ filter placement='chat' for this.venueId โ <AdSlot> โ
โ RecentActivityView: โ
โ interleave placement='feed' rows โ <FeedOfferCard> โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโError handling โ
| Case | Behavior |
|---|---|
Missing lat/lng query params | API returns 400 { error: 'INVALID_INPUT', message: 'lat and lng required' } |
| User signed out / token invalid | API returns 401; web app shows nothing (no offers panel) |
| API unreachable / 5xx | Web app caches the most recent successful response in memory; shows it for the rest of the session. Logs devLog.warn in development. No user-facing error toast โ offers are not critical UI. |
| Firestore query error inside the API | API returns 500 with a generic message; pino logs the actual error |
| User location unavailable (geolocation denied) | Web app does not call fetchActiveOffers โ no offers visible (consistent with the "outside the geofence = nothing" decision) |
| Empty offer set | API returns { offers: [], total: 0 }; web app renders no hero/inline/chat/feed slots |
Testing plan โ
Automated tests:
| Surface | Test file | Coverage |
|---|---|---|
| Public offers route | services/api/merchants/src/__tests__/publicOffers.test.js | Auth required, geofence filtering, each audience filter (nearby, lantern, new, frequent-as-nearby), placement filter, status/expiry exclusion, response shape, batched lantern query |
| Audience helper | services/api/merchants/src/lib/__tests__/audience.test.js | Each audience value, edge cases (account age = exactly 7 days, no lanterns, etc.) |
offerService.js | apps/web/src/lib/__tests__/offerService.test.js | fetchActiveOffers happy path, error fallback, getHeroOffer priority ordering, getOffersByVenue grouping, getVenueOffer per-venue lookup |
AdminVenuePicker | apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsx | Range query produces results past the old 500 cap, empty input shows nothing, prefix matching works |
| OpenAPI sync linter | tooling/scripts/__tests__/lint.openapi-sync.test.js | Detects undocumented route, detects unimplemented path, handles path-param normalization, exits non-zero on drift |
Manual verification:
- Seed an offer with
placement: 'hero',targetAudience: 'nearby',radius: 800against a known venue. Spoof location to within 800m โ hero card visible with real merchant data. Spoof outside โ nothing. - Repeat with each placement value, confirming each surface (HomeView hero, HomeView per-venue inline, Chat, RecentActivity) renders correctly.
- Repeat with each audience value:
lantern: light a lantern at the venue first, confirm offer appears; extinguish, confirm it disappears.new: test as a fresh account (createdAt < 7 days ago) and as an old account.frequent: confirm it currently behaves identically tonearby(v1 fallback) and that this is documented.
- In admin portal, search for "Swan Bar" โ appears in picker. Verify other previously-invisible venues now appear.
- In admin portal API Reference page, confirm all merchants-api routes are now documented and clickable.
- Run
npm run validate -- --scope openapiโ green. Manually break the openapi.json (delete a path) โ linter fails with clear report. - Confirm
apps/web/src/screens/merchant/OfferForm.jsxis gone and#/merchant/neweither redirects to admin or 404s gracefully. - Confirm storybook stories still render after the
@lantern/sharedextraction. npm run validatepasses for the full suite.
Files touched โ
New files โ
| Path | Responsibility |
|---|---|
services/api/merchants/src/routes/publicOffers.js | GET /offers/active route handler |
services/api/merchants/src/lib/audience.js | passesAudience helper + userContext builder |
services/api/merchants/src/__tests__/publicOffers.test.js | Route tests |
services/api/merchants/src/lib/__tests__/audience.test.js | Helper tests |
tooling/scripts/lint.openapi-sync.js | OpenAPI sync linter |
tooling/scripts/__tests__/lint.openapi-sync.test.js | Linter tests |
tooling/scripts/backfill-venue-name-lower.js | One-shot venue nameLower backfill |
packages/shared/components/OfferCards.jsx | Extracted from web/admin (or partial if styling reconciliation defers) |
packages/shared/components/AdSlot.jsx | Extracted from web/admin |
packages/shared/lib/offerNormalizer.js | Extracted from web/admin |
apps/web/src/lib/__tests__/offerService.test.js | Web-side fetch + selector tests |
apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsx | Picker tests after the fix |
Modified files โ
| Path | Change |
|---|---|
services/api/merchants/src/index.js | Mount publicOffersRouter under /offers with verifyFirebaseToken only |
services/api/merchants/openapi.json | Document all six routes + schemas |
services/api/merchants/src/routes/offers.js | (No logic change; possibly minor refactor to share the serializeOffer helper with the public route) |
apps/web/src/lib/offerService.js | Rewrite as API client; remove mock generator, setMerchantFakeOffers |
apps/web/src/components/dashboard/HomeView.jsx | Use fetchActiveOffers + useEffect instead of mock import |
apps/web/src/components/dashboard/VenueView.jsx | Use shared offer list (via prop or context) |
apps/web/src/components/dashboard/RecentActivityView.jsx | Wire feed placement |
apps/web/src/screens/chats/Chat.jsx (or actual path) | Wire chat placement |
apps/web/src/screens/merchant/MerchantDashboard.jsx | Remove setMerchantFakeOffers call |
apps/web/src/components/dashboard/OfferCards.jsx | Re-export from shared, or delete after extraction |
apps/web/src/components/dashboard/AdSlot.jsx | Same |
apps/web/src/lib/offerNormalizer.js | Re-export or delete |
apps/web/src/screens/dashboard/HeroOfferCard.stories.jsx | Update import path |
apps/web/src/screens/dashboard/OfferPill.stories.jsx | Update import path |
apps/admin/src/components/offers/OfferCards.jsx | Re-export from shared, or delete |
apps/admin/src/components/offers/AdSlot.jsx | Same |
apps/admin/src/shared/lib/offerNormalizer.js | Re-export or delete |
apps/admin/src/components/venues/AdminVenuePicker.jsx | Range query on nameLower, drop limit(500) fetch-all |
apps/web/src/lib/seedVenues.js | Write nameLower alongside name |
services/api/venues/src/routes/import.js | Same |
apps/web/src/lib/osmImportService.js | Same |
tooling/scripts/validate.js | Add openapi-sync section + --scope openapi support |
CLAUDE.md | One-line note in Linter Organization re: openapi-sync; one-line note in venue-onboarding re: nameLower |
packages/shared/package.json | Export new component paths |
Deleted files โ
| Path | Reason |
|---|---|
apps/web/src/screens/merchant/OfferForm.jsx | Moved to admin in sub-project #1 |
apps/web/src/screens/merchant/OfferForm.stories.jsx | Same |
apps/web/src/screens/merchant/OfferForm.stories-D6b1dms_.js (storybook-static, regenerated) | Build artifact, regenerates |
Out of scope โ
frequentaudience real enforcement โ file follow-up issue: "Add per-(user, venue) lantern counter forfrequentaudience targeting". Requires a Cloud Function trigger and backfill; documented in ยง3.per_user_limitenforcement โ already deferred per 2026-04-25 spec line 302. Tied to redemption flow.- Redemption flow (QR codes, code delivery, merchant verification) โ separate project per 2026-04-25 spec line 301.
- Server-side location validation โ anti-spoofing checks tracked in #155. For this work we trust the client's
lat/lng. - Offer analytics dashboard โ sub-project #3 of the offers migration; analytics-api
getOfferAnalyticsis scaffolded but not wired here. - Hero card visual updates (#323) โ independent visual work.
onSnapshotreal-time offers โ not needed; offers are stable on the timescale of dashboard sessions.- OpenAPI request body / response shape validation in the linter โ v1 covers path ร method only.
- Geohash index on offer venues โ pilot scale doesn't need it.
Risks โ
- Styling reconciliation between admin and web copies of
AdSlot/OfferCardsmay be larger than expected. Mitigation: if non-trivial, shipofferNormalizer.jsto@lantern/sharedonly (pure logic) and leave the styled components in their respective apps for this PR. Note the deferral in the PR body.- Implementation note (2026-04-30): Diff was 260 lines for
OfferCards.jsxand 91 forAdSlot.jsxโ well past the abort threshold. Web uses Tailwind utility classes; admin uses inline-style objects. Plan B fallback executed: onlyofferNormalizer.jsextracted to@lantern/shared/lib. Component extraction deferred to follow-up #350 โ needs astyleSet/theme prop pattern.
- Implementation note (2026-04-30): Diff was 260 lines for
- Venue
nameLowerbackfill window โ if a writer adds a venue mid-migration withoutnameLower, the picker won't find it. Mitigation: deploy the write-path changes (1, then addnameLowerto all writers) before running the backfill, then run the backfill, in that order. - Distance computation on every API call โ pilot scale is fine; flag for review if active-offer count exceeds a few hundred.
- OpenAPI linter false positives on dynamic routes โ Express supports
:paramregex constraints that the linter's parser might miss. Mitigation: start with strict matching and only allow opt-out via a// openapi-sync-ignorecomment if needed. Chat.jsxlocation โ file path is uncertain at spec time. Resolved in the implementation plan after a concrete grep.audience.jsevolution โ addingfrequentlater means changing the audience helper. Designed as a single-file pure function so the change is contained.
Open questions resolved during scoping โ
| Question | Resolution |
|---|---|
| Where does the user-facing read live? | merchants-api, new /offers/active route. See "Architecture ยง1". |
| What does each audience value enforce? | nearby/lantern/new enforced; frequent fallback to nearby with follow-up issue. See ยง3. |
| Outside-radius behavior? | Hidden entirely. No "you're not nearby" CTA. |
| Emulation? | Existing setSpoofedLocation is sufficient. No new code. |
| Placement consumer surfaces? | chat in Chat.jsx, feed in RecentActivityView. Components already exist (PR #322). |
| Venue picker bug? | nameLower denormalization + Firestore range query. Backfill script in tooling. |
| OpenAPI doc gap? | Fix merchants-api spec in this PR; ship cross-service linter from #347 in this PR. |
| Single PR or multiple? | Single PR per CLAUDE.md Rule #12. Closes #139, #321, #347. |