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,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 every endpoint here and their ids resolve to 404 (existence is never revealed). - 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 /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, or a staff/non-customer target) |
| 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/staff ids.
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 }.
POST /admin/customers/:id/sessions/revoke-all & DELETE /admin/customers/:id/sessions/:token
Revoke all or one session. Return the customer's remaining sessions.
POST /admin/customers/:id/impersonate — Impersonate (storefront)
Mints a storefront impersonation session bound to the acting admin (impersonatedBy = caller) and sets the session cookie on the response. The admin then browses the storefront as the customer. Vendor-context impersonation is not here — it lives in vendor management. Body: none. Response: { impersonatedUserId, impersonatedUserEmail, expiresAt }.
POST /admin/customers/impersonate/stop — Stop impersonating
Restores the acting admin's own session. No permission required — it only works from within an impersonation session (enforced by the admin_session cookie + impersonatedBy).
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 or fixed-amount discounts, with rich targeting (variants / categories / brands / tags / ingredients / vendors…