Offers Targeting + Web App Wiring โ Implementation Plan โ
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Wire real merchant offers from Firestore into the consumer web app with server-side targeting (geofence + audience), consume all four placement types on user-facing surfaces, fix a venue-picker bug surfaced during scoping, fill the merchants-api openapi.json drift, and add a cross-service openapi-sync linter.
Architecture: Add a public-auth GET /offers/active route to merchants-api that filters by geofence + audience server-side. Replace the web app's mock offerService.js with a thin client of this new endpoint. Wire chat/feed placements to existing-but-unused components. Extract shared offer modules to @lantern/shared.
Tech Stack: Express 5 + Firebase Admin SDK (server), React 19 + Vite (web), Vitest + Testing Library (tests), Firestore (data), npm workspaces.
Spec: docs/superpowers/specs/2026-04-30-offers-targeting-and-web-wiring-design.md
Closes: #139, #321, #347
Branch: feat/139-offers-targeting-web-wiring (off dev)
Pre-flight โ
- [ ] Step 1: Create the feature branch
git checkout dev
git pull origin dev
git checkout -b feat/139-offers-targeting-web-wiringPer CLAUDE.md Rule #12: branch in main repo, no git worktree.
- [ ] Step 2: Verify clean starting state
git statusExpected: untracked items from earlier sessions are fine; no staged or modified files for these tasks.
Task 1: Venue picker fix โ add nameLower write paths โ
Independent foundation. Update every code path that writes a venue's name to also write nameLower. Backfill comes in Task 2; query rewrite in Task 3.
Files:
Modify:
apps/web/src/lib/seedVenues.jsModify:
services/api/venues/src/services/venue.service.jsModify:
apps/web/src/lib/osmImportService.jsCreate:
packages/shared/venues/nameLower.jsModify:
packages/shared/venues/index.js[ ] Step 1: Inventory venue writers
grep -rn "addDoc(collection(db, 'venues')\|setDoc(doc(db, 'venues'\|venuesRef.add" apps/ services/ packages/ 2>/dev/nullCapture the file paths.
- [ ] Step 2: Add
nameLowerhelper to shared
Create packages/shared/venues/nameLower.js:
export function toNameLower(name) {
return (name || '').toLowerCase()
}- [ ] Step 3: Export from
@lantern/shared/venues
Read packages/shared/venues/index.js, append:
export { toNameLower } from './nameLower.js'- [ ] Step 4: Write a test
Create packages/shared/__tests__/venues/nameLower.test.js:
import { describe, it, expect } from 'vitest'
import { toNameLower } from '../../venues/nameLower.js'
describe('toNameLower', () => {
it('lowercases an ASCII name', () => {
expect(toNameLower('Swan Bar')).toBe('swan bar')
})
it('handles undefined/null safely', () => {
expect(toNameLower(undefined)).toBe('')
expect(toNameLower(null)).toBe('')
})
it('preserves whitespace and punctuation', () => {
expect(toNameLower("O'Malley's Pub & Grill")).toBe("o'malley's pub & grill")
})
})- [ ] Step 5: Run the test
npx vitest run packages/shared/__tests__/venues/nameLower.test.jsExpected: 3 tests pass.
- [ ] Step 6: Update each venue writer to include
nameLower: toNameLower(venue.name)
For each path from Step 1, read the file, locate the venue document write, add the field, add the import:
import { toNameLower } from '@lantern/shared/venues'- [ ] Step 7: Run lint + format
npm run validate -- --scope lint,formatExpected: green.
- [ ] Step 8: Commit
git add packages/shared/venues/nameLower.js \
packages/shared/venues/index.js \
packages/shared/__tests__/venues/nameLower.test.js \
apps/web/src/lib/seedVenues.js \
services/api/venues/src/services/venue.service.js \
apps/web/src/lib/osmImportService.js
git commit -m "feat(venues): write nameLower on every venue write path
Single source of truth: @lantern/shared/venues/toNameLower.
Backfill of pre-existing venues in next commit; picker query rewrite follows.
Refs #139"Task 2: Backfill nameLower for existing venue documents โ
Files:
Create:
tooling/scripts/backfill-venue-name-lower.jsModify:
docs/engineering/architecture/LOCATION_STACK.md[ ] Step 1: Write the backfill script
Create tooling/scripts/backfill-venue-name-lower.js:
#!/usr/bin/env node
import { initializeApp, applicationDefault, getApps } from 'firebase-admin/app'
import { getFirestore } from 'firebase-admin/firestore'
import { toNameLower } from '../../packages/shared/venues/index.js'
const args = process.argv.slice(2)
const projectId = (args.find((a) => a.startsWith('--project=')) || '--project=lantern-app-dev').split('=')[1]
const dryRun = args.includes('--dry-run')
if (getApps().length === 0) {
initializeApp({ credential: applicationDefault(), projectId })
}
const db = getFirestore()
async function main() {
console.log(`[backfill] project=${projectId} dryRun=${dryRun}`)
const snap = await db.collection('venues').get()
let scanned = 0, updated = 0, alreadyOk = 0
let batch = db.batch()
let batchSize = 0
for (const doc of snap.docs) {
scanned++
const data = doc.data()
const expected = toNameLower(data.name)
if (data.nameLower === expected) { alreadyOk++; continue }
if (!dryRun) {
batch.update(doc.ref, { nameLower: expected })
batchSize++
if (batchSize >= 450) {
await batch.commit()
batch = db.batch()
batchSize = 0
}
}
updated++
}
if (!dryRun && batchSize > 0) await batch.commit()
console.log(`[backfill] scanned=${scanned} updated=${updated} alreadyOk=${alreadyOk}`)
}
main().catch((err) => { console.error('[backfill] failed:', err); process.exit(1) })- [ ] Step 2: Verify syntax
chmod +x tooling/scripts/backfill-venue-name-lower.js
node --check tooling/scripts/backfill-venue-name-lower.jsExpected: no output (syntax OK).
- [ ] Step 3: Document in LOCATION_STACK.md
Append to docs/engineering/architecture/LOCATION_STACK.md:
## Venue `nameLower` field
Every venue document carries a denormalized `nameLower` field โ the lowercased value of `name` โ used for case-insensitive prefix search in the merchant venue picker. All writers must include this field via `toNameLower` from `@lantern/shared/venues`.
Backfill runbook:
\`\`\`bash
node tooling/scripts/backfill-venue-name-lower.js --project=lantern-app-dev --dry-run
node tooling/scripts/backfill-venue-name-lower.js --project=lantern-app-dev
\`\`\`
The script is idempotent.- [ ] Step 4: Commit
git add tooling/scripts/backfill-venue-name-lower.js docs/engineering/architecture/LOCATION_STACK.md
git commit -m "feat(venues): nameLower backfill script + docs
One-shot, idempotent, batched.
Refs #139"- [ ] Step 5: (Manual, post-merge) Run backfill against dev
Runbook line, not a code commit:
node tooling/scripts/backfill-venue-name-lower.js --project=lantern-app-dev --dry-run
node tooling/scripts/backfill-venue-name-lower.js --project=lantern-app-devTask 3: Rewrite AdminVenuePicker to use Firestore prefix query โ
Files:
Modify:
apps/admin/src/components/venues/AdminVenuePicker.jsxCreate:
apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsx[ ] Step 1: Write a failing test
Create apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsx:
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
const queryArgsLog = []
vi.mock('firebase/firestore', () => ({
collection: vi.fn(() => ({ __collection: 'venues' })),
query: vi.fn((...args) => { queryArgsLog.push(args); return { __query: args } }),
orderBy: vi.fn((field) => ({ __orderBy: field })),
startAt: vi.fn((v) => ({ __startAt: v })),
endAt: vi.fn((v) => ({ __endAt: v })),
limit: vi.fn((n) => ({ __limit: n })),
getDocs: vi.fn(async () => ({ docs: [] })),
}))
vi.mock('../../../firebase', () => ({
db: {},
associateVenueWithMerchant: vi.fn(),
}))
import AdminVenuePicker from '../AdminVenuePicker.jsx'
describe('AdminVenuePicker', () => {
beforeEach(() => { queryArgsLog.length = 0 })
it('does not query while the search input is empty', async () => {
render(<AdminVenuePicker merchantId="m1" />)
expect(queryArgsLog.length).toBe(0)
})
it('issues a Firestore prefix query on nameLower when the user types', async () => {
render(<AdminVenuePicker merchantId="m1" />)
const input = screen.getByPlaceholderText(/search venues/i)
fireEvent.change(input, { target: { value: 'Swan' } })
await waitFor(() => expect(queryArgsLog.length).toBeGreaterThan(0))
const constraints = queryArgsLog[0].slice(1)
expect(constraints).toContainEqual({ __orderBy: 'nameLower' })
expect(constraints).toContainEqual({ __startAt: 'swan' })
const endAt = constraints.find((c) => '__endAt' in c)
expect(endAt.__endAt.startsWith('swan')).toBe(true)
expect(endAt.__endAt.length).toBeGreaterThan('swan'.length)
})
})- [ ] Step 2: Run test, verify it fails
npx vitest run apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsxExpected: failures referencing missing prefix query usage.
- [ ] Step 3: Rewrite the picker
Read the current file first. Replace the imports + the fetch-all useEffect + the results useMemo with:
import { useEffect, useMemo, useState } from 'react'
import {
collection, endAt, getDocs, limit as qLimit, orderBy, query, startAt,
} from 'firebase/firestore'
import { associateVenueWithMerchant, db } from '../../firebase'
// Inside the component, replace the existing fetch-all + filter logic:
const [allVenues, setAllVenues] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
const term = searchTerm.trim().toLowerCase()
if (!term) { setAllVenues([]); return }
let cancelled = false
const handle = setTimeout(async () => {
try {
setLoading(true)
setError(null)
const q = query(
collection(db, 'venues'),
orderBy('nameLower'),
startAt(term),
endAt(term + '๏ฃฟ'),
qLimit(50)
)
const snap = await getDocs(q)
if (cancelled) return
setAllVenues(
snap.docs.map((d) => ({
venueId: d.id,
name: d.data().name,
address: d.data().address,
category: d.data().category,
merchantId: d.data().merchantId || null,
}))
)
} catch (err) {
if (!cancelled) setError(err.message || 'Failed to load venues')
} finally {
if (!cancelled) setLoading(false)
}
}, 200) // 200ms debounce
return () => { cancelled = true; clearTimeout(handle) }
}, [searchTerm])Update the JSX consumer of the previous results variable to use allVenues (or rename for clarity).
- [ ] Step 4: Run test, verify it passes
npx vitest run apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsxExpected: 2 tests pass.
- [ ] Step 5: Manual smoke
npm run dev -w apps/adminNavigate to merchant โ Venues โ Associate venue โ search "Swan". Verify result appears.
- [ ] Step 6: Commit
git add apps/admin/src/components/venues/AdminVenuePicker.jsx \
apps/admin/src/components/venues/__tests__/AdminVenuePicker.test.jsx
git commit -m "fix(admin): venue picker uses Firestore prefix query on nameLower
Replaces the limit(500) fetch-all-and-filter approach that hid venues
sorting past doc-id position 500 (Swan Bar etc.). Uses orderBy +
startAt/endAt with U+F8FF prefix bound, debounced 200ms.
Refs #139"Task 4: Establish merchants-api test infrastructure โ
Files:
Modify:
services/api/merchants/package.jsonCreate:
services/api/merchants/vitest.config.js[ ] Step 1: Add vitest scripts + devDep
Replace the scripts block in services/api/merchants/package.json with the auth-api pattern:
{
"scripts": {
"start": "node src/index.js",
"predev": "lsof -ti :8085 | xargs kill -9 2>/dev/null || true",
"dev": "node --env-file=../../../.env.local --watch src/index.js",
"test": "vitest",
"test:run": "vitest run",
"deploy:dev": "cp -r ../../../packages/shared .shared-pkg && gcloud run deploy merchants-api --source . --region us-central1 --project lantern-app-dev --allow-unauthenticated; STATUS=$?; rm -rf .shared-pkg; exit $STATUS",
"validate": "vitest run && echo 'Merchants API validation: OK'"
},
"devDependencies": {
"vitest": "^4.0.18"
}
}(Match the vitest version with auth-api so the lockfile resolves cleanly.)
- [ ] Step 2: Create vitest config
Create services/api/merchants/vitest.config.js:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/__tests__/**/*.test.js'],
globals: false,
},
})- [ ] Step 3: Install
npm install- [ ] Step 4: Verify the runner boots
npm test -w services/api/merchants -- --runExpected: "No test files found" โ runner works, nothing to run yet.
- [ ] Step 5: Commit
git add services/api/merchants/package.json services/api/merchants/vitest.config.js package-lock.json
git commit -m "chore(merchants-api): add vitest infrastructure
Mirrors auth-api setup. Tests at src/**/__tests__/**/*.test.js.
Refs #139"Task 5: Audience filter helper โ TDD โ
Files:
Create:
services/api/merchants/src/lib/audience.jsCreate:
services/api/merchants/src/lib/__tests__/audience.test.js[ ] Step 1: Write failing tests
Create services/api/merchants/src/lib/__tests__/audience.test.js:
import { describe, it, expect } from 'vitest'
import { passesAudience, NEW_USER_THRESHOLD_DAYS } from '../audience.js'
describe('passesAudience', () => {
const baseCtx = { uid: 'u1', accountAgeDays: 30, hasActiveLanternAtVenue: false }
const baseOffer = { venueId: 'v1', targetAudience: 'nearby' }
describe("audience 'nearby'", () => {
it('always passes (geofence enforced upstream)', () => {
expect(passesAudience('nearby', baseCtx, baseOffer)).toBe(true)
})
})
describe("audience 'lantern'", () => {
it('passes when user has active lantern at venue', () => {
const ctx = { ...baseCtx, hasActiveLanternAtVenue: true }
expect(passesAudience('lantern', ctx, baseOffer)).toBe(true)
})
it('fails when user has no active lantern at venue', () => {
expect(passesAudience('lantern', baseCtx, baseOffer)).toBe(false)
})
})
describe("audience 'new'", () => {
it('passes when account age < threshold', () => {
const ctx = { ...baseCtx, accountAgeDays: NEW_USER_THRESHOLD_DAYS - 1 }
expect(passesAudience('new', ctx, baseOffer)).toBe(true)
})
it('fails at exactly the threshold', () => {
const ctx = { ...baseCtx, accountAgeDays: NEW_USER_THRESHOLD_DAYS }
expect(passesAudience('new', ctx, baseOffer)).toBe(false)
})
it('fails for older accounts', () => {
expect(passesAudience('new', baseCtx, baseOffer)).toBe(false)
})
})
describe("audience 'frequent'", () => {
it('falls back to nearby in v1 (passes)', () => {
// v1 limitation โ see audience.js JSDoc and the spec.
expect(passesAudience('frequent', baseCtx, baseOffer)).toBe(true)
})
})
describe('unknown audience', () => {
it('rejects defensively', () => {
expect(passesAudience('invalid', baseCtx, baseOffer)).toBe(false)
})
})
describe('NEW_USER_THRESHOLD_DAYS', () => {
it('is 7 for v1', () => {
expect(NEW_USER_THRESHOLD_DAYS).toBe(7)
})
})
})- [ ] Step 2: Run test, verify failure
npm test -w services/api/merchants -- --runExpected: cannot find module ../audience.js.
- [ ] Step 3: Implement
Create services/api/merchants/src/lib/audience.js:
/**
* Audience filter for offer targeting.
* Geofence is enforced upstream of this helper; this covers identity-based filters.
*
* v1 audience semantics:
* nearby โ always true (geofence already passed)
* lantern โ user has an active lantern at offer.venueId
* new โ user account age < NEW_USER_THRESHOLD_DAYS days
* frequent โ returns true (v1 fallback). Real enforcement requires a per-(user,
* venue) lantern counter that doesn't exist yet โ the lanterns
* collection has a 48h TTL so it can't answer "lit โฅ 3 times
* historically." Tracked as a follow-up.
*
* Unknown audience values return false defensively.
*/
export const NEW_USER_THRESHOLD_DAYS = 7
export function passesAudience(audience, userContext, offer) {
switch (audience) {
case 'nearby': return true
case 'lantern': return Boolean(userContext.hasActiveLanternAtVenue)
case 'new': return Number(userContext.accountAgeDays) < NEW_USER_THRESHOLD_DAYS
case 'frequent': return true // v1 fallback
default: return false
}
}- [ ] Step 4: Run test, verify pass
npm test -w services/api/merchants -- --runExpected: 9 tests pass.
- [ ] Step 5: Commit
git add services/api/merchants/src/lib/audience.js \
services/api/merchants/src/lib/__tests__/audience.test.js
git commit -m "feat(merchants-api): add audience filter helper
Pure function with TDD coverage for nearby/lantern/new audiences.
'frequent' falls back to nearby in v1 โ proper enforcement needs a
per-(user, venue) lantern counter (follow-up issue).
Refs #139"Task 6: Public offers route โ GET /offers/active โ
Files:
Create:
services/api/merchants/src/routes/publicOffers.jsCreate:
services/api/merchants/src/__tests__/publicOffers.test.jsModify:
services/api/merchants/src/index.jsModify:
services/api/merchants/package.json(add supertest devDep)[ ] Step 1: Add supertest
npm install --save-dev supertest -w services/api/merchants- [ ] Step 2: Write failing tests
Create services/api/merchants/src/__tests__/publicOffers.test.js:
import { describe, it, expect, beforeEach, vi } from 'vitest'
import express from 'express'
import request from 'supertest'
const mockGetFirestore = vi.fn()
vi.mock('firebase-admin/firestore', () => ({
getFirestore: () => mockGetFirestore(),
FieldValue: { serverTimestamp: () => 'NOW' },
}))
import publicOffersRouter from '../routes/publicOffers.js'
function makeApp(uid = 'user-1') {
const app = express()
app.use(express.json())
app.use((req, _res, next) => { req.user = { uid }; next() })
app.use('/offers', publicOffersRouter)
return app
}
function makeFirestoreFixture({ offers = [], venues = {}, users = {}, lanterns = [] } = {}) {
const offersGet = vi.fn(async () => ({
docs: offers.map((o) => ({ id: o.id, data: () => o })),
}))
const venuesGet = vi.fn(async (id) => ({
exists: id in venues, id, data: () => venues[id],
}))
const usersGet = vi.fn(async (id) => ({
exists: id in users, data: () => users[id],
}))
const lanternsGet = vi.fn(async () => ({
docs: lanterns.map((l) => ({ id: l.id, data: () => l })),
}))
return {
collection: (name) => {
if (name === 'offers') return { where: () => ({ where: () => ({ get: offersGet }) }) }
if (name === 'venues') return { doc: (id) => ({ get: () => venuesGet(id) }) }
if (name === 'users') return { doc: (id) => ({ get: () => usersGet(id) }) }
if (name === 'lanterns') return {
where: () => ({ where: () => ({ where: () => ({ get: lanternsGet }) }) }),
}
throw new Error('unexpected collection: ' + name)
},
}
}
describe('GET /offers/active', () => {
beforeEach(() => mockGetFirestore.mockReset())
it('returns 400 when lat/lng are missing', async () => {
mockGetFirestore.mockReturnValue(makeFirestoreFixture())
const res = await request(makeApp()).get('/offers/active')
expect(res.status).toBe(400)
expect(res.body.error).toBe('INVALID_INPUT')
})
it('filters out offers outside the geofence radius', async () => {
mockGetFirestore.mockReturnValue(
makeFirestoreFixture({
offers: [{
id: 'o1', merchantId: 'm1', venueId: 'v1',
status: 'active', expiresAt: { toDate: () => new Date(Date.now() + 86400000) },
targetAudience: 'nearby', radius: 500,
placement: 'hero', title: 't', description: 'd',
}],
venues: { v1: { name: 'V1', lat: 0, lng: 0, lanternCount: 2 } },
users: { 'user-1': { createdAt: { toDate: () => new Date(Date.now() - 30 * 86400000) } } },
})
)
const res = await request(makeApp()).get('/offers/active?lat=0&lng=10')
expect(res.body.offers).toEqual([])
})
it('returns offers within radius with hydrated venue + distance', async () => {
mockGetFirestore.mockReturnValue(
makeFirestoreFixture({
offers: [{
id: 'o1', merchantId: 'm1', venueId: 'v1',
status: 'active', expiresAt: { toDate: () => new Date(Date.now() + 86400000) },
targetAudience: 'nearby', radius: 1000,
placement: 'hero', title: 'Brunch', description: 'd',
}],
venues: { v1: { name: 'V1', lat: 0, lng: 0, lanternCount: 5, category: 'restaurant' } },
users: { 'user-1': { createdAt: { toDate: () => new Date(Date.now() - 30 * 86400000) } } },
})
)
const res = await request(makeApp()).get('/offers/active?lat=0&lng=0.001')
expect(res.status).toBe(200)
expect(res.body.offers).toHaveLength(1)
expect(res.body.offers[0].id).toBe('o1')
expect(res.body.offers[0].venue).toMatchObject({ name: 'V1', lanternCount: 5 })
expect(res.body.offers[0].distanceMeters).toBeGreaterThan(0)
expect(res.body.offers[0].budget).toBeUndefined()
})
it('filters out lantern-audience offers when no active lantern', async () => {
mockGetFirestore.mockReturnValue(
makeFirestoreFixture({
offers: [{
id: 'o1', merchantId: 'm1', venueId: 'v1',
status: 'active', expiresAt: { toDate: () => new Date(Date.now() + 86400000) },
targetAudience: 'lantern', radius: 1000,
placement: 'hero', title: 't', description: 'd',
}],
venues: { v1: { name: 'V1', lat: 0, lng: 0, lanternCount: 0 } },
users: { 'user-1': { createdAt: { toDate: () => new Date() } } },
lanterns: [],
})
)
const res = await request(makeApp()).get('/offers/active?lat=0&lng=0')
expect(res.body.offers).toEqual([])
})
it('respects ?placement= filter', async () => {
mockGetFirestore.mockReturnValue(
makeFirestoreFixture({
offers: [
{ id: 'h', merchantId: 'm1', venueId: 'v1', status: 'active',
expiresAt: { toDate: () => new Date(Date.now() + 86400000) },
targetAudience: 'nearby', radius: 1000, placement: 'hero',
title: 't', description: 'd' },
{ id: 'c', merchantId: 'm1', venueId: 'v1', status: 'active',
expiresAt: { toDate: () => new Date(Date.now() + 86400000) },
targetAudience: 'nearby', radius: 1000, placement: 'chat',
title: 't', description: 'd' },
],
venues: { v1: { name: 'V1', lat: 0, lng: 0, lanternCount: 0 } },
users: { 'user-1': { createdAt: { toDate: () => new Date(Date.now() - 30 * 86400000) } } },
})
)
const res = await request(makeApp()).get('/offers/active?lat=0&lng=0&placement=chat')
expect(res.body.offers).toHaveLength(1)
expect(res.body.offers[0].id).toBe('c')
})
it('excludes expired offers', async () => {
mockGetFirestore.mockReturnValue(
makeFirestoreFixture({
offers: [{
id: 'o1', merchantId: 'm1', venueId: 'v1', status: 'active',
expiresAt: { toDate: () => new Date(Date.now() - 1000) },
targetAudience: 'nearby', radius: 1000, placement: 'hero',
title: 't', description: 'd',
}],
venues: { v1: { name: 'V1', lat: 0, lng: 0, lanternCount: 0 } },
users: { 'user-1': { createdAt: { toDate: () => new Date() } } },
})
)
const res = await request(makeApp()).get('/offers/active?lat=0&lng=0')
expect(res.body.offers).toEqual([])
})
})- [ ] Step 3: Run, verify failure
npm test -w services/api/merchants -- --runExpected: cannot find ../routes/publicOffers.js.
- [ ] Step 4: Implement the route
Create services/api/merchants/src/routes/publicOffers.js:
/**
* Public offer reads โ user-facing endpoint.
*
* GET /offers/active?lat=&lng=&placement=
*
* Auth: any signed-in app user (verifyFirebaseToken upstream).
* Filters: status=active, expiresAt>now, geofence (radius), audience.
* Hydration: includes venue summary + distanceMeters per offer.
*/
import { Router } from 'express'
import { getFirestore } from 'firebase-admin/firestore'
import { passesAudience } from '../lib/audience.js'
const router = Router()
const MS_PER_DAY = 86_400_000
const EARTH_RADIUS_M = 6_371_000
function haversineMeters(lat1, lng1, lat2, lng2) {
const toRad = (deg) => (deg * Math.PI) / 180
const dLat = toRad(lat2 - lat1)
const dLng = toRad(lng2 - lng1)
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a))
}
function toIsoOrNull(ts) {
if (!ts) return null
if (ts.toDate) return ts.toDate().toISOString()
if (ts instanceof Date) return ts.toISOString()
return null
}
function serializeForClient(offer, venue, distanceMeters) {
return {
id: offer.id,
merchantId: offer.merchantId,
venueId: offer.venueId,
title: offer.title,
description: offer.description,
placement: offer.placement,
targetAudience: offer.targetAudience,
radius: offer.radius,
expiresAt: toIsoOrNull(offer.expiresAt),
showDisclaimerWhileSuppliesLast: offer.showDisclaimerWhileSuppliesLast || false,
venue: {
id: venue.id,
name: venue.name,
lat: venue.lat,
lng: venue.lng,
category: venue.category || null,
lanternCount: venue.lanternCount || 0,
},
distanceMeters,
}
}
router.get('/active', async (req, res, next) => {
try {
const lat = Number(req.query.lat)
const lng = Number(req.query.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return res.status(400).json({
error: 'INVALID_INPUT',
message: 'lat and lng query params are required and must be numbers',
})
}
const placementFilter = req.query.placement || null
const db = getFirestore()
const now = new Date()
// 1. Active offers.
const offersSnap = await db.collection('offers').where('status', '==', 'active').get()
const rawOffers = offersSnap.docs.map((d) => ({ id: d.id, ...d.data() }))
// 2. Drop expired.
const live = rawOffers.filter((o) => {
const exp = o.expiresAt?.toDate ? o.expiresAt.toDate() : o.expiresAt
return exp instanceof Date && exp > now
})
// 3. Hydrate venues (one read per unique venueId).
const uniqueVenueIds = [...new Set(live.map((o) => o.venueId).filter(Boolean))]
const venueDocs = await Promise.all(
uniqueVenueIds.map((id) => db.collection('venues').doc(id).get())
)
const venueById = {}
for (const snap of venueDocs) {
if (snap.exists) venueById[snap.id] = { id: snap.id, ...snap.data() }
}
// 4. Geofence filter.
const withinRadius = live
.map((offer) => {
const venue = venueById[offer.venueId]
if (!venue || typeof venue.lat !== 'number' || typeof venue.lng !== 'number') return null
const distance = haversineMeters(lat, lng, venue.lat, venue.lng)
if (distance > (offer.radius || 0)) return null
return { offer, venue, distance }
})
.filter(Boolean)
// 5. User context.
const userSnap = await db.collection('users').doc(req.user.uid).get()
const userData = userSnap.exists ? userSnap.data() : {}
const createdAtDate = userData.createdAt?.toDate
? userData.createdAt.toDate()
: userData.createdAt instanceof Date ? userData.createdAt : new Date()
const accountAgeDays = (now - createdAtDate) / MS_PER_DAY
// Active lantern lookup โ batched per user.
const candidateVenueIds = [...new Set(withinRadius.map((r) => r.offer.venueId))]
let activeLanternVenueIds = new Set()
if (candidateVenueIds.length > 0) {
if (candidateVenueIds.length <= 10) {
const lanternsSnap = await db.collection('lanterns')
.where('userId', '==', req.user.uid)
.where('status', '==', 'active')
.where('venueId', 'in', candidateVenueIds)
.get()
activeLanternVenueIds = new Set(lanternsSnap.docs.map((d) => d.data().venueId))
} else {
// Firestore 'in' max = 10. Fall back to a single uid+status query.
const allLanternsSnap = await db.collection('lanterns')
.where('userId', '==', req.user.uid)
.where('status', '==', 'active')
.get()
activeLanternVenueIds = new Set(allLanternsSnap.docs.map((d) => d.data().venueId))
}
}
// 6. Audience filter.
const audienced = withinRadius.filter(({ offer }) =>
passesAudience(offer.targetAudience, {
uid: req.user.uid,
accountAgeDays,
hasActiveLanternAtVenue: activeLanternVenueIds.has(offer.venueId),
}, offer)
)
// 7. Optional placement filter.
const placementFiltered = placementFilter
? audienced.filter(({ offer }) => offer.placement === placementFilter)
: audienced
const offers = placementFiltered.map(({ offer, venue, distance }) =>
serializeForClient(offer, venue, Math.round(distance))
)
return res.json({ offers, total: offers.length })
} catch (err) {
next(err)
}
})
export default router- [ ] Step 5: Run, verify pass
npm test -w services/api/merchants -- --runExpected: all 6 publicOffers tests + 9 audience tests pass.
- [ ] Step 6: Mount the new router
Read services/api/merchants/src/index.js. Add the import and mount before the existing merchant-scoped mount:
import publicOffersRouter from './routes/publicOffers.js'
// ...later, replacing the section that mounts /merchants/:merchantId:
// Public read โ auth only, no merchant scoping
app.use('/offers', verifyFirebaseToken, publicOffersRouter)
// Existing merchant-scoped CRUD (unchanged)
app.use('/merchants/:merchantId', verifyFirebaseToken, requireMerchantAccess, offersRouter)- [ ] Step 7: Local smoke test
npm run dev -w services/api/merchantsIn another terminal:
curl -i 'http://localhost:8085/offers/active?lat=32.7&lng=-117.1'
# Expected: 401 (auth middleware engaged)With a valid ID token in the browser session, hit the endpoint via the web app DevTools fetch and confirm 200 response.
- [ ] Step 8: Commit
git add services/api/merchants/src/routes/publicOffers.js \
services/api/merchants/src/__tests__/publicOffers.test.js \
services/api/merchants/src/index.js \
services/api/merchants/package.json \
package-lock.json
git commit -m "feat(merchants-api): add GET /offers/active public route
User-facing endpoint with server-side geofence + audience filtering.
Hydrates venue data and computes distanceMeters. Internal fields
(budget, per_user_limit, createdBy) stripped from response. Mounts
under /offers with verifyFirebaseToken only โ no merchant scoping.
Refs #139"Task 7: Document the merchants-api routes in openapi.json โ
Files:
Modify:
services/api/merchants/openapi.json[ ] Step 1: Replace
services/api/merchants/openapi.jsonentirely
Write the full spec. It documents /health (existing), /offers/active (new), and the four merchant-scoped CRUD paths that already exist in code but were undocumented.
{
"openapi": "3.0.3",
"info": {
"title": "Lantern Merchants API",
"description": "Cloud Run service for merchant business operations โ offers (CRUD + public read), and merchant-scoped resources.",
"version": "1.1.0",
"contact": { "name": "Lantern Engineering", "url": "https://ourlantern.app" }
},
"servers": [
{ "url": "http://localhost:8085", "description": "Local development" }
],
"tags": [
{ "name": "Health", "description": "Service health โ no authentication required." },
{ "name": "Offers (Public)", "description": "User-facing read of active offers near a location. Requires authentication." },
{ "name": "Offers (Merchant)", "description": "Offer CRUD scoped to a merchant. Requires authentication and merchant access (admin or owning merchant)." }
],
"paths": {
"/health": {
"get": {
"tags": ["Health"], "summary": "Health check",
"responses": { "200": { "description": "Healthy", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Health" } } } } }
}
},
"/offers/active": {
"get": {
"tags": ["Offers (Public)"], "summary": "List active offers near a location",
"description": "Returns active, non-expired offers within geofence radius and audience-filtered for the requesting user.",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "lat", "in": "query", "required": true, "schema": { "type": "number" } },
{ "name": "lng", "in": "query", "required": true, "schema": { "type": "number" } },
{ "name": "placement", "in": "query", "required": false, "schema": { "type": "string", "enum": ["hero", "inline", "chat", "feed"] } }
],
"responses": {
"200": { "description": "Visible offers", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicOfferList" } } } },
"400": { "description": "Missing/invalid lat or lng", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Auth required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/merchants/{merchantId}/offers": {
"get": {
"tags": ["Offers (Merchant)"], "summary": "List a merchant's offers",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "merchantId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "status", "in": "query", "required": false, "schema": { "type": "string", "enum": ["active", "draft", "expired", "archived"] } }
],
"responses": {
"200": { "description": "Offers", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MerchantOfferList" } } } },
"401": { "description": "Auth required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"403": { "description": "Forbidden", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"post": {
"tags": ["Offers (Merchant)"], "summary": "Create an offer",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "name": "merchantId", "in": "path", "required": true, "schema": { "type": "string" } }],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OfferCreate" } } } },
"responses": {
"201": { "description": "Created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MerchantOffer" } } } },
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Merchant not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/merchants/{merchantId}/offers/{offerId}": {
"get": {
"tags": ["Offers (Merchant)"], "summary": "Get a single offer",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "merchantId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "offerId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Offer", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MerchantOffer" } } } },
"404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"put": {
"tags": ["Offers (Merchant)"], "summary": "Update an offer",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "merchantId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "offerId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OfferUpdate" } } } },
"responses": {
"200": { "description": "Updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MerchantOffer" } } } },
"400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"delete": {
"tags": ["Offers (Merchant)"], "summary": "Delete (hard for drafts, archive otherwise)",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "merchantId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "offerId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Deleted", "content": { "application/json": { "schema": { "type": "object", "properties": { "deleted": { "type": "boolean" }, "hard": { "type": "boolean" }, "status": { "type": "string" } } } } } },
"404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "Firebase ID token" }
},
"schemas": {
"Health": { "type": "object", "properties": { "status": { "type": "string" }, "service": { "type": "string" } } },
"Error": { "type": "object", "properties": { "error": { "type": "string" }, "message": { "type": "string" } } },
"Audience": { "type": "string", "enum": ["nearby", "lantern", "frequent", "new"] },
"Placement": { "type": "string", "enum": ["hero", "inline", "chat", "feed"] },
"Status": { "type": "string", "enum": ["draft", "active", "expired", "archived"] },
"VenueSummary": {
"type": "object",
"properties": {
"id": { "type": "string" }, "name": { "type": "string" },
"lat": { "type": "number" }, "lng": { "type": "number" },
"category": { "type": "string", "nullable": true }, "lanternCount": { "type": "integer" }
}
},
"PublicOffer": {
"type": "object",
"properties": {
"id": { "type": "string" }, "merchantId": { "type": "string" }, "venueId": { "type": "string" },
"title": { "type": "string" }, "description": { "type": "string" },
"placement": { "$ref": "#/components/schemas/Placement" },
"targetAudience": { "$ref": "#/components/schemas/Audience" },
"radius": { "type": "integer" },
"expiresAt": { "type": "string", "format": "date-time", "nullable": true },
"showDisclaimerWhileSuppliesLast": { "type": "boolean" },
"venue": { "$ref": "#/components/schemas/VenueSummary" },
"distanceMeters": { "type": "integer" }
}
},
"PublicOfferList": {
"type": "object",
"properties": {
"offers": { "type": "array", "items": { "$ref": "#/components/schemas/PublicOffer" } },
"total": { "type": "integer" }
}
},
"MerchantOffer": {
"type": "object",
"properties": {
"id": { "type": "string" }, "merchantId": { "type": "string" }, "venueId": { "type": "string" },
"title": { "type": "string" }, "description": { "type": "string" },
"placement": { "$ref": "#/components/schemas/Placement" },
"targetAudience": { "$ref": "#/components/schemas/Audience" },
"radius": { "type": "integer" }, "per_user_limit": { "type": "integer" }, "budget": { "type": "number" },
"status": { "$ref": "#/components/schemas/Status" },
"expiresAt": { "type": "string", "format": "date-time", "nullable": true },
"showDisclaimerWhileSuppliesLast": { "type": "boolean" },
"createdAt": { "type": "string", "format": "date-time", "nullable": true },
"createdBy": { "type": "string" },
"updatedAt": { "type": "string", "format": "date-time", "nullable": true }
}
},
"MerchantOfferList": {
"type": "object",
"properties": {
"offers": { "type": "array", "items": { "$ref": "#/components/schemas/MerchantOffer" } },
"total": { "type": "integer" }
}
},
"OfferCreate": {
"type": "object",
"required": ["venueId", "title", "description", "placement", "targetAudience", "radius", "budget", "expiresAt"],
"properties": {
"venueId": { "type": "string" },
"title": { "type": "string", "maxLength": 200 },
"description": { "type": "string", "maxLength": 2000 },
"placement": { "$ref": "#/components/schemas/Placement" },
"targetAudience": { "$ref": "#/components/schemas/Audience" },
"radius": { "type": "integer", "minimum": 10, "maximum": 2500 },
"per_user_limit": { "type": "integer", "minimum": 1, "default": 1 },
"budget": { "type": "number", "minimum": 1 },
"expiresAt": { "type": "string", "format": "date-time" },
"showDisclaimerWhileSuppliesLast": { "type": "boolean", "default": false },
"status": { "type": "string", "enum": ["draft", "active"], "default": "draft" }
}
},
"OfferUpdate": {
"type": "object",
"description": "All fields optional โ only included fields are updated.",
"properties": {
"venueId": { "type": "string" },
"title": { "type": "string", "maxLength": 200 },
"description": { "type": "string", "maxLength": 2000 },
"placement": { "$ref": "#/components/schemas/Placement" },
"targetAudience": { "$ref": "#/components/schemas/Audience" },
"radius": { "type": "integer", "minimum": 10, "maximum": 2500 },
"per_user_limit": { "type": "integer", "minimum": 1 },
"budget": { "type": "number", "minimum": 1 },
"expiresAt": { "type": "string", "format": "date-time" },
"showDisclaimerWhileSuppliesLast": { "type": "boolean" },
"status": { "type": "string", "enum": ["draft", "active", "archived"] }
}
}
}
}
}- [ ] Step 2: Validate JSON
node -e "JSON.parse(require('fs').readFileSync('services/api/merchants/openapi.json'))"Expected: no error.
- [ ] Step 3: Manual verify in admin portal API Reference
npm run dev -w apps/admin
npm run dev -w services/api/merchantsOpen admin portal โ API Reference โ Merchants API. Confirm all 6 paths render and the schemas resolve.
- [ ] Step 4: Commit
git add services/api/merchants/openapi.json
git commit -m "docs(merchants-api): document all routes in openapi.json
Fills the silent drift where only /health was documented despite five
offer routes being live. Adds GET /offers/active. Schemas for
PublicOffer, MerchantOffer, audience/placement/status enums.
Refs #139"Task 8: Extract offerNormalizer.js to @lantern/shared โ
Pure logic โ safest extraction first.
Files:
Create:
packages/shared/lib/offerNormalizer.jsCreate:
packages/shared/lib/index.jsCreate:
packages/shared/__tests__/lib/offerNormalizer.test.jsModify:
packages/shared/package.json(add./libexport)Modify:
apps/web/src/lib/offerNormalizer.js(re-export shim)Modify:
apps/admin/src/shared/lib/offerNormalizer.js(re-export shim)[ ] Step 1: Diff the two existing copies
diff apps/web/src/lib/offerNormalizer.js apps/admin/src/shared/lib/offerNormalizer.jsReconcile to a single canonical version (admin copy is likely a superset; if identical, the move is trivial).
- [ ] Step 2: Create shared copy
Create packages/shared/lib/offerNormalizer.js with the canonical content (start by copying apps/web/src/lib/offerNormalizer.js verbatim).
- [ ] Step 3: Create lib index
Create packages/shared/lib/index.js:
export { normalizeFormToOffer } from './offerNormalizer.js'- [ ] Step 4: Add
./libto package exports
Read packages/shared/package.json, add to exports:
"./lib": "./lib/index.js"- [ ] Step 5: Write a test
Create packages/shared/__tests__/lib/offerNormalizer.test.js:
import { describe, it, expect } from 'vitest'
import { normalizeFormToOffer } from '../../lib/offerNormalizer.js'
describe('normalizeFormToOffer', () => {
it('maps title to headline and description to body', () => {
const out = normalizeFormToOffer({ title: '20% off brunch', description: 'Show this' })
expect(out.headline).toBe('20% off brunch')
expect(out.body).toBe('Show this')
})
it('extracts the first 3 words of title as incentive', () => {
expect(normalizeFormToOffer({ title: 'Free latte for friends today' }).incentive)
.toBe('Free latte for')
})
it('falls back to placement="hero" if not provided', () => {
expect(normalizeFormToOffer({ title: 't' }).placement).toBe('hero')
})
it('marks the result as a preview', () => {
expect(normalizeFormToOffer({ title: 't' }).isPreview).toBe(true)
})
it('uses options.id when provided', () => {
expect(normalizeFormToOffer({ title: 't' }, { id: 'fixed' }).id).toBe('fixed')
})
})- [ ] Step 6: Run, verify pass
npx vitest run packages/shared/__tests__/lib/offerNormalizer.test.jsExpected: 5 tests pass.
- [ ] Step 7: Replace
apps/web/src/lib/offerNormalizer.jswith re-export
Replace entire file:
// Re-export from @lantern/shared/lib so existing imports keep working.
export { normalizeFormToOffer } from '@lantern/shared/lib'- [ ] Step 8: Replace
apps/admin/src/shared/lib/offerNormalizer.jswith re-export
Same pattern.
- [ ] Step 9: Run web + admin test suites
npm test -w apps/web -- --run
npm test -w apps/admin -- --runExpected: green.
- [ ] Step 10: Commit
git add packages/shared/lib/ packages/shared/__tests__/lib/ \
packages/shared/package.json \
apps/web/src/lib/offerNormalizer.js \
apps/admin/src/shared/lib/offerNormalizer.js
git commit -m "refactor(shared): extract offerNormalizer to @lantern/shared/lib
Pure logic, no styling. Web and admin become re-export shims.
Refs #321"Task 9: Extract OfferCards.jsx + AdSlot.jsx to @lantern/shared โ
Risk-aware: admin copy uses CSS variables; web uses Tailwind. Plan A: extract with styleSet prop. Plan B: defer if reconciliation is too costly.
Files (Plan A):
Create:
packages/shared/components/OfferCards.jsxCreate:
packages/shared/components/AdSlot.jsxCreate:
packages/shared/components/index.jsModify:
packages/shared/package.jsonModify: web + admin copies โ re-export shims
[ ] Step 1: Diff
OfferCards.jsx
diff apps/web/src/components/dashboard/OfferCards.jsx apps/admin/src/components/offers/OfferCards.jsx | head -100If substantive style divergence > ~80 lines: abort to Plan B (Step 3b).
- [ ] Step 2: Diff
AdSlot.jsx
diff apps/web/src/components/dashboard/AdSlot.jsx apps/admin/src/components/offers/AdSlot.jsx | head -100Same decision.
- [ ] Step 3a (Plan A): Move web copies to shared with
styleSetprop
Move apps/web/src/components/dashboard/OfferCards.jsx content to packages/shared/components/OfferCards.jsx. Add an optional styles prop on each component:
const DEFAULT_STYLES = {
heroCard: '...tailwind classes from web copy...',
// ...other slot styles
}
export const HeroOfferCard = ({ offer, onSelect, styles = DEFAULT_STYLES }) => (
<div className={styles.heroCard}>{/* ... */}</div>
)Web app passes nothing โ Tailwind defaults. Admin passes its own styles object โ admin CSS class names.
Same approach for AdSlot.jsx.
- [ ] Step 3b (Plan B fallback): Defer extraction
If Step 1/2 diffs were too divergent:
- Edit the spec at
docs/superpowers/specs/2026-04-30-offers-targeting-and-web-wiring-design.mdยง7 Risks. Append:
**Implementation note (2026-XX-XX):** Style reconciliation between admin and web copies of `OfferCards`/`AdSlot` exceeded the cost-benefit threshold. Only `offerNormalizer.js` was extracted to `@lantern/shared`. Components remain in their respective apps. Tracked as a follow-up issue.- File a follow-up:
gh issue create --title "Extract OfferCards/AdSlot to @lantern/shared with style theming" \
--label "enhancement,refactor" \
--body "Deferred from #139 PR on 2026-04-30. Admin and web have substantively diverged styling; needs a styleSet/theme prop pattern."- Skip to Task 10. Note the new issue # in the PR body.
- [ ] Step 4 (Plan A): Add
./componentsto package exports
In packages/shared/package.json, add to exports:
"./components": "./components/index.js"Create packages/shared/components/index.js:
export { HeroOfferCard, OfferPill, ChatOfferPill, FeedOfferCard } from './OfferCards.jsx'
export { AdSlot } from './AdSlot.jsx'- [ ] Step 5 (Plan A): Replace web + admin copies with re-export shims
// apps/web/src/components/dashboard/OfferCards.jsx
export { HeroOfferCard, OfferPill, ChatOfferPill, FeedOfferCard } from '@lantern/shared/components'// apps/web/src/components/dashboard/AdSlot.jsx
export { AdSlot } from '@lantern/shared/components'Same for admin copies.
- [ ] Step 6 (Plan A): Run all tests + storybook spot-check
npm test -- --run
npm run storybook -w apps/webExpected: green tests; stories render identically.
- [ ] Step 7: Commit
Plan A:
git add packages/shared/components/ packages/shared/package.json \
apps/web/src/components/dashboard/OfferCards.jsx \
apps/web/src/components/dashboard/AdSlot.jsx \
apps/admin/src/components/offers/OfferCards.jsx \
apps/admin/src/components/offers/AdSlot.jsx
git commit -m "refactor(shared): extract OfferCards + AdSlot to @lantern/shared
Components accept a styles prop so admin and web each pass their theme.
Web defaults preserve Tailwind; admin passes CSS-var class names.
Refs #321"Plan B:
git add docs/superpowers/specs/2026-04-30-offers-targeting-and-web-wiring-design.md
git commit -m "docs: defer OfferCards/AdSlot extraction to follow-up
Style reconciliation cost exceeded plan budget.
Refs #321"Task 10: Rewrite apps/web/src/lib/offerService.js as the API client โ
Files:
Modify:
apps/web/src/lib/offerService.js(full rewrite)Create:
apps/web/src/lib/__tests__/offerService.test.jsModify:
.env.local.example[ ] Step 1: Write failing tests
Create apps/web/src/lib/__tests__/offerService.test.js:
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../apiClient', () => ({ authRequest: vi.fn() }))
vi.mock('../../firebase', () => ({ auth: { currentUser: { uid: 'u1' } } }))
import { fetchActiveOffers, getHeroOffer, getOffersByVenue, getVenueOffer } from '../offerService.js'
import { authRequest } from '../apiClient'
const baseOffer = (over = {}) => ({
id: 'o1', merchantId: 'm1', venueId: 'v1', title: 't', description: 'd',
placement: 'hero', targetAudience: 'nearby', radius: 1000,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
venue: { id: 'v1', name: 'V1', lat: 0, lng: 0, lanternCount: 0 },
distanceMeters: 100,
...over,
})
describe('offerService', () => {
beforeEach(() => authRequest.mockReset())
describe('fetchActiveOffers', () => {
it('hits /offers/active with lat/lng query', async () => {
authRequest.mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ offers: [baseOffer()], total: 1 }),
})
const offers = await fetchActiveOffers({ lat: 32.7, lng: -117.1 })
const url = authRequest.mock.calls[0][0]
expect(url).toContain('/offers/active')
expect(url).toContain('lat=32.7')
expect(url).toContain('lng=-117.1')
expect(offers).toHaveLength(1)
})
it('passes optional placement filter', async () => {
authRequest.mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ offers: [], total: 0 }),
})
await fetchActiveOffers({ lat: 0, lng: 0, placement: 'chat' })
expect(authRequest.mock.calls[0][0]).toContain('placement=chat')
})
it('returns [] on non-ok responses', async () => {
authRequest.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'oops' })
const offers = await fetchActiveOffers({ lat: 0, lng: 0 })
expect(offers).toEqual([])
})
})
describe('getHeroOffer', () => {
it('returns the closest hero-placement offer', () => {
const list = [
baseOffer({ id: 'a', placement: 'hero', distanceMeters: 500 }),
baseOffer({ id: 'b', placement: 'inline' }),
baseOffer({ id: 'c', placement: 'hero', distanceMeters: 100 }),
]
expect(getHeroOffer(list).id).toBe('c')
})
it('returns null when no hero offers', () => {
expect(getHeroOffer([baseOffer({ placement: 'inline' })])).toBe(null)
})
})
describe('getOffersByVenue', () => {
it('groups by venueId, picks closest per venue', () => {
const list = [
baseOffer({ id: 'x', venueId: 'v1', distanceMeters: 200 }),
baseOffer({ id: 'y', venueId: 'v1', distanceMeters: 50 }),
baseOffer({ id: 'z', venueId: 'v2', distanceMeters: 300 }),
]
const map = getOffersByVenue(list)
expect(map.v1.id).toBe('y')
expect(map.v2.id).toBe('z')
})
})
describe('getVenueOffer', () => {
it('returns closest offer for venueId', () => {
const list = [
baseOffer({ id: 'a', venueId: 'v1', distanceMeters: 100 }),
baseOffer({ id: 'b', venueId: 'v1', distanceMeters: 50 }),
]
expect(getVenueOffer('v1', list).id).toBe('b')
})
it('returns null when no offer for that venue', () => {
expect(getVenueOffer('v9', [baseOffer()])).toBe(null)
})
})
})- [ ] Step 2: Run, verify failure
npm test -w apps/web -- --run apps/web/src/lib/__tests__/offerService.test.jsExpected: failures referencing missing exports.
- [ ] Step 3: Rewrite
offerService.js
Replace apps/web/src/lib/offerService.js entirely:
/**
* Offer Service โ thin client for merchants-api /offers/active.
*
* Replaces the prior mock generator + setMerchantFakeOffers singleton.
* Geofence + audience filtering happens server-side; this module is fetch + select.
*
* @sdkCategory Merchant & Offers
*/
import { authRequest } from './apiClient'
const API_BASE_URL = import.meta.env.VITE_MERCHANTS_API_URL
/**
* Fetch active offers visible to the current user near (lat, lng).
* Returns [] on errors โ offers are non-critical UI.
*/
export async function fetchActiveOffers({ lat, lng, placement } = {}) {
if (typeof lat !== 'number' || typeof lng !== 'number') return []
const params = new URLSearchParams({ lat: String(lat), lng: String(lng) })
if (placement) params.set('placement', placement)
try {
const res = await authRequest(`${API_BASE_URL}/offers/active?${params}`)
if (!res.ok) return []
const text = await res.text()
const data = text ? JSON.parse(text) : {}
return Array.isArray(data.offers) ? data.offers : []
} catch {
return []
}
}
/**
* Pick the hero offer for a global slot โ closest hero-placement offer wins.
*/
export function getHeroOffer(offers = []) {
const heroes = offers.filter((o) => o.placement === 'hero')
if (heroes.length === 0) return null
return heroes.reduce((closest, o) =>
o.distanceMeters < closest.distanceMeters ? o : closest
)
}
/**
* Group offers by venueId, picking the closest per venue.
*/
export function getOffersByVenue(offers = []) {
const map = {}
for (const o of offers) {
if (!o.venueId) continue
const existing = map[o.venueId]
if (!existing || o.distanceMeters < existing.distanceMeters) {
map[o.venueId] = o
}
}
return map
}
/**
* Closest offer for a single venue, or null.
*/
export function getVenueOffer(venueId, offers = []) {
let best = null
for (const o of offers) {
if (o.venueId !== venueId) continue
if (!best || o.distanceMeters < best.distanceMeters) best = o
}
return best
}- [ ] Step 4: Run, verify pass
npm test -w apps/web -- --run apps/web/src/lib/__tests__/offerService.test.jsExpected: all tests pass.
- [ ] Step 5: Add
VITE_MERCHANTS_API_URLto.env.local.example
Read .env.local.example. Append near other VITE_*_API_URL entries:
# Merchants API (offers CRUD + public reads)
VITE_MERCHANTS_API_URL=http://localhost:8085Also ensure apps/web/.env.local.example is updated if it exists separately.
- [ ] Step 6: Commit
git add apps/web/src/lib/offerService.js \
apps/web/src/lib/__tests__/offerService.test.js \
.env.local.example
git commit -m "feat(web): rewrite offerService to call /offers/active
Removes the mock generator entirely. setMerchantFakeOffers gone.
Selectors operate on server-filtered data; closest distance wins.
Refs #139"Task 11: Update HomeView.jsx to fetch real offers โ
Files:
Modify:
apps/web/src/components/dashboard/HomeView.jsx[ ] Step 1: Locate the integration points
grep -n "getActiveOffers\|getHeroOffer\|getOffersByVenue\|userLocation" apps/web/src/components/dashboard/HomeView.jsx | head -20Note the geolocation state name used by HomeView (likely userLocation or similar).
- [ ] Step 2: Update the import
Replace:
import { getHeroOffer, getOffersByVenue } from '../../lib/offerService'with:
import { fetchActiveOffers, getHeroOffer, getOffersByVenue } from '../../lib/offerService'- [ ] Step 3: Replace synchronous derivation with state + effect
Find the lines:
const offersByVenue = useMemo(() => getOffersByVenue(venues), [venues])
const heroOffer = useMemo(() => getHeroOffer(venues), [venues])Replace with:
const [offers, setOffers] = useState([])
useEffect(() => {
if (!userLocation) return
let cancelled = false
const load = () => fetchActiveOffers({
lat: userLocation.latitude, lng: userLocation.longitude,
}).then((list) => { if (!cancelled) setOffers(list) })
load()
const onVisible = () => {
if (document.visibilityState === 'visible' && userLocation) load()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
cancelled = true
document.removeEventListener('visibilitychange', onVisible)
}
}, [userLocation?.latitude, userLocation?.longitude])
const offersByVenue = useMemo(() => getOffersByVenue(offers), [offers])
const heroOffer = useMemo(() => getHeroOffer(offers), [offers])(If userLocation is named differently, adjust.)
- [ ] Step 4: Run HomeView tests
npm test -w apps/web -- --run HomeViewExpected: green (or pre-existing failures unrelated to offers).
- [ ] Step 5: Manual smoke
npm run dev -w apps/web
npm run dev -w services/api/merchantsSpoof location near a venue with an active hero offer (created in admin). Confirm hero card shows real merchant data.
- [ ] Step 6: Commit
git add apps/web/src/components/dashboard/HomeView.jsx
git commit -m "feat(web): HomeView fetches real offers from /offers/active
Refetches on location change + tab visibility. Hero + per-venue inline
slots reflect live merchant data.
Refs #139"Task 12: Update VenueView.jsx to consume the same offer list โ
Files:
Modify:
apps/web/src/components/dashboard/VenueView.jsxModify: parent (HomeView or wrapper) to pass
offersprop[ ] Step 1: Read current usage
grep -n "getVenueOffer\|venueOffer" apps/web/src/components/dashboard/VenueView.jsx- [ ] Step 2: Take
offersas a prop
import { getVenueOffer } from '../../lib/offerService'
export default function VenueView({ venue, offers = [], /* existing props */ }) {
const venueOffer = getVenueOffer(venue.id, offers)
// ... rest unchanged
}- [ ] Step 3: Pass
offersfrom the parent that renders<VenueView>
Find the parent (HomeView or a wrapper). Pass the offers state from Task 11:
<VenueView venue={selectedVenue} offers={offers} /* existing props */ />- [ ] Step 4: Run tests
npm test -w apps/web -- --run VenueViewExpected: green.
- [ ] Step 5: Commit
git add apps/web/src/components/dashboard/VenueView.jsx \
apps/web/src/components/dashboard/HomeView.jsx
git commit -m "feat(web): VenueView reads offers from parent's fetched list
Avoids a second round-trip โ VenueView consumes the same /offers/active
result HomeView already fetched.
Refs #139"Task 13: Wire chat placement in Chat.jsx โ
Files:
Modify:
apps/web/src/components/Chat.jsx[ ] Step 1: Read Chat.jsx
grep -n "venueId\|connection.venueId\|return (\|userLocation\|location" apps/web/src/components/Chat.jsx | head -20Chat consumes connection.venueId (line ~161). Identify how Chat receives location (prop, context, or hook).
- [ ] Step 2: Add offer fetch keyed on
connection.venueId
Inside the component:
import { useEffect, useState } from 'react'
import { fetchActiveOffers } from '../lib/offerService'
import { AdSlot } from './dashboard/AdSlot'
// inside component, alongside existing state:
const [chatOffer, setChatOffer] = useState(null)
useEffect(() => {
if (!connection?.venueId || !userLocation) return
let cancelled = false
fetchActiveOffers({
lat: userLocation.latitude,
lng: userLocation.longitude,
placement: 'chat',
}).then((offers) => {
if (cancelled) return
setChatOffer(offers.find((o) => o.venueId === connection.venueId) || null)
})
return () => { cancelled = true }
}, [connection?.venueId, userLocation?.latitude, userLocation?.longitude])(Adjust userLocation to match Chat's actual state/prop name.)
- [ ] Step 3: Render slot at top of chat thread
In the JSX return, before the messages list:
{chatOffer && (
<div style={{ position: 'sticky', top: 0, zIndex: 5 }}>
<AdSlot offer={chatOffer} />
</div>
)}- [ ] Step 4: Storybook spot-check
npm run storybook -w apps/webOpen the Chat story; confirm no console errors.
- [ ] Step 5: Commit
git add apps/web/src/components/Chat.jsx
git commit -m "feat(web): wire chat placement to AdSlot in Chat.jsx
When a chat is anchored to a venue with an active placement='chat'
offer, render a sticky banner above the message thread.
Refs #321"Task 14: Wire feed placement in RecentActivityView.jsx โ
Files:
Modify:
apps/web/src/components/dashboard/RecentActivityView.jsx[ ] Step 1: Read RecentActivityView
grep -n "map\|return\|venueId\|userLocation\|activity" apps/web/src/components/dashboard/RecentActivityView.jsx | head -20Find the activity-row map and how it receives location.
- [ ] Step 2: Add feed-placement offer fetch
import { useEffect, useState } from 'react'
import { fetchActiveOffers } from '../../lib/offerService'
import { AdSlot } from './AdSlot'
// inside component:
const [feedOffers, setFeedOffers] = useState([])
useEffect(() => {
if (!userLocation) return
let cancelled = false
fetchActiveOffers({
lat: userLocation.latitude, lng: userLocation.longitude, placement: 'feed',
}).then((offers) => { if (!cancelled) setFeedOffers(offers) })
return () => { cancelled = true }
}, [userLocation?.latitude, userLocation?.longitude])- [ ] Step 3: Interleave AdSlots into the activity list
Replace the activity map with:
{(() => {
const seen = new Set()
const rendered = []
for (const activity of activities) {
rendered.push(<ActivityRow key={activity.id} activity={activity} />)
const matching = feedOffers.find((o) => o.venueId === activity.venueId && !seen.has(o.venueId))
if (matching) {
seen.add(matching.venueId)
rendered.push(<AdSlot key={`offer-${matching.id}`} offer={matching} />)
}
}
return rendered
})()}(Replace <ActivityRow ... /> with whatever the existing activity row component is named in the file.)
- [ ] Step 4: Run tests
npm test -w apps/web -- --run RecentActivityViewExpected: green.
- [ ] Step 5: Commit
git add apps/web/src/components/dashboard/RecentActivityView.jsx
git commit -m "feat(web): wire feed placement to AdSlot in RecentActivityView
Interleaves feed-placement offers after the first activity row for each
matching venue. One AdSlot per venue per render (Set-deduped).
Refs #321"Task 15: Stub cleanup โ delete web OfferForm + setMerchantFakeOffers โ
Files:
Delete:
apps/web/src/screens/merchant/OfferForm.jsxDelete:
apps/web/src/screens/merchant/OfferForm.stories.jsxModify:
apps/web/src/screens/merchant/MerchantDashboard.jsxModify:
apps/web/src/App.jsx(if#/merchant/newroute exists)[ ] Step 1: Confirm no callers remain
grep -rn "setMerchantFakeOffers\|from.*screens/merchant/OfferForm" apps/web/srcExpected: only the one site in MerchantDashboard.jsx.
- [ ] Step 2: Remove import + call from MerchantDashboard.jsx
Delete the import:
import { setMerchantFakeOffers } from '../../lib/offerService'Delete the call (around line 243):
setMerchantFakeOffers(fakeAds)If fakeAds becomes unused, remove its declaration. If the surrounding block becomes a no-op, remove it.
- [ ] Step 3: Remove
#/merchant/newroute from App.jsx (if present)
grep -n "#/merchant/new\|merchant/new" apps/web/src/App.jsxIf found, remove the route check + the OfferForm import.
- [ ] Step 4: Delete the OfferForm files
rm apps/web/src/screens/merchant/OfferForm.jsx
rm apps/web/src/screens/merchant/OfferForm.stories.jsx- [ ] Step 5: Run validation
npm run validate -- --workspace apps/webExpected: green.
- [ ] Step 6: Commit
git add -A apps/web/src/screens/merchant/ apps/web/src/App.jsx
git commit -m "chore(web): remove stub OfferForm + setMerchantFakeOffers
OfferForm now lives in the admin portal (sub-project #1, PR #334).
Refs #139, #321"Task 16: OpenAPI sync linter (closes #347) โ
Files:
Create:
tooling/scripts/lint.openapi-sync.jsCreate:
tooling/scripts/__tests__/lint.openapi-sync.test.jsModify:
tooling/scripts/validate.jsModify:
CLAUDE.md(Linter Organization note)[ ] Step 1: Inspect validate.js section pattern
grep -n "section\|scope\|--scope" tooling/scripts/validate.js | head -30Note the structure for adding a new lint section.
- [ ] Step 2: Inspect services index
cat packages/shared/services/index.js | head -80Note the service entry shape (ports, localPath, openapiPath, etc.).
- [ ] Step 3: Write failing tests
Create tooling/scripts/__tests__/lint.openapi-sync.test.js:
import { describe, it, expect } from 'vitest'
import { compareRoutes, normalizePath } from '../lint.openapi-sync.js'
describe('normalizePath', () => {
it('treats :param and {param} as equivalent', () => {
expect(normalizePath('/users/:id')).toBe(normalizePath('/users/{id}'))
})
it('preserves literal segments', () => {
expect(normalizePath('/users/:id/posts')).toBe('/users/{param}/posts')
})
})
describe('compareRoutes', () => {
it('returns no drift when sets match', () => {
const r = compareRoutes(
[{ method: 'GET', path: '/health' }],
[{ method: 'GET', path: '/health' }]
)
expect(r.implementedNotDocumented).toEqual([])
expect(r.documentedNotImplemented).toEqual([])
})
it('detects implemented-but-undocumented', () => {
const r = compareRoutes(
[{ method: 'GET', path: '/health' }, { method: 'POST', path: '/users' }],
[{ method: 'GET', path: '/health' }]
)
expect(r.implementedNotDocumented).toEqual([{ method: 'POST', path: '/users' }])
})
it('detects documented-but-unimplemented', () => {
const r = compareRoutes(
[{ method: 'GET', path: '/health' }],
[{ method: 'GET', path: '/health' }, { method: 'GET', path: '/ghost' }]
)
expect(r.documentedNotImplemented).toEqual([{ method: 'GET', path: '/ghost' }])
})
it('treats path-param syntax differences as a match', () => {
const r = compareRoutes(
[{ method: 'GET', path: '/users/:id' }],
[{ method: 'GET', path: '/users/{id}' }]
)
expect(r.implementedNotDocumented).toEqual([])
expect(r.documentedNotImplemented).toEqual([])
})
})- [ ] Step 4: Implement the linter
Create tooling/scripts/lint.openapi-sync.js:
#!/usr/bin/env node
/**
* OpenAPI sync linter.
* For every Cloud Run API service registered in packages/shared/services/index.js,
* compares (method, path) tuples between Express route declarations and openapi.json.
* Exits 0 if all in sync; non-zero with per-service drift report otherwise.
*/
import { readFileSync, readdirSync, statSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join, relative } from 'path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = join(__dirname, '..', '..')
// โโ Helpers (exported for tests) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export function normalizePath(p) {
return p
.replace(/:([A-Za-z_]+)/g, '{param}')
.replace(/\{([A-Za-z_]+)\}/g, '{param}')
}
export function compareRoutes(implemented, documented) {
const key = (r) => `${r.method.toUpperCase()} ${normalizePath(r.path)}`
const implMap = new Map(implemented.map((r) => [key(r), r]))
const docMap = new Map(documented.map((r) => [key(r), r]))
const implementedNotDocumented = []
const documentedNotImplemented = []
for (const [k, r] of implMap) if (!docMap.has(k)) implementedNotDocumented.push(r)
for (const [k, r] of docMap) if (!implMap.has(k)) documentedNotImplemented.push(r)
return { implementedNotDocumented, documentedNotImplemented }
}
// โโ Service discovery โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function loadServices() {
const mod = await import(join(repoRoot, 'packages/shared/services/index.js'))
const services = mod.default || mod.SERVICES || mod.services
if (!Array.isArray(services)) {
throw new Error('packages/shared/services/index.js does not have a default/SERVICES array export')
}
return services.filter((s) => s.localPath)
}
// โโ Express route extraction โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const ROUTE_RE = /router\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/g
function walkJs(dir, out = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (entry === 'node_modules' || entry === '__tests__') continue
const st = statSync(full)
if (st.isDirectory()) walkJs(full, out)
else if (entry.endsWith('.js')) out.push(full)
}
return out
}
function extractImplementedRoutes(serviceDir) {
const indexPath = join(serviceDir, 'src', 'index.js')
let indexSrc = ''
try { indexSrc = readFileSync(indexPath, 'utf8') } catch { return [] }
const routes = []
const routesDir = join(serviceDir, 'src', 'routes')
let routeFiles = []
try { routeFiles = walkJs(routesDir) } catch { return [] }
for (const file of routeFiles) {
const src = readFileSync(file, 'utf8')
// Find this router file's mount prefix in index.js by import name โ app.use match.
const rel = relative(routesDir, file).replace(/\.js$/, '')
const importLine = new RegExp(
`import\\s+(\\w+)\\s+from\\s+['"\`]\\.\\./routes/${rel.replace(/\//g, '\\/')}\\.js['"\`]`
)
const importMatch = indexSrc.match(importLine)
let prefix = ''
if (importMatch) {
const routerVar = importMatch[1]
const useRe = new RegExp(`app\\.use\\(\\s*['"\`]([^'"\`]+)['"\`]\\s*,[^,]*${routerVar}`)
const useMatch = indexSrc.match(useRe)
if (useMatch) prefix = useMatch[1]
}
let m
const re = new RegExp(ROUTE_RE.source, 'g')
while ((m = re.exec(src))) {
const subPath = m[2]
const fullPath = (prefix + subPath).replace(/\/+/g, '/')
routes.push({ method: m[1].toUpperCase(), path: fullPath })
}
}
return routes
}
function extractDocumentedRoutes(openapiPath) {
const spec = JSON.parse(readFileSync(openapiPath, 'utf8'))
const out = []
for (const [path, methods] of Object.entries(spec.paths || {})) {
for (const method of Object.keys(methods)) {
if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) {
out.push({ method: method.toUpperCase(), path })
}
}
}
return out
}
// โโ Main โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function main() {
const services = await loadServices()
let drift = false
for (const svc of services) {
const dir = join(repoRoot, svc.localPath)
const specPath = join(dir, 'openapi.json')
const implemented = extractImplementedRoutes(dir)
let documented = []
try { documented = extractDocumentedRoutes(specPath) }
catch { console.error(`[openapi-sync] ${svc.name}: missing/invalid openapi.json at ${specPath}`); drift = true; continue }
const cmp = compareRoutes(implemented, documented)
if (cmp.implementedNotDocumented.length || cmp.documentedNotImplemented.length) {
drift = true
console.error(`\n[openapi-sync] DRIFT in ${svc.name} (${svc.localPath}):`)
if (cmp.implementedNotDocumented.length) {
console.error(' Implemented but undocumented:')
for (const r of cmp.implementedNotDocumented) console.error(` ${r.method} ${r.path}`)
}
if (cmp.documentedNotImplemented.length) {
console.error(' Documented but unimplemented:')
for (const r of cmp.documentedNotImplemented) console.error(` ${r.method} ${r.path}`)
}
} else {
console.log(`[openapi-sync] ${svc.name}: OK`)
}
}
process.exit(drift ? 1 : 0)
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error('[openapi-sync] failed:', err); process.exit(2) })
}- [ ] Step 5: Run unit tests
npx vitest run tooling/scripts/__tests__/lint.openapi-sync.test.jsExpected: 5 tests pass.
- [ ] Step 6: Run linter against the live tree
node tooling/scripts/lint.openapi-sync.jsExpected: every service reports OK (merchants-api just fixed in Task 7). If another service has drift, that's a real finding โ file a follow-up issue, do not fix in this PR.
- [ ] Step 7: Wire into validate.js
Read tooling/scripts/validate.js. Add a section entry mirroring an existing one (likely an array literal of { scope, label, command } objects):
{
scope: 'openapi',
label: 'OpenAPI sync',
command: 'node tooling/scripts/lint.openapi-sync.js',
}Then verify the --scope filter handles openapi.
- [ ] Step 8: Test the validate scope filter
npm run validate -- --scope openapiExpected: runs only the openapi linter, green.
- [ ] Step 9: Update CLAUDE.md Linter Organization
In CLAUDE.md, find the "Linter Organization" section, append:
- `tooling/scripts/lint.openapi-sync.js` โ verifies every Cloud Run API service's `openapi.json` matches its actual Express routes- [ ] Step 10: Commit
git add tooling/scripts/lint.openapi-sync.js \
tooling/scripts/__tests__/lint.openapi-sync.test.js \
tooling/scripts/validate.js CLAUDE.md
git commit -m "feat(tooling): add openapi-sync linter (closes #347)
Compares (method, path) tuples between Express route declarations and
each service's openapi.json. Wired into npm run validate; supports
--scope openapi.
Closes #347"Task 17: Final validation + manual smoke โ
- [ ] Step 1: Run the full validate suite
npm run validateExpected: all green.
- [ ] Step 2: Manual end-to-end smoke
Spin up:
npm run dev -w services/api/merchants
npm run dev -w services/api/auth
npm run dev -w apps/web
npm run dev -w apps/adminWalk through:
- Admin portal: create a merchant offer with
placement: 'hero',targetAudience: 'nearby',radius: 1000, against a real venue. Save as active. - Web app: spoof location within the venue's radius via dev panel. Open dashboard โ confirm hero card shows real merchant title.
- Spoof location 5km away โ confirm hero card disappears (geofence works).
- Repeat with
placement: 'inline'โ confirmOfferPillrenders on the venue card. - Repeat with
placement: 'chat'โ enter a chat anchored to that venue โ confirmAdSlotrenders. - Repeat with
placement: 'feed'โ open RecentActivity โ confirm offer interleaves. - Audience
lantern: without lighting a lantern at the venue, offer is hidden. Light a lantern โ offer appears. - Audience
new: test as fresh account (createdAt < 7d) โ visible. Test as old account โ invisible. - Venue picker: in admin, search for "Swan Bar" โ appears.
- API docs: admin portal โ API Reference โ Merchants API. All 6 routes present.
- [ ] Step 3: Push the branch + ASK before opening the PR
Per CLAUDE.md Rule #11, push the branch then stop and ask the user before opening the PR:
git push -u origin feat/139-offers-targeting-web-wiringThen prompt the user with the proposed PR title + body for approval.
Proposed PR body:
## Summary
- Wire real merchant offers into the web app with server-side geofence + audience filtering
- Add public `GET /offers/active` endpoint to merchants-api
- Wire `chat` and `feed` placement consumer surfaces (components shipped previously by #322 but unused)
- Extract shared offer modules to `@lantern/shared`
- Fix venue picker bug (limit-500 hid venues like "Swan Bar")
- Document all merchants-api routes in openapi.json (was: only `/health`)
- Add openapi-sync linter to prevent the same drift in any service
## Test plan
- [ ] `npm run validate` green
- [ ] Hero/inline/chat/feed placements render with real merchant data when within geofence
- [ ] Outside-radius hides offers entirely
- [ ] `lantern`/`new` audiences gate correctly
- [ ] Venue picker finds previously-hidden venues
- [ ] Admin portal API Reference shows all 6 merchants-api routes
- [ ] Backfill `nameLower` against `lantern-app-dev` after merge (runbook step in LOCATION_STACK.md)
Closes #139
Closes #321
Closes #347
๐ค Generated with [Claude Code](https://claude.com/claude-code)Self-review โ
1. Spec coverage:
- Spec ยง1 New public-auth route โ Task 6 โ
- Spec ยง2 Server-side filtering โ Task 6 โ
- Spec ยง3 Audience semantics โ Task 5 + Task 6 โ
- Spec ยง4 Audience filter helper โ Task 5 โ
- Spec ยง5 Web app
offerService.jsrewrite โ Task 10 โ - Spec ยง6 Wire
chat/feedplacements โ Tasks 13, 14 โ - Spec ยง7 Extract to
@lantern/sharedโ Tasks 8, 9 (with Plan B fallback) โ - Spec ยง8 Stub cleanup โ Task 15 โ
- Spec ยง9 Venue picker bug โ Tasks 1, 2, 3 โ
- Spec ยง10 Merchants-api openapi.json โ Task 7 โ
- Spec ยง11 OpenAPI sync linter โ Task 16 โ
- HomeView/VenueView updates โ Tasks 11, 12 โ
- Test infrastructure (preq for Tasks 5โ6) โ Task 4 โ
All spec sections covered. โ
2. Placeholder scan: No "TBD"/"TODO"/"implement later". Discovery steps that say "find the parent" or "adjust to match the actual state name" are environment-discovery steps with grep commands, not placeholders. โ
3. Type consistency:
passesAudience(audience, userContext, offer)โ Task 5 def, Task 6 call โuserContext = { uid, accountAgeDays, hasActiveLanternAtVenue }โ consistent across Task 5 tests/impl, Task 6 impl โfetchActiveOffers({ lat, lng, placement? })โ Task 10 def, Tasks 11/12/13/14 calls โgetHeroOffer(offers)/getOffersByVenue(offers)/getVenueOffer(venueId, offers)โ Task 10 defs, Tasks 11/12 uses โ- Public offer response shape โ Task 6
serializeForClientmatches Task 10 test fixtures matches Task 7 openapi schema โ normalizeFormToOfferโ Task 8 extraction; signature unchanged โtoNameLowerโ Task 1 def, Tasks 1/2/3 uses โ
All consistent. โ