Merchant โ Venue Association โ 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: Introduce a merchant-entity concept (merchants/{merchantId} with m_* id) separate from user identity, and let admins associate venues with merchants via a picker on MerchantDetail.
Architecture: New Firestore collection merchants/{merchantId}; users/{uid} gains singular merchantId field; venues.merchantId referent changes from user uid to merchant id. Four new server endpoints under /auth/admin/merchants/*. Security rules indirect through new isMerchantOf() helper. Admin UI gets a new AdminVenuePicker component and MerchantDetail's Venue placeholder becomes a functional Venues section.
Tech Stack: Node 22, Express, Firebase Admin SDK, Firestore, zod v4, React 19, React Router v6, Vite, Tailwind v4.
Spec: docs/superpowers/specs/2026-04-22-merchant-venue-association-design.md
Decisions recap โ
- merchantId format:
m_{12 chars from crypto.randomBytes}โ no new npm dep (nanoidwas a placeholder in the spec; the repo already uses Node'scrypto). - Strict constraints: one venue = one merchant; user โ exactly one merchant; one merchant โ many venues; one merchant โ many users (future, via #231).
- Admin-assigned workflow only; no self-service claim.
- Pick existing venues only; no create-on-the-fly (venue onboarding is a separate follow-up feature).
- Delete the one existing test merchant. No migration code.
File structure โ
| File | Action | Responsibility |
|---|---|---|
services/api/auth/src/lib/merchantIds.js | create | Pure helper: mint m_* id |
services/api/auth/src/routes/adminUsers.js | modify | Update create/patch handlers to mint merchantId and route fields |
services/api/auth/src/routes/adminMerchants.js | create | New /auth/admin/merchants/* routes (list, detail, associate, disassociate) |
services/api/auth/src/index.js | modify | Mount adminMerchants router |
services/api/auth/openapi.json | modify | Document new routes |
services/api/auth/src/lib/__tests__/merchantIds.test.js | create | Unit tests for id generator |
firestore.rules | modify | Add isMerchantOf(), add merchants match, update venues update rule |
apps/admin/src/lib/authApi.js | modify | Add API client methods for new merchant endpoints |
apps/admin/src/firebase.js | modify | Rewire getMerchantData to new endpoint; add venue-assoc proxies |
apps/admin/src/components/venues/AdminVenuePicker.jsx | create | Inline picker used by MerchantDetail |
apps/admin/src/components/merchants/MerchantDetail.jsx | modify | Replace Venue placeholder with Venues section |
apps/admin/src/components/merchants/MerchantsAll.jsx | modify | Query merchants collection; add venue-count column |
apps/admin/src/components/merchants/CreateMerchantForm.jsx | modify | Navigate using merchantId from response |
Testing approach (realistic) โ
There is no test harness for services/api/auth/ (Vitest is configured in package.json, but no test files exist and there's no Firebase Admin SDK mock). There is no test harness for apps/admin/ either. Building those harnesses is worthwhile but out of scope โ it belongs in a separate follow-up ticket.
Per-task testing:
- Pure helpers (id generator, field-routing pure functions if any): real unit tests.
- Endpoint handlers: manual verification via
curlcommands included in each task. - Admin UI: manual verification steps in a browser included in each task.
At the end of the plan there's a file-a-followup-issue step to capture the test-harness gap.
Tasks โ
Task 1: Delete the existing test merchant and verify baseline โ
Files:
- No code changes. Firestore + Firebase Auth console work.
Context: Per Q4 of the spec, the single test merchant created during prior iterations is to be deleted. Start with a clean slate so the new merchantId-aware code has no uid-keyed legacy to handle.
- [ ] Step 1: Find the test merchant's uid
Run the admin app in dev, navigate to /merchants/all, note the email and uid of the sole listed merchant. (If nothing is listed, skip to Step 4.)
npm run dev -w apps/admin
# โ open http://localhost:3001/merchants/all
# Record uid from the URL after clicking into the merchant- [ ] Step 2: Delete Firestore docs
Use Firebase console (dev project lantern-app-dev) โ Firestore โ Data. Delete:
users/{testUid}merchantProfiles/{testUid}
Also search adminActions for any logs referencing the test merchant and leave them (audit trail stays).
- [ ] Step 3: Delete the Firebase Auth user
Firebase console (dev project) โ Authentication โ Users tab โ search by email โ delete.
- [ ] Step 4: Verify clean state
Reload /merchants/all in the admin app. Expected: empty-state rendering ("No merchants yet"). No errors in console.
- [ ] Step 5: Commit a note-only change to mark baseline
No file changes โ skip the commit. Proceed to Task 2.
Task 2: Add the merchantId generator helper โ
Files:
- Create:
services/api/auth/src/lib/merchantIds.js - Create:
services/api/auth/src/lib/__tests__/merchantIds.test.js
Why: Centralize id minting so format changes (e.g. moving to nanoid later) happen in one place. Pure function โ testable without Firebase.
- [ ] Step 1: Write the failing test
Create services/api/auth/src/lib/__tests__/merchantIds.test.js:
import { describe, it, expect } from 'vitest'
import { generateMerchantId } from '../merchantIds.js'
describe('generateMerchantId', () => {
it('starts with m_', () => {
expect(generateMerchantId()).toMatch(/^m_/)
})
it('has the expected total length (2 prefix + 12 chars)', () => {
expect(generateMerchantId()).toHaveLength(14)
})
it('produces URL-safe characters only', () => {
// base64url: A-Z a-z 0-9 - _
expect(generateMerchantId()).toMatch(/^m_[A-Za-z0-9_-]{12}$/)
})
it('is unique across many calls', () => {
const seen = new Set()
for (let i = 0; i < 1000; i++) seen.add(generateMerchantId())
expect(seen.size).toBe(1000)
})
})- [ ] Step 2: Run tests to verify they fail
Run: npm test -- --run -w services/api/auth src/lib/__tests__/merchantIds.test.js Expected: FAIL with "Cannot find module '../merchantIds.js'" or similar.
- [ ] Step 3: Write the minimal implementation
Create services/api/auth/src/lib/merchantIds.js:
import crypto from 'crypto'
/**
* Generate a new merchant id. Format: `m_` + 12 base64url characters.
* Example: `m_V1StGXR8_Z5j`.
*
* Uses Node's crypto.randomBytes so no new npm dependency is required.
* 9 bytes โ 12 base64url chars (no padding needed at this length).
*/
export function generateMerchantId() {
return `m_${crypto.randomBytes(9).toString('base64url')}`
}- [ ] Step 4: Run tests to verify they pass
Run: npm test -- --run -w services/api/auth src/lib/__tests__/merchantIds.test.js Expected: 4 passing.
- [ ] Step 5: Commit
git add services/api/auth/src/lib/merchantIds.js services/api/auth/src/lib/__tests__/merchantIds.test.js
git commit -m "$(cat <<'EOF'
feat(auth-api): add generateMerchantId helper
Mints m_-prefixed 14-char ids for the merchant entity collection.
Uses Node's crypto; no new dep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 3: Rewrite POST /auth/admin/users/merchant to create the merchant entity โ
Files:
- Modify:
services/api/auth/src/routes/adminUsers.jsโ updateCreateMerchantBodyzod schema, rewrite therouter.post('/merchant', ...)handler at lines 268โ375.
Context: Per the spec, the create flow now mints a merchantId, writes a merchants/{merchantId} doc, sets users/{uid}.merchantId, removes businessName from merchantProfiles, sets Firebase Auth displayName = contactName, and rolls back on Firestore-batch failure. Promotion path preserved.
- [ ] Step 1: Update the zod schema for CreateMerchantBody
In services/api/auth/src/routes/adminUsers.js, replace lines 28โ36:
// Before:
const CreateMerchantBody = z.object({
email: z.string().email(),
displayName: z.string().min(1),
businessName: z.string().optional(),
contactName: z.string().optional(),
phone: z.string().optional(),
notes: z.string().optional(),
sendInvite: z.boolean().optional(),
})โฆwith:
// After: businessName + contactName now required; displayName removed
// (server computes displayName = contactName for Firebase Auth).
const CreateMerchantBody = z.object({
email: z.string().email(),
businessName: z.string().min(1),
contactName: z.string().min(1),
phone: z.string().optional(),
notes: z.string().optional(),
sendInvite: z.boolean().optional(),
})- [ ] Step 2: Add the generateMerchantId import at the top of adminUsers.js
Near the top (after existing imports, around line 15):
import { generateMerchantId } from '../lib/merchantIds.js'- [ ] Step 3: Rewrite the POST /merchant handler
Replace lines 268โ375 (the entire existing router.post('/merchant', ...) block) with:
// โโ POST /auth/admin/users/merchant โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
router.post('/merchant', async (req, res, next) => {
let createdAuthUid = null // tracked for rollback if Firestore batch fails
try {
const body = CreateMerchantBody.parse(req.body)
const callerUid = req.user.uid
const auth = getAuth()
const db = getFirestore()
const email = body.email.trim()
const businessName = body.businessName.trim()
const contactName = body.contactName.trim()
const phone = body.phone?.trim() || ''
const notes = body.notes?.trim() || ''
// โโ 1. Resolve target user (promotion vs fresh create) โโโโโโโโโโโโโโโ
let targetUser
let isPromotion = false
try {
targetUser = await auth.getUserByEmail(email)
isPromotion = true
} catch (err) {
if (err.code !== 'auth/user-not-found') throw err
targetUser = await auth.createUser({ email, displayName: contactName })
createdAuthUid = targetUser.uid // track for rollback
}
// โโ 2. Guard: if promoting, user must not already be a merchant โโโโโโ
if (isPromotion) {
const existingSnap = await db.collection('users').doc(targetUser.uid).get()
if (existingSnap.exists && existingSnap.data().merchantId) {
return res.status(409).json({
error: 'ALREADY_MERCHANT',
message: 'User is already associated with a merchant',
})
}
}
// โโ 3. Mint merchantId and set custom claim โโโโโโโโโโโโโโโโโโโโโโโโโโ
const merchantId = generateMerchantId()
await auth.setCustomUserClaims(targetUser.uid, { role: 'merchant' })
// โโ 4. Firestore batch: merchants + users + merchantProfiles โโโโโโโโโ
const batch = db.batch()
const now = FieldValue.serverTimestamp()
batch.set(db.collection('merchants').doc(merchantId), {
businessName,
status: 'pending_setup',
createdAt: now,
createdBy: callerUid,
venueIds: [],
ownerUserIds: [targetUser.uid],
})
if (isPromotion) {
batch.update(db.collection('users').doc(targetUser.uid), {
role: 'merchant',
merchantId,
promotedToMerchantAt: now,
promotedBy: callerUid,
})
} else {
batch.set(db.collection('users').doc(targetUser.uid), {
email,
role: 'merchant',
displayName: contactName,
merchantId,
createdAt: now,
createdBy: callerUid,
})
}
batch.set(
db.collection('merchantProfiles').doc(targetUser.uid),
{
contactName,
phone,
notes,
status: 'pending_setup',
createdAt: now,
createdBy: callerUid,
},
{ merge: true }
)
await batch.commit()
// Past this point, no more rollback of the Auth user: Firestore is consistent.
createdAuthUid = null
// โโ 5. Ensure Firebase Auth displayName matches contactName โโโโโโโโโโ
if (targetUser.displayName !== contactName) {
await auth.updateUser(targetUser.uid, { displayName: contactName })
}
// โโ 6. Invite email (new accounts only) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let resetLink = null
let emailSent = false
const isProd = process.env.GOOGLE_CLOUD_PROJECT === 'lantern-app-prod'
if (!isPromotion) {
resetLink = await auth.generatePasswordResetLink(email, {
url: isProd ? 'https://admin.ourlantern.app' : 'https://admin.dev.ourlantern.app',
})
if (body.sendInvite !== false && resetLink) {
let inviterName = 'The Lantern Team'
try {
const callerProfile = await db.collection('adminProfiles').doc(callerUid).get()
if (callerProfile.exists) inviterName = callerProfile.data().displayName || inviterName
} catch {
/* use default */
}
const emailResult = await sendMerchantInviteEmail({
apiKey: process.env.RESEND_API_KEY,
toEmail: email,
displayName: contactName,
businessName,
resetLink,
inviterName,
})
emailSent = emailResult.success
}
}
// โโ 7. Audit โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
await db.collection('adminActions').add({
action: isPromotion ? 'promoteToMerchant' : 'createMerchantUser',
targetUserId: targetUser.uid,
targetEmail: email,
merchantId,
performedBy: callerUid,
performedAt: FieldValue.serverTimestamp(),
})
return res.status(201).json({
userId: targetUser.uid,
merchantId,
email,
wasPromotion: isPromotion,
emailSent,
resetLink,
})
} catch (err) {
// Rollback an orphaned Auth user if the Firestore batch failed.
if (createdAuthUid) {
try {
await getAuth().deleteUser(createdAuthUid)
} catch (rollbackErr) {
// Log but don't mask the original error.
req.log?.error({ err: rollbackErr, uid: createdAuthUid }, 'rollback failed')
}
}
next(err)
}
})- [ ] Step 4: Restart the auth API (node --watch caveat)
Per CLAUDE.md, node --watch may not pick up new routes. Restart the running auth-api terminal:
Ctrl+C in the auth-api terminal, then: npm run dev -w services/api/auth- [ ] Step 5: Manually verify the new endpoint
With a valid admin bearer token (AUTH_TOKEN) and App Check token (APP_CHECK):
curl -i -X POST http://localhost:8084/auth/admin/users/merchant \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "X-Firebase-AppCheck: $APP_CHECK" \
-H "Content-Type: application/json" \
-d '{"email":"merchant1@example.com","businessName":"Cafe Luna","contactName":"John Smith"}'Expected: HTTP 201; response includes "merchantId":"m_...","userId":"...","wasPromotion":false.
Then in Firestore console, verify:
merchants/{merchantId}doc exists withbusinessName: "Cafe Luna",ownerUserIds: [uid],venueIds: [].users/{uid}hasmerchantId: "m_...",displayName: "John Smith".merchantProfiles/{uid}hascontactName: "John Smith", nobusinessNamefield.[ ] Step 6: Verify the promotion-guard 409
Call the same endpoint twice with the same email. The second call should return 409 with "error":"ALREADY_MERCHANT".
- [ ] Step 7: Commit
git add services/api/auth/src/routes/adminUsers.js
git commit -m "$(cat <<'EOF'
feat(auth-api): POST /users/merchant mints merchantId + creates entity doc
- Required fields now businessName + contactName; displayName removed
from body (server computes from contactName for Firebase Auth).
- Writes new merchants/{merchantId} doc with denormalized venueIds/
ownerUserIds arrays.
- users/{uid} gains singular merchantId field.
- merchantProfiles/{uid} no longer holds businessName (narrowed to
per-user fields).
- Firestore batch rollback deletes the Auth user if the batch fails,
fixing a latent orphan risk in the prior flow.
- Promotion path: 409 if user already has merchantId (enforces strict
userโmerchant one-to-one).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 4: Rewrite PATCH /auth/admin/users/merchant/:userId field routing โ
Files:
- Modify:
services/api/auth/src/routes/adminUsers.jsโ updateUpdateMerchantBodyand the PATCH handler at lines 381โ445.
Context: businessName now lives on merchants/{merchantId}, not merchantProfiles. displayName mirrors contactName rather than being a separate field.
- [ ] Step 1: Update UpdateMerchantBody zod schema
Replace lines 40โ46 in adminUsers.js:
// After: displayName removed; businessName, contactName, phone, notes remain optional.
const UpdateMerchantBody = z.object({
businessName: z.string().min(1).optional(),
contactName: z.string().min(1).optional(),
phone: z.string().optional(),
notes: z.string().optional(),
})- [ ] Step 2: Rewrite the PATCH handler
Replace lines 381โ445 with:
// โโ PATCH /auth/admin/users/merchant/:userId โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Partial update of an existing merchant. Email and role are NOT editable
// here โ email changes need Firebase Auth's verification flow; role changes
// belong in a dedicated promote/demote path. businessName lives on the
// merchants/{merchantId} doc; contactName/phone/notes live on
// merchantProfiles/{uid}; Firebase Auth displayName mirrors contactName.
router.patch('/merchant/:userId', async (req, res, next) => {
try {
const { userId } = req.params
const body = UpdateMerchantBody.parse(req.body)
const callerUid = req.user.uid
const auth = getAuth()
const db = getFirestore()
const userSnap = await db.collection('users').doc(userId).get()
if (!userSnap.exists) {
return res.status(404).json({ error: 'NOT_FOUND', message: 'Merchant not found' })
}
const userData = userSnap.data()
if (userData.role !== 'merchant') {
return res.status(422).json({ error: 'NOT_MERCHANT', message: 'User is not a merchant' })
}
if (!userData.merchantId) {
return res.status(409).json({
error: 'MISSING_MERCHANT_ID',
message: 'User has role=merchant but no merchantId; data repair required',
})
}
const trimmed = (v) => (typeof v === 'string' ? v.trim() : v)
const now = FieldValue.serverTimestamp()
const batch = db.batch()
let writes = 0
// โโ merchants/{merchantId} โ businessName only โโโโโโโโโโโโโโโโโโโโโโ
if (body.businessName !== undefined) {
batch.update(db.collection('merchants').doc(userData.merchantId), {
businessName: trimmed(body.businessName),
updatedAt: now,
updatedBy: callerUid,
})
writes++
}
// โโ merchantProfiles/{uid} โ contactName, phone, notes โโโโโโโโโโโโโโ
const profileUpdates = {}
for (const key of ['contactName', 'phone', 'notes']) {
if (body[key] !== undefined) profileUpdates[key] = trimmed(body[key])
}
if (Object.keys(profileUpdates).length > 0) {
profileUpdates.updatedAt = now
profileUpdates.updatedBy = callerUid
batch.set(db.collection('merchantProfiles').doc(userId), profileUpdates, { merge: true })
writes++
}
// โโ users/{uid} โ displayName mirror if contactName changed โโโโโโโโโโ
if (body.contactName !== undefined) {
batch.update(db.collection('users').doc(userId), {
displayName: trimmed(body.contactName),
updatedAt: now,
updatedBy: callerUid,
})
writes++
}
if (writes > 0) await batch.commit()
// Firebase Auth displayName also mirrors contactName.
if (body.contactName !== undefined) {
await auth.updateUser(userId, { displayName: trimmed(body.contactName) })
}
await db.collection('adminActions').add({
action: 'updateMerchantUser',
targetUserId: userId,
merchantId: userData.merchantId,
performedBy: callerUid,
performedAt: FieldValue.serverTimestamp(),
updatedFields: Object.keys(body),
})
return res.json({ userId, merchantId: userData.merchantId, success: true })
} catch (err) {
next(err)
}
})- [ ] Step 3: Restart auth-api
Ctrl+C + npm run dev -w services/api/auth.
- [ ] Step 4: Manually verify
Using the merchant uid from Task 3:
curl -i -X PATCH "http://localhost:8084/auth/admin/users/merchant/$UID" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "X-Firebase-AppCheck: $APP_CHECK" \
-H "Content-Type: application/json" \
-d '{"businessName":"Cafe Luna Renamed","contactName":"Jane Smith"}'Expected: HTTP 200; {"success":true,"merchantId":"m_..."}. In Firestore:
merchants/{merchantId}.businessNamenow"Cafe Luna Renamed".merchantProfiles/{uid}.contactNamenow"Jane Smith". NobusinessNamefield.users/{uid}.displayNamenow"Jane Smith".[ ] Step 5: Commit
git add services/api/auth/src/routes/adminUsers.js
git commit -m "$(cat <<'EOF'
feat(auth-api): PATCH merchant routes businessName to merchants doc
Field routing split:
- businessName โ merchants/{merchantId}
- contactName / phone / notes โ merchantProfiles/{uid}
- displayName on users/{uid} + Firebase Auth mirrors contactName
Returns 409 if user has role=merchant but no merchantId (should not
happen with the new create flow, but surface clearly if data is stale).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 5: Create adminMerchants router + GET /auth/admin/merchants (list) โ
Files:
- Create:
services/api/auth/src/routes/adminMerchants.js - Modify:
services/api/auth/src/index.jsโ import + mount the router
Context: New route file for merchant-entity operations (list, detail, venue associate/disassociate). Keeping them separate from adminUsers.js avoids further bloating that file.
- [ ] Step 1: Create the new router file with the list endpoint
Create services/api/auth/src/routes/adminMerchants.js:
/**
* Admin merchant-entity routes (admin only).
*
* GET /auth/admin/merchants โ list merchants
* GET /auth/admin/merchants/:merchantId โ merchant detail
* POST /auth/admin/merchants/:merchantId/venues โ associate venue
* DELETE /auth/admin/merchants/:merchantId/venues/:venueId โ disassociate venue
*/
import { Router } from 'express'
import { getFirestore, FieldValue } from 'firebase-admin/firestore'
import { z } from 'zod'
const router = Router()
// โโ GET /auth/admin/merchants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// List merchants with primary-owner join and venue count.
router.get('/', async (req, res, next) => {
try {
const db = getFirestore()
const pageSize = Math.min(parseInt(req.query.pageSize, 10) || 25, 100)
const statusFilter = req.query.status // optional: 'active' | 'pending_setup' | 'suspended'
const startAfter = req.query.startAfter // merchantId cursor
let query = db.collection('merchants').orderBy('createdAt', 'desc')
if (statusFilter) query = query.where('status', '==', statusFilter)
if (startAfter) {
const cursorSnap = await db.collection('merchants').doc(startAfter).get()
if (cursorSnap.exists) query = query.startAfter(cursorSnap)
}
query = query.limit(pageSize)
const merchantsSnap = await query.get()
// Collect all primary owner uids (first entry of ownerUserIds[]) for one
// batched fetch rather than N per-merchant reads.
const primaryOwnerUids = merchantsSnap.docs
.map((d) => (d.data().ownerUserIds || [])[0])
.filter(Boolean)
const ownerDocs = new Map()
if (primaryOwnerUids.length > 0) {
const ownerSnaps = await db.getAll(
...primaryOwnerUids.map((uid) => db.collection('users').doc(uid))
)
for (const snap of ownerSnaps) {
if (snap.exists) ownerDocs.set(snap.id, snap.data())
}
}
const items = merchantsSnap.docs.map((doc) => {
const data = doc.data()
const primaryOwnerUid = (data.ownerUserIds || [])[0]
const primaryOwner = primaryOwnerUid ? ownerDocs.get(primaryOwnerUid) : null
return {
merchantId: doc.id,
businessName: data.businessName,
status: data.status,
venueCount: (data.venueIds || []).length,
ownerCount: (data.ownerUserIds || []).length,
createdAt: data.createdAt?.toDate?.()?.toISOString() || null,
primaryOwner: primaryOwner
? {
uid: primaryOwnerUid,
email: primaryOwner.email,
displayName: primaryOwner.displayName,
}
: null,
}
})
const lastDoc = merchantsSnap.docs[merchantsSnap.docs.length - 1]
return res.json({
items,
nextCursor: lastDoc && items.length === pageSize ? lastDoc.id : null,
})
} catch (err) {
next(err)
}
})
export default router- [ ] Step 2: Mount the router in services/api/auth/src/index.js
Add the import near the other route imports (around line 29):
import adminMerchantsRoutes from './routes/adminMerchants.js'Register the route in the adminDispatch block. Add this line just after line 111 (adminDispatch.use('/users', verifyFirebaseToken, requireAdmin, adminUsersRoutes)):
adminDispatch.use('/merchants', verifyFirebaseToken, requireAdmin, adminMerchantsRoutes)- [ ] Step 3: Restart auth-api
Ctrl+C + npm run dev -w services/api/auth.
- [ ] Step 4: Manually verify
curl -s "http://localhost:8084/auth/admin/merchants" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "X-Firebase-AppCheck: $APP_CHECK" | jqExpected: {"items":[{"merchantId":"m_...","businessName":"Cafe Luna Renamed",...}],"nextCursor":null}.
- [ ] Step 5: Commit
git add services/api/auth/src/routes/adminMerchants.js services/api/auth/src/index.js
git commit -m "$(cat <<'EOF'
feat(auth-api): GET /auth/admin/merchants lists merchants
New adminMerchants router, mounted under /auth/admin/merchants. List
endpoint returns each merchant with primary-owner join (email, display
name) and venue count, plus pagination cursor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 6: GET /auth/admin/merchants/:merchantId (detail) โ
Files:
- Modify:
services/api/auth/src/routes/adminMerchants.jsโ add detail endpoint.
Context: Returns merchant doc + all owner users + all associated venues in one round-trip for the MerchantDetail page.
- [ ] Step 1: Append the detail endpoint to adminMerchants.js
Above export default router, add:
// โโ GET /auth/admin/merchants/:merchantId โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
router.get('/:merchantId', async (req, res, next) => {
try {
const { merchantId } = req.params
const db = getFirestore()
const merchantSnap = await db.collection('merchants').doc(merchantId).get()
if (!merchantSnap.exists) {
return res.status(404).json({ error: 'NOT_FOUND', message: 'Merchant not found' })
}
const merchant = merchantSnap.data()
// Parallel-fetch owner user docs, owner profile docs, and venue docs.
const ownerUids = merchant.ownerUserIds || []
const venueIds = merchant.venueIds || []
const [ownerUserSnaps, ownerProfileSnaps, venueSnaps] = await Promise.all([
ownerUids.length
? db.getAll(...ownerUids.map((u) => db.collection('users').doc(u)))
: Promise.resolve([]),
ownerUids.length
? db.getAll(...ownerUids.map((u) => db.collection('merchantProfiles').doc(u)))
: Promise.resolve([]),
venueIds.length
? db.getAll(...venueIds.map((v) => db.collection('venues').doc(v)))
: Promise.resolve([]),
])
const profileByUid = new Map(
ownerProfileSnaps.filter((s) => s.exists).map((s) => [s.id, s.data()])
)
const owners = ownerUserSnaps
.filter((s) => s.exists)
.map((s) => {
const userData = s.data()
const profile = profileByUid.get(s.id) || {}
return {
uid: s.id,
email: userData.email,
displayName: userData.displayName,
contactName: profile.contactName || null,
phone: profile.phone || null,
notes: profile.notes || null,
}
})
const venues = venueSnaps
.filter((s) => s.exists)
.map((s) => {
const v = s.data()
return {
venueId: s.id,
name: v.name,
address: v.address,
category: v.category,
}
})
return res.json({
merchant: {
merchantId,
businessName: merchant.businessName,
status: merchant.status,
createdAt: merchant.createdAt?.toDate?.()?.toISOString() || null,
venueCount: venueIds.length,
ownerCount: ownerUids.length,
},
owners,
venues,
})
} catch (err) {
next(err)
}
})- [ ] Step 2: Restart auth-api
Ctrl+C + npm run dev -w services/api/auth.
- [ ] Step 3: Manually verify
curl -s "http://localhost:8084/auth/admin/merchants/$MERCHANT_ID" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "X-Firebase-AppCheck: $APP_CHECK" | jqExpected: JSON with merchant, owners: [{uid, email, displayName, contactName, phone, notes}], venues: [].
- [ ] Step 4: Commit
git add services/api/auth/src/routes/adminMerchants.js
git commit -m "$(cat <<'EOF'
feat(auth-api): GET /auth/admin/merchants/:merchantId detail endpoint
Parallel-fetches merchant doc, owner users, owner profiles, and venues
in one server round-trip. Returns shaped data for MerchantDetail page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 7: POST /auth/admin/merchants/:merchantId/venues (associate) โ
Files:
- Modify:
services/api/auth/src/routes/adminMerchants.jsโ add associate endpoint.
Context: Atomic batch sets venues/{venueId}.merchantId AND pushes venueId to merchants.venueIds[]. Guards: venue must be unclaimed; merchant must exist. 409 on conflict.
- [ ] Step 1: Add zod schema and endpoint
In adminMerchants.js, after the existing imports but before const router = Router():
const AssociateVenueBody = z.object({
venueId: z.string().min(1),
})Above export default router, add:
// โโ POST /auth/admin/merchants/:merchantId/venues โโโโโโโโโโโโโโโโโโโโโโโโ
// Associate a venue with a merchant. Atomic batch: sets venue.merchantId
// AND adds venueId to merchant.venueIds[]. Guards enforce strict one
// venue โ one merchant.
router.post('/:merchantId/venues', async (req, res, next) => {
try {
const { merchantId } = req.params
const body = AssociateVenueBody.parse(req.body)
const callerUid = req.user.uid
const db = getFirestore()
const merchantRef = db.collection('merchants').doc(merchantId)
const venueRef = db.collection('venues').doc(body.venueId)
const [merchantSnap, venueSnap] = await Promise.all([merchantRef.get(), venueRef.get()])
if (!merchantSnap.exists) {
return res.status(404).json({ error: 'MERCHANT_NOT_FOUND', message: 'Merchant not found' })
}
if (!venueSnap.exists) {
return res.status(404).json({ error: 'VENUE_NOT_FOUND', message: 'Venue not found' })
}
const existingMerchantId = venueSnap.data().merchantId
if (existingMerchantId && existingMerchantId !== merchantId) {
return res.status(409).json({
error: 'VENUE_ALREADY_CLAIMED',
message: 'Venue is already associated with another merchant',
claimedByMerchantId: existingMerchantId,
})
}
if (existingMerchantId === merchantId) {
// Idempotent success: already associated.
return res.json({ merchantId, venueId: body.venueId, alreadyAssociated: true })
}
const batch = db.batch()
batch.update(venueRef, { merchantId, updatedAt: FieldValue.serverTimestamp() })
batch.update(merchantRef, {
venueIds: FieldValue.arrayUnion(body.venueId),
updatedAt: FieldValue.serverTimestamp(),
updatedBy: callerUid,
})
await batch.commit()
await db.collection('adminActions').add({
action: 'associateVenue',
merchantId,
venueId: body.venueId,
performedBy: callerUid,
performedAt: FieldValue.serverTimestamp(),
})
return res.status(201).json({ merchantId, venueId: body.venueId, alreadyAssociated: false })
} catch (err) {
next(err)
}
})- [ ] Step 2: Restart auth-api
Ctrl+C + npm run dev -w services/api/auth.
- [ ] Step 3: Seed a test venue and verify associate
In Firestore console, create a venue doc with minimum fields:
venues/{newDocId}
name: "Cafe Luna"
address: "123 Main St"
lat: 40.7128
lng: -74.0060
category: "cafe"
source: "manual"
activeLanternCount: 0
createdAt: (timestamp)Then:
curl -i -X POST "http://localhost:8084/auth/admin/merchants/$MERCHANT_ID/venues" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "X-Firebase-AppCheck: $APP_CHECK" \
-H "Content-Type: application/json" \
-d "{\"venueId\":\"$VENUE_ID\"}"Expected: HTTP 201; {"merchantId":"m_...","venueId":"...","alreadyAssociated":false}. In Firestore:
venues/{VENUE_ID}.merchantId=== MERCHANT_ID.merchants/{MERCHANT_ID}.venueIdscontains VENUE_ID.
Try the call again โ expect 200 with alreadyAssociated:true.
- [ ] Step 4: Verify conflict path
Create a second merchant (curl POST to /users/merchant with new email + businessName). Then try to associate the same venue with the second merchant:
Expected: HTTP 409; {"error":"VENUE_ALREADY_CLAIMED","claimedByMerchantId":"m_..."}.
- [ ] Step 5: Commit
git add services/api/auth/src/routes/adminMerchants.js
git commit -m "$(cat <<'EOF'
feat(auth-api): POST merchants/:merchantId/venues associates a venue
Atomic batch updates venues.merchantId AND merchants.venueIds[] in one
commit. Guards: merchant+venue must exist; 409 if venue already claimed
by another merchant; idempotent 200 if same merchant claims it twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 8: DELETE /auth/admin/merchants/:merchantId/venues/:venueId (disassociate) โ
Files:
Modify:
services/api/auth/src/routes/adminMerchants.jsโ add disassociate endpoint.[ ] Step 1: Append the disassociate endpoint
Above export default router in adminMerchants.js:
// โโ DELETE /auth/admin/merchants/:merchantId/venues/:venueId โโโโโโโโโโโโโ
router.delete('/:merchantId/venues/:venueId', async (req, res, next) => {
try {
const { merchantId, venueId } = req.params
const callerUid = req.user.uid
const db = getFirestore()
const merchantRef = db.collection('merchants').doc(merchantId)
const venueRef = db.collection('venues').doc(venueId)
const [merchantSnap, venueSnap] = await Promise.all([merchantRef.get(), venueRef.get()])
if (!merchantSnap.exists) {
return res.status(404).json({ error: 'MERCHANT_NOT_FOUND', message: 'Merchant not found' })
}
if (!venueSnap.exists) {
return res.status(404).json({ error: 'VENUE_NOT_FOUND', message: 'Venue not found' })
}
// Guard against accidentally clearing another merchant's association.
if (venueSnap.data().merchantId !== merchantId) {
return res.status(409).json({
error: 'VENUE_NOT_ASSOCIATED',
message: 'Venue is not associated with this merchant',
actualMerchantId: venueSnap.data().merchantId || null,
})
}
const batch = db.batch()
batch.update(venueRef, {
merchantId: FieldValue.delete(),
updatedAt: FieldValue.serverTimestamp(),
})
batch.update(merchantRef, {
venueIds: FieldValue.arrayRemove(venueId),
updatedAt: FieldValue.serverTimestamp(),
updatedBy: callerUid,
})
await batch.commit()
await db.collection('adminActions').add({
action: 'disassociateVenue',
merchantId,
venueId,
performedBy: callerUid,
performedAt: FieldValue.serverTimestamp(),
})
return res.json({ merchantId, venueId, disassociated: true })
} catch (err) {
next(err)
}
})[ ] Step 2: Restart auth-api
[ ] Step 3: Manually verify
curl -i -X DELETE "http://localhost:8084/auth/admin/merchants/$MERCHANT_ID/venues/$VENUE_ID" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "X-Firebase-AppCheck: $APP_CHECK"Expected: HTTP 200; {"disassociated":true}. In Firestore: venues/{VENUE_ID}.merchantId gone; merchants/{MERCHANT_ID}.venueIds no longer contains VENUE_ID.
Try again โ expect 409 VENUE_NOT_ASSOCIATED.
- [ ] Step 4: Commit
git add services/api/auth/src/routes/adminMerchants.js
git commit -m "$(cat <<'EOF'
feat(auth-api): DELETE merchants/:merchantId/venues/:venueId disassociates
Atomic batch clears venues.merchantId AND arrayRemove from merchants.venueIds.
Guard prevents disassociating a venue that belongs to a different merchant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 9: Update Firestore security rules โ
Files:
- Modify:
firestore.rulesโ addisMerchantOf()helper, addmerchants/{merchantId}match, updatevenues/{venueId}update rule.
Context: Per Section 2 of the spec. Rules are defense-in-depth; server endpoints with Admin SDK are the primary authorization.
- [ ] Step 1: Add isMerchantOf helper
In firestore.rules, insert these functions after the existing isMerchant() at line 46:
// Look up the caller's merchantId from users/{uid} โ used by venue rules
// and merchant-doc read rules. One get() per rule evaluation (cached).
function userMerchantId() {
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.merchantId;
}
function isMerchantOf(merchantId) {
return request.auth != null && userMerchantId() == merchantId;
}- [ ] Step 2: Add merchants collection match block
Insert after the existing merchantProfiles match block (after line 86 โ the closing } of match /merchantProfiles/{userId}):
// Merchant business entities. Admins read all; a merchant user reads
// their own merchant doc. All writes flow through the server (Admin SDK);
// clients can never write directly.
match /merchants/{merchantId} {
allow read: if isAdmin() || isMerchantOf(merchantId);
allow create, update, delete: if false;
}- [ ] Step 3: Update the venues update rule
In firestore.rules, replace lines 351โ354 (the existing allow update: block for venues):
// Before:
allow update: if isAuthenticated()
&& (resource.data.merchantId == request.auth.uid
|| resource.data.ownerId == request.auth.uid
|| request.resource.data.diff(resource.data).affectedKeys().hasOnly(['activeLanternCount', 'updatedAt']));โฆwith:
// After: merchantId now references merchants/{merchantId}, not user uid.
// isMerchantOf() reads the caller's user doc to resolve membership.
allow update: if isAuthenticated()
&& (isMerchantOf(resource.data.merchantId)
|| resource.data.ownerId == request.auth.uid
|| request.resource.data.diff(resource.data).affectedKeys().hasOnly(['activeLanternCount', 'updatedAt']));(The ownerId fallback is preserved for any legacy venues that used it.)
- [ ] Step 4: Deploy rules to the dev emulator and validate syntax
cd /workspaces/lantern_app
npx firebase --project lantern-app-dev firestore:rules:release --dry-runExpected: "Rules are valid" or similar success. If not, fix syntax.
- [ ] Step 5: Commit
git add firestore.rules
git commit -m "$(cat <<'EOF'
feat(rules): add isMerchantOf helper + merchants collection rules
- New helper userMerchantId() / isMerchantOf() indirects through
users/{uid}.merchantId so venue access resolves correctly now that
venues.merchantId references the merchants collection, not user uid.
- merchants/{merchantId}: admin read all; merchant reads own; no client
writes (server-only via Admin SDK).
- venues update rule: isMerchantOf(...) replaces direct uid comparison.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 10: Add API client methods for the new merchant endpoints โ
Files:
- Modify:
apps/admin/src/lib/authApi.jsโ addlistMerchants,getMerchantById,associateVenue,disassociateVenue. - Modify:
apps/admin/src/firebase.jsโ rewiregetMerchantDatato new endpoint; add thin proxies for venue actions.
Context: Admin UI consumes the server via authRequest() (already handles bearer + App Check headers). firebase.js is the single import surface for components today.
- [ ] Step 1: Add client methods in authApi.js
In apps/admin/src/lib/authApi.js, append below updateMerchantUser (after line 191):
/**
* GET /auth/admin/merchants โ list merchants with pagination + venue count
*/
export async function listMerchants({ pageSize = 25, status, startAfter } = {}) {
const params = new URLSearchParams()
params.set('pageSize', String(pageSize))
if (status) params.set('status', status)
if (startAfter) params.set('startAfter', startAfter)
const response = await authRequest(
`${API_BASE_URL}/auth/admin/merchants?${params.toString()}`,
{ method: 'GET' }
)
return parseResponse(response)
}
/**
* GET /auth/admin/merchants/:merchantId โ merchant + owners + venues
*/
export async function getMerchantById(merchantId) {
const response = await authRequest(
`${API_BASE_URL}/auth/admin/merchants/${encodeURIComponent(merchantId)}`,
{ method: 'GET' }
)
return parseResponse(response)
}
/**
* POST /auth/admin/merchants/:merchantId/venues โ associate a venue
*/
export async function associateVenueWithMerchant(merchantId, venueId) {
const response = await authRequest(
`${API_BASE_URL}/auth/admin/merchants/${encodeURIComponent(merchantId)}/venues`,
{ method: 'POST', body: JSON.stringify({ venueId }) }
)
return parseResponse(response)
}
/**
* DELETE /auth/admin/merchants/:merchantId/venues/:venueId โ disassociate
*/
export async function disassociateVenueFromMerchant(merchantId, venueId) {
const response = await authRequest(
`${API_BASE_URL}/auth/admin/merchants/${encodeURIComponent(merchantId)}/venues/${encodeURIComponent(venueId)}`,
{ method: 'DELETE' }
)
return parseResponse(response)
}- [ ] Step 2: Rewire getMerchantData in apps/admin/src/firebase.js
Find the existing getMerchantData function (around lines 911โ925) and replace its body with a call to the new detail endpoint. Import getMerchantById from ./lib/authApi.js at the top of firebase.js.
Add to the import block near the top of firebase.js (merge with existing imports from ./lib/authApi.js):
import {
createMerchantUser as _createMerchantUser,
updateMerchantUser as _updateMerchantUser,
listMerchants as _listMerchants,
getMerchantById,
associateVenueWithMerchant,
disassociateVenueFromMerchant,
} from './lib/authApi.js'(If createMerchantUser and updateMerchantUser are already imported and re-exported elsewhere, leave those as-is and only add the four new names.)
Replace the getMerchantData function body. The call signature changes โ it now takes a merchantId instead of a uid:
/**
* Fetch merchant detail by merchantId โ returns { merchant, owners, venues }.
*/
export async function getMerchantData(merchantId) {
return getMerchantById(merchantId)
}At the bottom of firebase.js, add re-exports if needed:
export {
_listMerchants as listMerchants,
associateVenueWithMerchant,
disassociateVenueFromMerchant,
}(Only add re-exports that aren't already there.)
- [ ] Step 3: Verify compile
npm run lint -w apps/adminExpected: no lint errors. If there are unused-import warnings for names that aren't used yet, that's fine โ subsequent tasks will use them.
- [ ] Step 4: Commit
git add apps/admin/src/lib/authApi.js apps/admin/src/firebase.js
git commit -m "$(cat <<'EOF'
feat(admin): api client methods for new merchant endpoints
listMerchants, getMerchantById, associateVenueWithMerchant,
disassociateVenueFromMerchant. getMerchantData rewired to call the new
detail endpoint and now returns {merchant, owners, venues} shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 11: Create the AdminVenuePicker component โ
Files:
- Create:
apps/admin/src/components/venues/AdminVenuePicker.jsx
Context: Inline picker used by MerchantDetail. Search-first UI, shows claim status per venue. Uses the existing consumer venue-service or a direct Firestore query (whichever is accessible from admin app โ check apps/admin/src/firebase.js or apps/web/src/lib/venueService.js export path).
- [ ] Step 1: Check venue-search accessibility from admin
Run:
grep -r "getNearbyVenues\|searchVenues\|venuesCollection" apps/admin/src/ | headIf the admin app can access a search function, use it. Otherwise, fall back to a direct Firestore query from within the picker (pattern below uses the direct path).
- [ ] Step 2: Create the picker component
Create apps/admin/src/components/venues/AdminVenuePicker.jsx:
import React, { useEffect, useMemo, useState } from 'react'
import { collection, getDocs, limit as qLimit, query, where } from 'firebase/firestore'
import { db } from '../../firebase'
import { associateVenueWithMerchant } from '../../firebase'
/**
* AdminVenuePicker โ inline venue search & associate for MerchantDetail.
*
* Props:
* merchantId: string โ the merchant claiming the venue
* currentVenueIds: string[] โ venues already linked to this merchant
* onAssociated: (venueId) => void โ called on successful associate
* onCancel: () => void
*/
export default function AdminVenuePicker({ merchantId, currentVenueIds = [], onAssociated, onCancel }) {
const [searchTerm, setSearchTerm] = useState('')
const [results, setResults] = useState([])
const [selectedVenueId, setSelectedVenueId] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [submitting, setSubmitting] = useState(false)
const currentSet = useMemo(() => new Set(currentVenueIds), [currentVenueIds])
useEffect(() => {
let cancelled = false
async function run() {
if (!searchTerm.trim()) {
setResults([])
return
}
try {
setLoading(true)
setError(null)
// Simple prefix match on name (Firestore doesn't do full-text).
// Case-sensitive; users see results as they type.
const term = searchTerm.trim()
const q = query(
collection(db, 'venues'),
where('name', '>=', term),
where('name', '<=', term + '๏ฃฟ'),
qLimit(25)
)
const snap = await getDocs(q)
if (cancelled) return
setResults(
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 || 'Search failed')
} finally {
if (!cancelled) setLoading(false)
}
}
const timer = setTimeout(run, 250) // debounce
return () => {
cancelled = true
clearTimeout(timer)
}
}, [searchTerm])
function statusFor(venue) {
if (currentSet.has(venue.venueId)) return 'this-merchant'
if (venue.merchantId) return 'claimed'
return 'available'
}
async function handleAssociate() {
if (!selectedVenueId) return
try {
setSubmitting(true)
setError(null)
await associateVenueWithMerchant(merchantId, selectedVenueId)
onAssociated?.(selectedVenueId)
} catch (err) {
setError(err.message || 'Failed to associate venue')
} finally {
setSubmitting(false)
}
}
return (
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium">Associate venue</h3>
<button className="text-sm text-gray-500 hover:text-gray-700" onClick={onCancel}>
Cancel
</button>
</div>
<input
type="text"
placeholder="Search venues by name..."
className="w-full border rounded px-3 py-2"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
autoFocus
/>
{loading && <div className="text-sm text-gray-500">Searching...</div>}
{error && <div className="text-sm text-red-600">{error}</div>}
{!loading && searchTerm && results.length === 0 && (
<div className="text-sm text-gray-500">No venues match. Venue onboarding is a separate feature โ seed venues via the Firestore console for now.</div>
)}
<ul className="divide-y">
{results.map((venue) => {
const status = statusFor(venue)
const selectable = status === 'available'
const isSelected = selectedVenueId === venue.venueId
return (
<li
key={venue.venueId}
className={`py-2 px-2 flex items-start gap-3 ${selectable ? 'hover:bg-white cursor-pointer' : 'opacity-60'}`}
onClick={() => selectable && setSelectedVenueId(venue.venueId)}
>
<input
type="radio"
disabled={!selectable}
checked={isSelected}
onChange={() => setSelectedVenueId(venue.venueId)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{venue.name}</div>
<div className="text-sm text-gray-500 truncate">{venue.address}</div>
</div>
<span className={
status === 'available' ? 'text-xs px-2 py-0.5 rounded bg-green-100 text-green-800' :
status === 'this-merchant' ? 'text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-800' :
'text-xs px-2 py-0.5 rounded bg-gray-200 text-gray-700'
}>
{status === 'available' ? 'Available' : status === 'this-merchant' ? 'Already linked' : 'Claimed'}
</span>
</li>
)
})}
</ul>
<div className="flex items-center justify-end gap-2 pt-2">
<button
className="px-3 py-1.5 text-sm rounded border"
onClick={onCancel}
disabled={submitting}
>
Cancel
</button>
<button
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white disabled:bg-gray-300"
onClick={handleAssociate}
disabled={!selectedVenueId || submitting}
>
{submitting ? 'Associating...' : 'Associate'}
</button>
</div>
</div>
)
}Note: This imports db from ../../firebase. If firebase.js doesn't currently export db directly, add the export there. Check with grep '^export.*\\bdb\\b' apps/admin/src/firebase.js. If missing, add export { db } near the other exports.
- [ ] Step 3: Lint check
npm run lint -w apps/adminExpected: no errors.
- [ ] Step 4: Commit
git add apps/admin/src/components/venues/AdminVenuePicker.jsx apps/admin/src/firebase.js
git commit -m "$(cat <<'EOF'
feat(admin): AdminVenuePicker component
Inline venue search + associate for MerchantDetail. Shows per-venue
status (available / claimed / this merchant) with disabled rows for
unselectable entries. Debounced prefix-match search on venue name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 12: Replace Venue placeholder on MerchantDetail with functional Venues section โ
Files:
- Modify:
apps/admin/src/components/merchants/MerchantDetail.jsx
Context: Per Section 4 of the spec, venue associations are immediate (not tied to Edit/Save). View mode shows a list with Remove buttons + an "Associate venue" affordance that expands into AdminVenuePicker. MerchantDetail's data shape changes from {user, profile} to {merchant, owners, venues}.
- [ ] Step 1: Update imports
At the top of MerchantDetail.jsx, add:
import AdminVenuePicker from '../venues/AdminVenuePicker'
import { disassociateVenueFromMerchant } from '../../firebase'- [ ] Step 2: Refactor state shape + load function
Find the useState({ user: null, profile: null }) (line 28 approximately) and update to:
const [data, setData] = useState({ merchant: null, owners: [], venues: [] })
const [showPicker, setShowPicker] = useState(false)
const [removingVenueId, setRemovingVenueId] = useState(null)No change needed in the useEffect that calls getMerchantData(merchantId) โ the wrapper now returns the new shape.
- [ ] Step 3: Update handleEnterEdit to use new shape
Around line 56 (handleEnterEdit), replace:
function handleEnterEdit() {
const { user, profile } = data
setFormData({
displayName: profile?.displayName || user?.displayName || '',
businessName: profile?.businessName || user?.businessName || '',
contactName: profile?.contactName || '',
phone: profile?.phone || '',
notes: profile?.notes || '',
})
setSaveError(null)
setEditing(true)
}โฆwith:
function handleEnterEdit() {
const { merchant, owners } = data
const primaryOwner = owners[0] || {}
setFormData({
businessName: merchant?.businessName || '',
contactName: primaryOwner.contactName || primaryOwner.displayName || '',
phone: primaryOwner.phone || '',
notes: primaryOwner.notes || '',
})
setSaveError(null)
setEditing(true)
}Note: displayName is gone from form data (server derives it from contactName).
- [ ] Step 4: Update handleSave
The existing handleSave calls updateMerchantUser(merchantId, formData). merchantId param was the user uid before; now it's the merchant id. The PATCH endpoint is still keyed by user uid (per spec). Resolve the primary owner uid from data.owners[0].uid and pass that instead. Change handleSave body (around line 75):
async function handleSave() {
try {
setSaving(true)
setSaveError(null)
const primaryOwnerUid = data.owners?.[0]?.uid
if (!primaryOwnerUid) {
throw new Error('No owner user to update')
}
await updateMerchantUser(primaryOwnerUid, formData)
const fresh = await getMerchantData(merchantId)
setData(fresh)
setEditing(false)
setFormData(null)
setSavedAt(new Date())
} catch (err) {
setSaveError(err.message || 'Failed to save changes')
} finally {
setSaving(false)
}
}- [ ] Step 5: Add venue-action handlers
Below handleSave, add:
async function handleRemoveVenue(venueId) {
if (!window.confirm('Remove this venue from the merchant?')) return
try {
setRemovingVenueId(venueId)
await disassociateVenueFromMerchant(merchantId, venueId)
const fresh = await getMerchantData(merchantId)
setData(fresh)
} catch (err) {
alert(err.message || 'Failed to remove venue')
} finally {
setRemovingVenueId(null)
}
}
async function handleVenueAssociated() {
setShowPicker(false)
const fresh = await getMerchantData(merchantId)
setData(fresh)
}- [ ] Step 6: Replace the Venue PlaceholderSection in the JSX
Find the Venue placeholder (around lines 221โ225):
<PlaceholderSection
title="Venue"
icon="๐"
description="Link this merchant to a venue so their offers appear at the right location."
/>Replace with a real Venues section:
<section className="bg-white rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">๐ Venues</h2>
<span className="text-sm text-gray-500">
{data.venues.length} {data.venues.length === 1 ? 'venue' : 'venues'}
</span>
</div>
{data.venues.length === 0 && !showPicker && (
<p className="text-sm text-gray-500">No venues linked yet.</p>
)}
{data.venues.length > 0 && (
<ul className="divide-y">
{data.venues.map((v) => (
<li key={v.venueId} className="py-2 flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{v.name}</div>
<div className="text-sm text-gray-500 truncate">{v.address}</div>
</div>
<button
className="text-sm text-red-600 hover:text-red-800 disabled:text-gray-400"
onClick={() => handleRemoveVenue(v.venueId)}
disabled={removingVenueId === v.venueId}
>
{removingVenueId === v.venueId ? 'Removingโฆ' : 'Remove'}
</button>
</li>
))}
</ul>
)}
{showPicker ? (
<AdminVenuePicker
merchantId={merchantId}
currentVenueIds={data.venues.map((v) => v.venueId)}
onAssociated={handleVenueAssociated}
onCancel={() => setShowPicker(false)}
/>
) : (
<button
className="text-sm text-blue-600 hover:text-blue-800"
onClick={() => setShowPicker(true)}
>
+ Associate venue
</button>
)}
</section>- [ ] Step 7: Update any reads of
data.user/data.profileelsewhere in the file
Grep within the file for data.user and data.profile:
grep -n 'data\.user\|data\.profile\|\.profile\?\.\|\.user\?\.' apps/admin/src/components/merchants/MerchantDetail.jsxUpdate each reference to use the new shape:
data.user.emailโdata.owners[0]?.emaildata.user.displayNameโdata.owners[0]?.displayNamedata.profile.businessNameโdata.merchant?.businessNamedata.profile.contactNameโdata.owners[0]?.contactNamedata.profile.phoneโdata.owners[0]?.phonedata.profile.notesโdata.owners[0]?.notes
Read the file at each hit and adjust carefully. If the file uses const { user, profile } = data destructuring, replace with const { merchant, owners } = data; const primaryOwner = owners[0] || {} and update references.
- [ ] Step 8: Run lint + manual smoke test
npm run lint -w apps/admin
npm run dev -w apps/admin
# navigate to /merchants/{merchantId-from-task-3}Expected: page loads; venue section renders with current venue from Task 7; Remove button works; + Associate venue opens picker.
- [ ] Step 9: Commit
git add apps/admin/src/components/merchants/MerchantDetail.jsx
git commit -m "$(cat <<'EOF'
feat(admin): real Venues section on MerchantDetail
- Replaces placeholder with functional list + Associate venue picker.
- Venue actions are immediate (no Edit/Save), Gmail-labels pattern.
- Data shape migrated from {user, profile} to {merchant, owners, venues}.
- PATCH still keyed by primary owner uid (single-owner today).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 13: Reshape MerchantsAll page to use the list endpoint โ
Files:
- Modify:
apps/admin/src/components/merchants/MerchantsAll.jsx
Context: Per Section 4 of the spec, the page queries the merchants collection directly (via new GET /auth/admin/merchants) rather than filtering users by role. Rows now show businessName, primary owner email, status, and venue count.
- [ ] Step 1: Update imports
Remove the fetchUsers import (if unused elsewhere after this change). Add:
import { listMerchants } from '../../firebase'(Assumes listMerchants is re-exported from firebase.js per Task 10 Step 2.)
- [ ] Step 2: Rewrite the load function
Find the existing load/fetch that calls fetchUsers({ roleFilter: 'merchant' }) (around line 32). Replace with:
async function load(append = false) {
try {
setLoading(true)
setError(null)
const result = await listMerchants({
pageSize: 25,
startAfter: append ? nextCursor : undefined,
})
setItems((prev) => (append ? [...prev, ...result.items] : result.items))
setNextCursor(result.nextCursor)
} catch (err) {
setError(err.message || 'Failed to load merchants')
} finally {
setLoading(false)
}
}Adjust state names if they differ (items / nextCursor / lastDoc).
- [ ] Step 3: Update row rendering
Find the .map() that renders merchant rows. Each row should display:
items.map((m) => (
<Link
key={m.merchantId}
to={`/merchants/${m.merchantId}`}
className="block border-b py-3 hover:bg-gray-50 px-3"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{m.businessName}</div>
<div className="text-sm text-gray-500 truncate">
{m.primaryOwner?.email || 'โ'}
</div>
</div>
<div className="text-right text-sm text-gray-600 shrink-0">
<div>
{m.venueCount} {m.venueCount === 1 ? 'venue' : 'venues'}
</div>
<div className="text-xs text-gray-400">{m.status}</div>
</div>
</div>
</Link>
))- [ ] Step 4: Update client-side search (if present)
If the existing file has a search box filtering across businessName, email, displayName: update the filter to read from the new row shape:
const filteredItems = items.filter((m) => {
const q = searchTerm.toLowerCase()
return (
m.businessName?.toLowerCase().includes(q) ||
m.primaryOwner?.email?.toLowerCase().includes(q) ||
m.primaryOwner?.displayName?.toLowerCase().includes(q)
)
})- [ ] Step 5: Lint + smoke test
npm run lint -w apps/adminNavigate to /merchants/all in the dev admin app. Expected: the merchant from Task 3 appears with "1 venue" (if Task 7 completed successfully). Clicking the row navigates to /merchants/m_....
- [ ] Step 6: Commit
git add apps/admin/src/components/merchants/MerchantsAll.jsx
git commit -m "$(cat <<'EOF'
feat(admin): MerchantsAll queries merchants collection
Switches from fetchUsers({roleFilter:'merchant'}) to listMerchants().
Rows now show businessName, primary owner email, status, and venue
count. Row links use merchantId route param.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 14: Update CreateMerchantForm to navigate using the new merchantId โ
Files:
- Modify:
apps/admin/src/components/CreateMerchantForm.jsx
Context: The POST response now includes merchantId. The form should:
- Stop sending
displayName(server derives from contactName). - Navigate to
/merchants/{merchantId}(not/merchants/{userId}).
- [ ] Step 1: Remove displayName from the submit payload
Find the createMerchantUser({...}) call (around line 48). Current form builds a displayName from contactName/businessName and includes it. Remove the displayName field from the object:
const response = await createMerchantUser({
email: formData.email.trim(),
businessName: formData.businessName.trim(),
contactName: formData.contactName.trim(),
phone: formData.phone.trim(),
notes: formData.notes.trim(),
sendInvite: formData.sendInvite,
})Also remove any const displayName = ... derivation above this call if it's no longer used.
- [ ] Step 2: Update navigation to use merchantId
Find the success handler (around lines 58โ68) that navigates to /merchants/${response.userId}. Change to response.merchantId:
navigate(`/merchants/${response.merchantId}?created=true`, {
state: {
resetLink: response.resetLink,
emailSent: response.emailSent,
wasPromotion: response.wasPromotion,
},
})- [ ] Step 3: Mark businessName + contactName as required in the form UI
Ensure the form's <input> elements for businessName and contactName have the required attribute (they already map to what zod now requires). If the form previously allowed empty businessName, add a client-side check.
- [ ] Step 4: Lint + smoke test
Run the admin dev server, click "Create merchant" in the nav, fill out the form, submit. Expected:
POST returns 201 with a
merchantId.Browser navigates to
/merchants/m_...?created=true.Just-created banner displays with the reset link (if new user).
Refresh โ MerchantDetail loads with owner info + 0 venues.
[ ] Step 5: Commit
git add apps/admin/src/components/CreateMerchantForm.jsx
git commit -m "$(cat <<'EOF'
feat(admin): CreateMerchantForm uses merchantId for navigation
Stops sending displayName (server derives from contactName). Post-create
nav uses the new merchantId from the response instead of userId.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 15: Update OpenAPI spec โ
Files:
- Modify:
services/api/auth/openapi.json
Context: CLAUDE.md rule #8 requires the OpenAPI spec to reflect live routes. The admin portal's API Reference page reads from this file.
- [ ] Step 1: Inspect the existing merchant route spec
Open services/api/auth/openapi.json. Find the entry for POST /auth/admin/users/merchant and PATCH /auth/admin/users/merchant/:userId.
- [ ] Step 2: Update CreateMerchantBody schema
In the components.schemas.CreateMerchantBody (or similar name):
- Change
displayNamefrom required to not present. - Change
businessNamefrom optional to required (move out of theoptionaltreatment). - Change
contactNamefrom optional to required.
Example resulting schema:
{
"CreateMerchantBody": {
"type": "object",
"required": ["email", "businessName", "contactName"],
"properties": {
"email": { "type": "string", "format": "email" },
"businessName": { "type": "string", "minLength": 1 },
"contactName": { "type": "string", "minLength": 1 },
"phone": { "type": "string" },
"notes": { "type": "string" },
"sendInvite": { "type": "boolean" }
}
}
}Also update the response schema for the create endpoint to include merchantId: string.
- [ ] Step 3: Add paths for the four new endpoints
Under paths, add entries for:
/auth/admin/merchantsโ GET (list)/auth/admin/merchants/{merchantId}โ GET (detail)/auth/admin/merchants/{merchantId}/venuesโ POST (associate)/auth/admin/merchants/{merchantId}/venues/{venueId}โ DELETE (disassociate)
Each with appropriate parameters, requestBody (where applicable), and responses (200/201/404/409).
- [ ] Step 4: Verify spec loads in Scalar
Hit http://localhost:8084/api-docs in a browser. Expected: the new endpoints appear in the sidebar with correct schemas.
- [ ] Step 5: Commit
git add services/api/auth/openapi.json
git commit -m "$(cat <<'EOF'
docs(auth-api): document merchant-venue association endpoints
Updates CreateMerchantBody (businessName/contactName now required,
displayName removed), CreateMerchant response (adds merchantId), and
adds paths for the four new /merchants/* endpoints.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Task 16: End-to-end manual validation + file follow-up issues โ
Files:
- No code changes.
Context: Final sanity check that all pieces work together, plus filing issues for deferred test harness work (so it's not forgotten).
- [ ] Step 1: Run full validate suite
npm run validateExpected: all scopes pass (lint, format, test, audit). Fix any failures before proceeding.
- [ ] Step 2: End-to-end browser test โ create flow
- Start fresh: delete any existing test merchant + Auth user from Task 1.
- Navigate to
/merchants/createin admin dev. - Fill form: email=
test1@example.com, businessName=Test Cafe, contactName=Test Owner, phone, notes. - Submit.
- Expected: navigate to
/merchants/m_...?created=truewith banner + reset link. - Verify in Firestore: merchants doc exists with m_ id, users doc has merchantId, merchantProfiles doc has no businessName.
- Firebase Auth console: user's displayName is "Test Owner" (not "Test Cafe").
- [ ] Step 3: End-to-end โ associate flow
- Seed a venue in Firestore: name="Cafe Luna", address="123 Main St", plus required venue fields.
- From
/merchants/m_..., click "+ Associate venue". - Type "Cafe" โ see the venue with "Available".
- Select it, click Associate.
- Expected: picker closes, venue appears in list.
- Verify in Firestore: venue has
merchantId, merchant has venueId invenueIds[].
- [ ] Step 4: End-to-end โ disassociate flow
- Click Remove on the associated venue.
- Confirm dialog.
- Expected: venue disappears.
- Verify in Firestore: venue has no
merchantId, merchantvenueIdsis empty.
- [ ] Step 5: End-to-end โ edit flow
- Click Edit.
- Change businessName.
- Save.
- Expected: view refreshes with new businessName.
- Verify:
merchants/{merchantId}.businessNameupdated;merchantProfiles/{uid}unchanged for that field.
- [ ] Step 6: End-to-end โ MerchantsAll
- Navigate to
/merchants/all. - Expected: row shows businessName, primary owner email, status, venue count.
- Click row โ navigates to
/merchants/m_....
- [ ] Step 7: File a follow-up issue for test harness
gh issue create --title "Add test harness for services/api/auth and apps/admin" --body "$(cat <<'EOF'
## Background
During the merchant-venue association feature (see docs/superpowers/plans/2026-04-22-merchant-venue-association.md) we verified endpoints and UI flows manually because there is no test harness for either services/api/auth/ or apps/admin/.
## Scope
1. **services/api/auth/** โ set up:
- Firebase Admin SDK mock utilities (getAuth, getFirestore)
- Supertest-based integration tests against the express app
- Coverage target: start at 60%, ramp to project default
2. **apps/admin/** โ set up:
- Vitest + Testing Library React + jsdom (mirror apps/web)
- Shared mocks for Firebase modular SDK
- Coverage target: match apps/web (75%)
Both efforts benefit existing code immediately โ not just the merchant feature.
## Not in scope
Writing comprehensive tests for existing code. This issue is just the *infrastructure*; per-feature test debts get their own issues.
EOF
)"Record the issue number for reference.
- [ ] Step 8: Commit nothing (or, if steps 2โ6 surfaced fixes, commit those with descriptive messages)
Do not commit "validate passed" as an empty change. Skip.
- [ ] Step 9: Update the rolling plan doc
Update docs/plans/can-we-create-a-smooth-beaver.md to move "Venue association" out of "Next candidates" and into "Shipped," mirroring the commit pattern used for prior iterations.
# Manual edit of docs/plans/can-we-create-a-smooth-beaver.md:
# - Add a new "Venue association" section under "Shipped" with commit range.
# - Remove section #2 from "Next candidates" or mark it โ
and renumber.
# - Add "Venue onboarding" to "Next candidates" (was Path 2 deferred).- [ ] Step 10: Commit plan update
git add docs/plans/can-we-create-a-smooth-beaver.md
git commit -m "$(cat <<'EOF'
docs(plans): mark venue-association shipped; add venue-onboarding next
Merchant-venue association completed on this branch. Reshuffles the
Next candidates list: venue onboarding moves from the implicit
deferred-configs bucket into an explicit next-iteration candidate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Self-review (completed before handoff) โ
Spec coverage:
- โ Section 1 (Schema) โ Tasks 2, 3, 4
- โ Section 2 (Security rules) โ Task 9
- โ Section 3 (API) โ Tasks 3, 4, 5, 6, 7, 8
- โ Section 4 (Admin UI) โ Tasks 11, 12, 13, 14
- โ Section 5 (Creation flow) โ Task 3 (rollback included)
- โ Section 6 (Deferred) โ Task 16 step 7 files the test-harness follow-up; venue onboarding listed in A2
Acceptance criteria coverage (from spec):
- All
- [ ]bullets in the spec's acceptance criteria map to verification steps in Task 16.
Placeholder scan: No TBD / TODO / "handle edge cases" strings. Every step has either code, a command, or a concrete manual-verify description.
Type consistency: merchantId is consistently m_{12 chars} (Task 2 defines, Task 3+ use). API route paths consistent: /auth/admin/merchants for entity, /auth/admin/users/merchant for user-scoped create/update (intentional per spec deferred-migration note). Field names consistent: businessName, contactName, phone, notes throughout.
Known deviations from spec (acknowledged):
- Spec says
nanoid(12); plan usescrypto.randomBytes(9).toString('base64url')(12 URL-safe chars). No new npm dep; same shape. Spec should be considered aligned after review.