Customer Module — Admin
HTTP surface for the admin customer-management portal (list, inspect, ban/unban, set-role, delete, set-password, session revocation, and storefront impersonation of non-staff accounts) plus the customer picker and lifetime-stats recompute. The storefront address book lives in the same module but is documented in store/customer.md.
HTTP surface for platform staff to manage customer accounts — every non-staff user (shoppers, including shoppers who are also vendors). Covers the management portal (list/detail + lifecycle actions + impersonation), the lightweight customer picker for form selects, and the lifetime-stats recompute job. The customer's storefront address book lives in the same module but is out of scope here (see store/customer.md).
Sources:
api-modules/customer/src/controllers/admin-customer-management.controller.ts,admin-customer-actions.controller.ts,admin-customer-impersonation.controller.ts,store-impersonation-redeem.controller.ts,admin-customer.controller.ts.The customer identity lives in Better-Auth's
usertable; this module owns the shopping-related customer data and the admin lifecycle surface on top of it.
Scope & enforcement model
- Who is a "customer": any account whose role is not
admin/superAdmin, has nodynamicRole, and is not an anonymous guest. Staff accounts are excluded from the list and from every mutation (their ids resolve to 404 there — existence is never revealed). - Reads for a promoted shopper still resolve. Staff and customers share one
usertable, so granting a shopper staff access (POST /admin/staff/grant-access) moves the same row out of "customer". The detail endpoint and its sub-resources deliberately stay readable for those ids, or a promoted shopper's orders, addresses and stats would become permanently unreachable. Only anonymous guests 404 on read. - Vendor-linked customers are included. A shopper who also became a vendor is the same
userrow; they appear here, badgedisVendor. Banning/deleting/impersonating affects their seller access too — surfaced as a warning in the UI. - Authorization is
dynamicRole-driven. The built-inadminrole grants nothing on its own;superAdminretains full access. Every action below is independently grantable to staff via adynamicRoleand enforced byPermissionsGuard(built-in OR dynamic).delete,set-password, andimpersonateare intentionally grantable (no superAdmin-only tier). - Target protection: all mutations refuse staff targets server-side (404), independent of the caller's grants.
set-roleadditionally refuses assigningadmin/superAdmin(the portal cannot mint staff). - Audit: ban/unban/set-role/delete/set-password/session-revoke/impersonate-start/impersonate-stop are written to
user_admin_audit(actor, target, before→after, source, reason) in the same transaction as the change.
Authentication & permissions
| Endpoint | Permission |
|---|---|
GET /admin/customers | user: list |
GET /admin/customers/:id | user: list |
GET /admin/customers/:id/orders | user: list |
GET /admin/customers/:id/sessions | user: list |
GET /admin/customers/:id/addresses | user: list |
GET /admin/customers/:id/audit | user: list |
POST /admin/customers/:id/ban | user: ban |
POST /admin/customers/:id/unban | user: ban |
POST /admin/customers/:id/set-role | user: set-role |
POST /admin/customers/:id/set-password | user: set-password |
DELETE /admin/customers/:id | user: delete |
POST /admin/customers/:id/sessions/revoke-all | session: revoke |
DELETE /admin/customers/:id/sessions/:token | session: revoke |
POST /admin/customers/:id/impersonate | user: impersonate |
POST /store/impersonation/redeem | none (guarded by the single-use handoff token) |
POST /admin/customers/impersonate/stop | none (must be in an impersonation session) |
GET /admin/customers/picker | user: list |
POST /admin/customers/recalculate-stats | user: update |
GET /admin/customers/recalculate-stats/status | user: update |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{ "data": <payload>, "message": "Success", "statusCode": 200, "metadata": { /* pagination if present */ } }Paginated lists use the canonical metadata: { total, limit, offset, hasMore } shape.
Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND (unknown id, a guest account, or a staff target on any mutation) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Management portal
GET /admin/customers — Customer list
Paginated. Substring ILIKE search across email, name, phone (searchValue). Facets: banned, emailVerified, isVendor, hasOrdered (each true/false, omit for any). Sort: sortBy ∈ createdAt | totalSpent | lastOrderedAt, sortDirection ∈ asc | desc. Offset pagination (limit, offset). Excludes admin/superAdmin and anonymous guests.
Row shape (CustomerSummary): id, name, email, emailVerified, image, phoneNumber, phoneNumberVerified, banned, isVendor, createdAt, totalSpent, deliveredOrderCount, lastOrderedAt.
GET /admin/customers/:id — Customer detail
Returns CustomerDetail: profile + status (banned, banReason, banExpires) + isVendor + vendorMemberships[] ({ vendorId, vendorName, vendorSlug, memberRole }) + stats (lifetime aggregates, null until first order). 404 for unknown ids and guest accounts; an account promoted to staff still resolves here.
GET /admin/customers/:id/orders — Orders (paginated)
limit/offset. Items: { id, orderNumber, status, paymentStatus, grandTotal, placedAt } (money in subunits), newest-first.
GET /admin/customers/:id/sessions — Active sessions
Array of { id, token, ipAddress, userAgent, isImpersonation, createdAt, expiresAt }.
GET /admin/customers/:id/addresses — Saved addresses
Array of the storefront address shape (see store/customer.md).
GET /admin/customers/:id/audit — Admin-action audit trail (paginated)
limit/offset. Items: { id, action, actorId, source, changes, createdAt }, newest-first.
POST /admin/customers/:id/ban — Ban
Body: { reason?: string, banExpiresInDays?: number } (omit banExpiresInDays for a permanent ban). Sets banned, revokes all the customer's sessions, audit-logged. Returns the refreshed CustomerDetail.
POST /admin/customers/:id/unban — Lift ban
Clears banned/banReason/banExpires. Returns refreshed CustomerDetail.
POST /admin/customers/:id/set-role — Set role
Body: { role: string | null } (null clears). Rejects admin/superAdmin with 403. Returns refreshed CustomerDetail.
POST /admin/customers/:id/set-password — Set password
Body: { newPassword: string (≥8), revokeSessions?: boolean (default true) }. Hashes with Better-Auth's configured hasher and updates the customer's credential account; 400 if the account has no password login (OAuth-only). Returns refreshed CustomerDetail.
DELETE /admin/customers/:id — Delete
Permanently deletes the account (cascades child rows; order.customerId set null). The deletion's audit row survives. Returns { success: true }. If the customer signed in with Apple, their Apple tokens are revoked after the delete commits, as App Store review requires (best-effort, never blocks the delete; see Social Sign-In).
POST /admin/customers/:id/sessions/revoke-all & DELETE /admin/customers/:id/sessions/:token
Revoke all or one session. Return the customer's remaining sessions.
Customer impersonation
Signs the admin into the storefront as the customer, so they see exactly what that shopper sees. Mirrors vendor impersonation (vendor.md) in storefront context; there is no activeVendorId to scope, since a customer session needs none.
The session is minted natively by us behind PermissionsGuard (never via Better-Auth's static-role check), and impersonatedBy + the admin_session cookie point at the real acting admin, so stopping restores that admin's own session. The redemption and the stop are both written to user_admin_audit with source admin-portal.
Why this is a two-step handoff
Each frontend proxies the API through its own origin (/bff/* on the dashboards, /api/v1/* on the storefront). That is deliberate: Better-Auth writes its session cookie onto whatever origin the request lands on, so routing through per-app origins keeps admin, vendor and store sessions isolated instead of sharing one cookie on a common API host.
The direct consequence is that the admin app cannot be given a customer session. A Set-Cookie on the admin's impersonate call is scoped to the admin origin — it would replace the caller's own admin session inside the admin app (locking them out of the dashboard until it expires), and the storefront would still see nothing.
So the start call returns a single-use token instead, and the storefront redeems it through its own origin:
admin app ──POST /admin/customers/:id/impersonate──▶ API (validates, mints token)
◀──────────────── handoffUrl ─────────────
│
└─▶ opens https://shop.example.com/impersonate?token=…
│
└─▶ POST /store/impersonation/redeem (via the STORE origin's proxy)
◀── Set-Cookie: session scoped to the store originPOST /admin/customers/:id/impersonate — Issue a handoff link
Required permission: user: impersonate. Validates the target up front, then mints a token — no session and no cookie are created here. Body: none. Vendor-context impersonation is not here — it lives in vendor management.
Response 200
{
"impersonatedUserId": "…",
"impersonatedUserEmail": "rita@example.test",
"impersonatedUserName": "Rita Shopper",
"storeUrl": "https://shop.example.com", // store.storefront_urls.store_url; null when unset
"handoffPath": "/impersonate?token=…", // for callers that know their own storefront origin
"handoffUrl": "https://shop.example.com/impersonate?token=…", // null when store URL unset
"expiresAt": "2026-08-01T12:01:00.000Z" // TOKEN expiry (60s), not the session's
}Errors — 404 NOT_FOUND (unknown id, a guest, or a staff account — the customer scope makes those indistinguishable, so ids can't be probed); 400 BAD_REQUEST (the customer is banned — lift the ban first); 403 FORBIDDEN (caller lacks user:impersonate).
POST /store/impersonation/redeem — Redeem the token
Public by necessity — the browser has no session on the store origin yet; creating one is the point of the call. Authority is the token itself: 32 random bytes, single-use (consumption is atomic, so a replay finds nothing) and valid for 60 seconds. The target is re-checked on redemption, so a ban or a promotion to staff inside that window still takes effect.
Body — { "token": "…" }
Response 200 — Set-Cookie establishes the session on the calling origin:
{
"impersonatedUserId": "…",
"impersonatedUserEmail": "rita@example.test",
"impersonatedUserName": "Rita Shopper",
"expiresAt": "2026-08-01T13:00:00.000Z" // SESSION expiry, 1h default
}Errors — 400 BAD_REQUEST (token missing, already used, or expired); the same 404/400 target checks as above if the customer changed in the meantime.
The storefront serves this at /impersonate, which redeems the token, strips it from the URL and history, and redirects to the home page.
POST /admin/customers/impersonate/stop — Stop impersonating
No permission required, by design: while impersonating, the caller's session is the (permissionless) customer's. Authority comes from the admin_session cookie, so it can only restore the admin who started this session. This is what the storefront's impersonation banner calls.
Response 200 — { "stopped": true }, with Set-Cookie restoring the admin's session.
Customer picker
GET /admin/customers/picker — Customer picker (form selects)
Required permission: user: list. Substring ILIKE search across email and name. Pass selectedIds=<csv> to receive a pinned block of already-applied customers. Uses the picker envelope (data[] + pinned[]) — see Picker responses in catalog.md.
CustomerListItem: { id, email, name, image, createdAt, totalSpent, averageSpent, deliveredOrderCount, lastDeliveredAt, lastOrderedAt }. Lifetime stats live in customer_order_stats (maintained from source on order.placed/order.delivered) and are embedded via one batched lookup (no N+1).
Lifetime-stats recompute
POST /admin/customers/recalculate-stats — Recompute lifetime stats
Required permission: user: update. Enqueues a background BullMQ job that rebuilds customer_order_stats for all customers from source and backfills order.delivered_at. Deduped on a stable jobId. Response 202: { status: "queued" | "running", jobId }.
GET /admin/customers/recalculate-stats/status — Recompute status
Required permission: user: update. Poll until state is completed/failed. Returns { state, result, error, finishedAt }.
Related modules
admin-rbac— gates every endpoint; the built-inadminrole grants nothing, so staff access flows throughdynamicRole. The current admin's effective permissions are served byGET /admin/rbac/me/permissions(used by the UI to gate actions). Seeadmin-rbac.md.auth— owns theuser/session/accounttables and Better-Auth's impersonation primitives; the impersonation session is minted via@sc/auth'smintImpersonationSessionand stopped via Better-Auth's nativestopImpersonating.order,reviews— admin flows that consume the picker to attach a customer onto an admin-side action.
Content Pages Module — Admin
HTTP surface for managing content (CMS) pages — operator-authored static pages addressable by slug. The content field stores raw HTML rendered verbatim by the storefront; pages form a tree via parentId.
Discount Module — Admin
HTTP surface for managing platform-wide discount coupons: percentage, fixed-amount, or free-gift coupons, with rich targeting (variants / categories / brands / tags / ingredients / vendors…