Skip to content

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 (nanoid was a placeholder in the spec; the repo already uses Node's crypto).
  • 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 โ€‹

FileActionResponsibility
services/api/auth/src/lib/merchantIds.jscreatePure helper: mint m_* id
services/api/auth/src/routes/adminUsers.jsmodifyUpdate create/patch handlers to mint merchantId and route fields
services/api/auth/src/routes/adminMerchants.jscreateNew /auth/admin/merchants/* routes (list, detail, associate, disassociate)
services/api/auth/src/index.jsmodifyMount adminMerchants router
services/api/auth/openapi.jsonmodifyDocument new routes
services/api/auth/src/lib/__tests__/merchantIds.test.jscreateUnit tests for id generator
firestore.rulesmodifyAdd isMerchantOf(), add merchants match, update venues update rule
apps/admin/src/lib/authApi.jsmodifyAdd API client methods for new merchant endpoints
apps/admin/src/firebase.jsmodifyRewire getMerchantData to new endpoint; add venue-assoc proxies
apps/admin/src/components/venues/AdminVenuePicker.jsxcreateInline picker used by MerchantDetail
apps/admin/src/components/merchants/MerchantDetail.jsxmodifyReplace Venue placeholder with Venues section
apps/admin/src/components/merchants/MerchantsAll.jsxmodifyQuery merchants collection; add venue-count column
apps/admin/src/components/merchants/CreateMerchantForm.jsxmodifyNavigate 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 curl commands 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.)

bash
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:

javascript
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:

javascript
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
bash
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 โ€” update CreateMerchantBody zod schema, rewrite the router.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:

javascript
// 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:

javascript
// 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):

javascript
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:

javascript
// โ”€โ”€ 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):

bash
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 with businessName: "Cafe Luna", ownerUserIds: [uid], venueIds: [].

  • users/{uid} has merchantId: "m_...", displayName: "John Smith".

  • merchantProfiles/{uid} has contactName: "John Smith", no businessName field.

  • [ ] 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
bash
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 โ€” update UpdateMerchantBody and 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:

javascript
// 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:

javascript
// โ”€โ”€ 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:

bash
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}.businessName now "Cafe Luna Renamed".

  • merchantProfiles/{uid}.contactName now "Jane Smith". No businessName field.

  • users/{uid}.displayName now "Jane Smith".

  • [ ] Step 5: Commit

bash
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:

javascript
/**
 * 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):

javascript
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)):

javascript
adminDispatch.use('/merchants', verifyFirebaseToken, requireAdmin, adminMerchantsRoutes)
  • [ ] Step 3: Restart auth-api

Ctrl+C + npm run dev -w services/api/auth.

  • [ ] Step 4: Manually verify
bash
curl -s "http://localhost:8084/auth/admin/merchants" \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "X-Firebase-AppCheck: $APP_CHECK" | jq

Expected: {"items":[{"merchantId":"m_...","businessName":"Cafe Luna Renamed",...}],"nextCursor":null}.

  • [ ] Step 5: Commit
bash
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:

javascript
// โ”€โ”€ 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
bash
curl -s "http://localhost:8084/auth/admin/merchants/$MERCHANT_ID" \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "X-Firebase-AppCheck: $APP_CHECK" | jq

Expected: JSON with merchant, owners: [{uid, email, displayName, contactName, phone, notes}], venues: [].

  • [ ] Step 4: Commit
bash
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():

javascript
const AssociateVenueBody = z.object({
  venueId: z.string().min(1),
})

Above export default router, add:

javascript
// โ”€โ”€ 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:

bash
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}.venueIds contains 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
bash
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:

javascript
// โ”€โ”€ 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

bash
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
bash
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 โ€” add isMerchantOf() helper, add merchants/{merchantId} match, update venues/{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
bash
cd /workspaces/lantern_app
npx firebase --project lantern-app-dev firestore:rules:release --dry-run

Expected: "Rules are valid" or similar success. If not, fix syntax.

  • [ ] Step 5: Commit
bash
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 โ€” add listMerchants, getMerchantById, associateVenue, disassociateVenue.
  • Modify: apps/admin/src/firebase.js โ€” rewire getMerchantData to 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):

javascript
/**
 * 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):

javascript
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:

javascript
/**
 * 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:

javascript
export {
  _listMerchants as listMerchants,
  associateVenueWithMerchant,
  disassociateVenueFromMerchant,
}

(Only add re-exports that aren't already there.)

  • [ ] Step 3: Verify compile
bash
npm run lint -w apps/admin

Expected: 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
bash
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:

bash
grep -r "getNearbyVenues\|searchVenues\|venuesCollection" apps/admin/src/ | head

If 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:

javascript
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
bash
npm run lint -w apps/admin

Expected: no errors.

  • [ ] Step 4: Commit
bash
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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):

javascript
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:

javascript
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):

jsx
<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:

jsx
<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.profile elsewhere in the file

Grep within the file for data.user and data.profile:

bash
grep -n 'data\.user\|data\.profile\|\.profile\?\.\|\.user\?\.' apps/admin/src/components/merchants/MerchantDetail.jsx

Update each reference to use the new shape:

  • data.user.email โ†’ data.owners[0]?.email
  • data.user.displayName โ†’ data.owners[0]?.displayName
  • data.profile.businessName โ†’ data.merchant?.businessName
  • data.profile.contactName โ†’ data.owners[0]?.contactName
  • data.profile.phone โ†’ data.owners[0]?.phone
  • data.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
bash
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
bash
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:

javascript
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:

javascript
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:

jsx
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:

javascript
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
bash
npm run lint -w apps/admin

Navigate 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
bash
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:

  1. Stop sending displayName (server derives from contactName).
  2. 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:

javascript
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:

javascript
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

bash
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 displayName from required to not present.
  • Change businessName from optional to required (move out of the optional treatment).
  • Change contactName from optional to required.

Example resulting schema:

json
{
  "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
bash
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
bash
npm run validate

Expected: all scopes pass (lint, format, test, audit). Fix any failures before proceeding.

  • [ ] Step 2: End-to-end browser test โ€” create flow
  1. Start fresh: delete any existing test merchant + Auth user from Task 1.
  2. Navigate to /merchants/create in admin dev.
  3. Fill form: email=test1@example.com, businessName=Test Cafe, contactName=Test Owner, phone, notes.
  4. Submit.
  5. Expected: navigate to /merchants/m_...?created=true with banner + reset link.
  6. Verify in Firestore: merchants doc exists with m_ id, users doc has merchantId, merchantProfiles doc has no businessName.
  7. Firebase Auth console: user's displayName is "Test Owner" (not "Test Cafe").
  • [ ] Step 3: End-to-end โ€” associate flow
  1. Seed a venue in Firestore: name="Cafe Luna", address="123 Main St", plus required venue fields.
  2. From /merchants/m_..., click "+ Associate venue".
  3. Type "Cafe" โ†’ see the venue with "Available".
  4. Select it, click Associate.
  5. Expected: picker closes, venue appears in list.
  6. Verify in Firestore: venue has merchantId, merchant has venueId in venueIds[].
  • [ ] Step 4: End-to-end โ€” disassociate flow
  1. Click Remove on the associated venue.
  2. Confirm dialog.
  3. Expected: venue disappears.
  4. Verify in Firestore: venue has no merchantId, merchant venueIds is empty.
  • [ ] Step 5: End-to-end โ€” edit flow
  1. Click Edit.
  2. Change businessName.
  3. Save.
  4. Expected: view refreshes with new businessName.
  5. Verify: merchants/{merchantId}.businessName updated; merchantProfiles/{uid} unchanged for that field.
  • [ ] Step 6: End-to-end โ€” MerchantsAll
  1. Navigate to /merchants/all.
  2. Expected: row shows businessName, primary owner email, status, venue count.
  3. Click row โ†’ navigates to /merchants/m_....
  • [ ] Step 7: File a follow-up issue for test harness
bash
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.

bash
# 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
bash
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 uses crypto.randomBytes(9).toString('base64url') (12 URL-safe chars). No new npm dep; same shape. Spec should be considered aligned after review.

Built with VitePress