Merchant โ Venue Association โ Design โ
Date: 2026-04-22 Status: Approved Scope: services/api/auth/, apps/admin/, firestore.rulesBranch: claude/merchant-ad-placeholders-y2pxuBuilds on: Merchant Portal in Admin App (Phases 1โ2 + merchant create/edit/detail/directory; see docs/plans/can-we-create-a-smooth-beaver.md)
Summary โ
Introduce a merchant-entity concept separate from user identity, and let admins associate venues with merchants. Today a "merchant" is a user (users/{uid} with role: 'merchant'); after this work, a merchant is a business entity (merchants/{merchantId}) that has one or more users and has one or more venues.
Ships the identifier separation (so future references are stable) and the admin-assigned venue workflow (picker + associate/disassociate). Does not ship the full multi-user-per-merchant model (Issue #231), self-service merchant signup (#136), or venue onboarding โ all intentionally deferred.
Motivation โ
Consumer auth uses zero-knowledge encryption (passphrase = encryption key), which is wrong for business accounts that need standard password recovery and multi-user access. The preceding iterations gave merchants a role-gated portal inside the admin app. This iteration lets that portal carry real merchant configuration โ starting with venue ownership, which everything downstream (offers, placements, consumer-app tether, analytics) references.
The design separates merchant identity from user identity today so that when #231 (multi-user per merchant) lands, venue references don't need to be rewritten and security rules don't need to be inverted. Getting the identifier right now is ~10 minutes of work; retrofitting it later is a multi-week migration.
Decisions โ
All clarifying questions resolved during brainstorming (2026-04-22):
| Q | Decision | Rationale |
|---|---|---|
| Q0 โ Merchant model | Introduce stable merchantId identifier now; full entity + merchantUsers junction deferred to #231 | Schema correctness without scope explosion |
| Q1 โ Venue multiplicity | Strict one venue = one merchant. Multi-merchant-per-venue not planned | Food-court case degrades into child-venues-per-stall (more accurate anyway) |
| Q2 โ Association workflow | Admin assigns now; merchant self-service claim deferred | Every merchant is admin-created today (#136 is the prerequisite for claim) |
| Q3 โ Venue source | Pick from existing venues only; venue onboarding ships as a separate follow-up feature | Keeps this iteration's PR scoped; venue onboarding deserves its own review |
| Q4 โ Data migration | Delete the one test merchant; start fresh | No real data to preserve; cleanest outcome |
| โ user โ merchant | Strict one-to-one (singular merchantId on user doc) | Simplification user confirmed: no context-switcher UI needed |
Design โ
Section 1 โ Schema โ
Three existing collections touched, one new.
New: merchants/{merchantId} โ
Business-entity document. {merchantId} format: m_{nanoid(12)} โ self-generated, type-prefixed (e.g. m_V1StGXR8_Z5j).
merchants/{merchantId}
businessName: string
status: 'active' | 'pending_setup' | 'suspended'
createdAt: Timestamp
createdBy: string (uid of admin who created)
venueIds: string[] (denormalized โ mirrors venues.merchantId)
ownerUserIds: string[] (convenience โ today always length 1)Two intentional denormalizations:
venueIdsduplicatesvenues.merchantId. Lets admin UI render venue count / list without a collection scan. Authoritative source remainsvenues.merchantId; both sides always written in one batch.ownerUserIdsduplicatesusers.merchantId. Convenience for listing a merchant's users. Keeps array shape from day one so #231 doesn't require array-migration.
Both safe because all writes flow through server endpoints โ client cannot break the mirror.
Modified: users/{uid} (merchant role only) โ
One new field: merchantId: string. businessName removed from user doc (moves to merchants doc). Firebase Auth displayName now tracks contactName (the person), not businessName (the business).
users/{uid}
email, role, createdAt, createdBy (unchanged)
displayName (now = contactName, not businessName)
merchantId: string NEW โ singular, never an arrayWhy no mirror of businessName: when #231 lands and one merchant has N users, each user's displayName should be their personal name. "Hi Cafe Luna" in a password-reset email to John Smith is wrong.
Modified: merchantProfiles/{uid} (scope narrowed) โ
Keyed by uid. Drops businessName. Keeps per-user fields.
merchantProfiles/{uid}
contactName: string
phone: string
notes: string
status: 'pending_setup' | 'active' | ...Mirrors the adminProfiles/{uid} pattern. Survives #231 cleanly as the "per-user role-detail within a merchant" doc.
Modified: venues/{venueId} (no structural change) โ
venues.merchantId already exists. The field keeps its name and type; only its referent changes from "user uid" to "merchantId." Since there is no real venue data today, no migration is needed.
Relationship diagram โ
users/{uid}
โ merchantId (string, single)
โผ
merchants/{merchantId} โโโโโ venues.merchantId
โ
โโโโบ venueIds[], ownerUserIds[] (denormalized)Section 2 โ Security rules โ
One helper function hides the userโmerchant indirection; other rules call it.
Helpers (add near top of firestore.rules) โ
function userMerchantId() {
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.merchantId;
}
function isMerchantOf(merchantId) {
return request.auth != null && userMerchantId() == merchantId;
}When #231 introduces a full merchantUsers junction, only isMerchantOf needs to change โ all call sites migrate for free.
New: merchants/{merchantId} โ
match /merchants/{merchantId} {
allow read: if isAdmin() || isMerchantOf(merchantId);
allow create, update, delete: if false; // server-only via Admin SDK
}Rules are defense-in-depth. Primary authorization is checkAdminRole() middleware on the server endpoints; rules are the second gate.
Modified: venues/{venueId} update rule โ
// Before: resource.data.merchantId == request.auth.uid
// After: isMerchantOf(resource.data.merchantId)Lantern-count-only carve-out (authenticated users bumping activeLanternCount) remains unchanged. Read, create, delete rules unchanged.
Cost note โ
Every rule evaluation that hits isMerchantOf() costs 1 extra document read (the users/{uid} lookup). Cached within a request. Limit: max 10 get() calls per rule per request โ we use 1. Merchant-originated venue edits are rare (merchants don't flood writes); admin operations bypass rules via Admin SDK.
Section 3 โ API / server changes โ
All new endpoints land in services/api/auth/. Spinning up a dedicated services/api/merchants/ service is premature (CLAUDE.md rule #8: new OpenAPI/Scalar mount, new port, new deploy wiring). Can split later when billing/team operations arrive.
Route inventory โ
| Method | Route | Status | Purpose |
|---|---|---|---|
| POST | /auth/admin/users/merchant | modified | Create merchant (user + entity) |
| PATCH | /auth/admin/users/merchant/:userId | modified | Update (routes fields to correct doc) |
| GET | /auth/admin/merchants | new | List merchants (for MerchantsAll) |
| GET | /auth/admin/merchants/:merchantId | new | Merchant detail (replaces client-side getMerchantData) |
| POST | /auth/admin/merchants/:merchantId/venues | new | Associate venue |
| DELETE | /auth/admin/merchants/:merchantId/venues/:venueId | new | Disassociate venue |
services/api/venues/ gets no changes โ the picker reuses existing read endpoints.
Create (POST /auth/admin/users/merchant) โ modified โ
Atomic sequence:
- Validate body (zod โ unchanged fields)
- Mint
merchantId = 'm_' + nanoid(12) - Create Firebase Auth user
- Firestore batch:
merchants/{merchantId}โ businessName, status='pending_setup', ownerUserIds=[uid], venueIds=[], createdAt, createdByusers/{uid}โ email, role='merchant', merchantId, createdAt, createdBymerchantProfiles/{uid}โ contactName, phone, notes, status='pending_setup' (no businessName)
- Set Firebase Auth
displayName = contactName - Generate password reset link
- Send invite email (if
sendInvite !== false)
Response: { uid, merchantId, emailSent, resetLink, wasPromotion }.
Rollback on failure of step 4: if Firestore batch fails after Auth user is created, the server must call auth.deleteUser(uid) before returning error. (Today's code lacks this rollback โ latent bug fixed while touching the file.)
Update (PATCH /auth/admin/users/merchant/:userId) โ modified โ
Keyed by uid for URL compat. Field routing:
| Field | Writes to |
|---|---|
businessName | merchants/{user.merchantId} |
contactName | merchantProfiles/{uid} + Firebase Auth displayName |
phone, notes | merchantProfiles/{uid} |
Server resolves merchantId from users/{uid}.merchantId on each request.
List (GET /auth/admin/merchants) โ new โ
Query merchants collection (not users-with-role-merchant). Pagination via lastDoc cursor. Optional status filter. Server joins the primary owner (the first entry in ownerUserIds[] โ today always the creating user; when #231 lands, the "primary" concept may evolve) and returns their email + contactName to avoid N client queries.
Detail (GET /auth/admin/merchants/:merchantId) โ new โ
Server parallel-fetches:
merchants/{merchantId}- All users where
merchantId == :merchantId - All venues where
documentIdโmerchant.venueIds
Returns { merchant, owners, venues } โ one round-trip for the whole MerchantDetail page.
Associate venue (POST .../merchants/:merchantId/venues) โ new โ
Body: { venueId: string }.
Guards:
venues/{venueId}.merchantIdmust be null/unset (enforces Q1 strict one-to-one).merchants/{merchantId}must exist.
Atomic batch:
venues/{venueId}.merchantId = merchantIdmerchants/{merchantId}.venueIdsarrayUnionvenueId
Returns 409 Conflict if venue is already associated with a different merchant.
Disassociate venue (DELETE .../merchants/:merchantId/venues/:venueId) โ new โ
Guard: venues/{venueId}.merchantId must currently equal :merchantId (prevents accidental cross-merchant clobber). Atomic batch removes both sides.
Promotion path (existing-user โ merchant) โ
The endpoint's existing behavior: if the email matches an existing user, promote them (wasPromotion: true); otherwise fall through to fresh-create. Preserved in the new flow.
Promotion sequence:
- Look up user by email โ get existing uid
- Guard: if
users/{uid}.merchantIdalready set, reject 409 โ honors "user โ exactly one merchant." - Mint
merchantId - Firestore batch (same fields as fresh-create, with
users/{uid}merged rather than created):merchants/{merchantId}โ businessName, status='pending_setup', ownerUserIds=[uid], venueIds=[], createdAt, createdByusers/{uid}โ merge role='merchant' + merchantId (other fields preserved)merchantProfiles/{uid}โ contactName, phone, notes, status='pending_setup' (merge if somehow already present)
- Set Firebase Auth displayName = contactName (if changed)
- No Auth user create, no password reset (existing user already has login)
- Return
{ uid, merchantId, emailSent: false, resetLink: null, wasPromotion: true }
Cross-cutting โ
- All routes gated by existing
checkAdminRole()middleware. - App Check inherited (per commit 4193cb3).
- OpenAPI: extend
services/api/auth/openapi.jsonwith new routes (CLAUDE.md rule #8).
Section 4 โ Admin UI โ
Five surfaces change.
4.1 Route key: :merchantId instead of :userId โ
/merchants/:merchantId now expects an m_* id. CreateMerchantForm navigates using the new merchantId from the create response. MerchantsAll rows link via merchantId. No legacy URL compatibility needed (single test merchant being deleted per Q4).
4.2 MerchantDetail โ replace Venue placeholder โ
The Venue PlaceholderSection becomes a real section. Venue associations are immediate, not part of Edit/Save โ Gmail-labels mental model. Edit/Save only governs field edits (businessName, contactName, phone, notes).
View mode:
โโ Venues โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Cafe Luna 123 Main St [Remove] โ
โ Cafe Luna - West 456 Oak Ave [Remove] โ
โ โ
โ [+ Associate venue] โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ"Remove" hits DELETE .../venues/:venueId with a confirm dialog. "+ Associate venue" opens AdminVenuePicker inline.
4.3 New: AdminVenuePicker โ
Lives at apps/admin/src/components/venues/AdminVenuePicker.jsx. Admin context (search-first, no map, no geolocation) differs enough from the consumer VenuePicker (location-aware, map view, category filters) that a new component is cleaner than multi-purposing the existing one.
Shape:
โโ Associate venue โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ [ Search venues by name or address... ] โ
โ โ
โ โฏ Cafe Luna 123 Main St [available] โ
โ โ Cafe Luna - East 789 East Rd [available] โ
โ โ Bar Saturn 555 Moon Way [claimed] โ โ disabled
โ โ Cafe Luna - West 456 Oak Ave [this merchant] โ โ disabled
โ โ
โ [Cancel] [Associate] โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโThree result states:
- available โ
venues.merchantIdis null; selectable - claimed โ already associated with some merchant; disabled
- this merchant โ already associated with the current merchant; disabled
Transparency beats trickery: showing claimed venues (disabled, labeled) beats hiding them. Common troubleshooting: "why can't I find Cafe Luna?" โ "it's claimed."
Backend: reuses existing venue-search endpoints in services/api/venues/ or venueService.js. New component layers claim-status overlay on top.
4.4 MerchantsAll reshape โ
Currently calls fetchUsers({ roleFilter: 'merchant' }). Switch to GET /auth/admin/merchants.
Row contents change:
- Before: uid, email, businessName (from user doc)
- After: businessName (from merchant doc), primary owner email (joined server-side), status, venue count
Venue count is a new at-a-glance signal โ "which merchants need venues assigned?"
Click row โ /merchants/:merchantId.
4.5 CreateMerchantForm post-create navigation โ
Minor change: response now includes merchantId; use it for navigate(\/merchants/${merchantId}`). location.state` unchanged (reset link + banner).
File-level touch points โ
| File | Change |
|---|---|
| apps/admin/src/components/merchants/MerchantDetail.jsx | Replace Venue placeholder; route key change |
| apps/admin/src/components/merchants/MerchantsAll.jsx | Query merchants not users; add venue-count column |
| apps/admin/src/components/merchants/CreateMerchantForm.jsx | Consume new merchantId field |
| apps/admin/src/components/venues/AdminVenuePicker.jsx | New |
| apps/admin/src/firebase.js | Point getMerchantData at new GET endpoint; add venue-assoc helpers |
| apps/admin/src/App.jsx | Route param name (cosmetic) |
Section 5 โ Merchant creation flow (end-to-end) โ
Supplements Section 3 with failure modes and downstream effects.
Failure modes and recovery โ
| Step fails | Effect | Recovery |
|---|---|---|
| 3 (Auth create) | No user, no Firestore docs | Safe. Admin retries. |
| 4 (Firestore batch) | Orphaned Auth user | Server must auth.deleteUser(uid) before returning error. Latent bug fixed while touching the file. |
| 5 (displayName set) | User + Firestore docs exist, displayName missing | Tolerate. Next PATCH sets it. |
| 6 (reset link) | Everything exists, no resetLink | Tolerate. Return resetLink: null. |
| 7 (email send) | Everything exists, resetLink in response | Tolerate. Return emailSent: false. UI surfaces resetLink as copy-fallback. |
displayName = contactName โ downstream effects โ
Firebase Auth displayName changes from businessName โ contactName. Effects:
- Email templates. "Hi John" in password-reset/verification emails instead of "Hi Cafe Luna." Correct.
- Firebase Auth console. Shows person name, not business name.
- Client code reading
auth.currentUser.displayName. Grep needed at implementation time. Any reliance on displayName-as-businessName gets rewritten to readmerchants/{merchantId}.businessName.
Edge cases deliberately out of scope โ
- Email change after creation (Firebase Auth verification flow non-trivial)
- Delete / suspend merchant
- Resend invite
Section 6 โ Deferred / future configs โ
Split into three categories:
A โ Unlocked by this feature (ships next) โ
| # | Feature | Notes |
|---|---|---|
| A1 | Address field on MerchantDetail | Merchant-level correspondence address; distinct from venue physical address |
| A2 | Venue onboarding as its own feature | Path 2 follow-up from Q3; proper create-venue flow with geocoding + dedup in services/api/venues/ |
| A3 | Offers UI (#139) | Was blocked on venue association |
| A4 | Consumer-app tether | "View your offers at Cafe Luna" when merchant is signed in inside their venue |
| A5 | Overview metrics (real UI) | Impressions/clicks/redemptions โ needs A3 |
| A6 | Upgrade AdminVenuePicker to Path 1 | Add "Create new venue" button once A2 exists |
| A7 | Photo upload | Scope decision at implementation time: venue-level (interior shots) vs merchant-level (logo) |
B โ Designed-for-but-not-built (schema accommodates; UI/endpoints don't exist) โ
| # | Feature | What this iteration prepared | Ref |
|---|---|---|---|
| B1 | Multi-user per merchant | merchantId on user doc; ownerUserIds[]; isMerchantOf() rule function | #231 |
| B2 | Merchant team management UI | Roles (owner/manager/staff), permissions, invite flow | #231 |
| B3 | merchantUsers junction collection | Replaces ownerUserIds[] when membership becomes first-class | #231 |
| B4 | Merchant self-service signup | Uses same Firestore shape; adds onboarding flow | #136 |
| B5 | Self-service venue claim | Q2's deferred path; admin approves merchant claims | new issue |
| B6 | Delete / suspend merchant | merchants.status already has values; needs cascade + Auth disable | |
| B7 | Resend invite flow | Mirror admin's /resend-setup | |
| B8 | Email change on merchant | Firebase Auth verification flow | |
| B9 | Role change (promote/demote) | First-class operation with audit shape | |
| B10 | Audit trail for merchant actions | merchantAuditLog/{id} subcollection | #231 |
| B11 | Route migration /users/merchant/* โ /merchants/* | Consolidate once user-vs-entity is real |
C โ Explicitly not planned โ
| # | Decision | Why rejected |
|---|---|---|
| C1 | Many merchants per single venue | Food-court case degrades into child-venues-per-stall |
| C2 | One user belonging to multiple merchants | User confirmed: strict one-to-one. Could relax later if franchisee pattern emerges, not planned |
| C3 | merchantId as slug from businessName | Identity-as-mutable is a bug factory |
| C4 | Kill merchantProfiles/{uid} collection | Breaks symmetry with adminProfiles; no structural benefit |
| C5 | Mirror businessName on user doc | Becomes semantically wrong when one merchant has multiple users |
Acceptance criteria โ
- [ ] New merchant created via admin UI produces:
- [ ] One
merchants/{merchantId}doc withm_*id - [ ]
users/{uid}.merchantIdset - [ ]
merchantProfiles/{uid}withoutbusinessName - [ ] Firebase Auth user with
displayName = contactName
- [ ] One
- [ ] Admin can associate an available venue with a merchant via MerchantDetail
- [ ] Admin can disassociate a venue
- [ ] Claimed venues show as disabled in the picker with a visible state label
- [ ]
MerchantsAllqueriesmerchantscollection and shows businessName, owner email, status, venue count - [ ]
MerchantDetailrenders merchant + owners + venues in one round-trip - [ ] Firestore rules allow admin reads/writes; allow merchant reads of own merchant doc; deny client writes to merchants collection
- [ ] Firestore rules allow merchant updates of venues where
isMerchantOf(venue.merchantId) - [ ] Attempting to associate a venue that's already claimed returns 409
- [ ] Attempting to promote a user who already has a
merchantIdreturns 409 - [ ] Test merchant from prior iterations deleted; fresh create produces clean docs
Out of scope โ
- Merchant self-service signup (#136)
- Full
merchantUsersjunction + role/permissions system (#231) - Venue onboarding / create-new-venue flow
- Self-service venue claim
- Delete/suspend merchant
- Resend invite
- Email change
- Offers UI (#139) โ unblocked after this
- Consumer-app tether โ unblocked after this
Implementation notes โ
nanoidis not currently a dep ofservices/api/auth/; add topackage.json(small, well-maintained).node --watchcaveat (CLAUDE.md) โ new routes require auth-api restart; existing-route edits hot-reload.- Admin validation:
npm run validate -- --workspace services/api/authandnpm run validate -- --workspace apps/webduring iteration; fullnpm run validatebefore commit. - OpenAPI spec at services/api/auth/openapi.json must be updated for new routes per CLAUDE.md rule #8.