ADR-011: Route Base Path Consolidation
Status
Accepted
Context
There are ~53 top-level API route directories. Some domains are cleanly nested (/migration/*, /waitlist/*), while others are scattered across many base paths:
Credits has routes across 8 prefixes: /credits, /credit-packages, /user-credit-balances, /organization-credit-balances, /balances, /users/balances, /users/credits, /admin/credits, /admin/credit-transfers.
"Current user" operations are split across 4 prefixes: /users/me, /user (singular), /profile, /account.
Cross-domain routes hide under /users: /users/balances is a credit route, /users/me/reservations is a reservation route, /users/credits/subscription is a credit route.
Physical access is split across /access-codes, /access-gates, and /staff/locks.
This fragmentation makes it difficult for agents to discover all routes for a domain, and new routes get placed inconsistently.
Decision
Organizing principles
- Domain routes live under their domain prefix — not under the accessor.
/users/balancesis a credit route and belongs under/credits. /authand/usersare correctly separate —/authoperates on Supabaseauth.users(identity, sessions, passwords)./usersoperates on the localUsertable (profiles, roles, management). This follows the Supabase-recommended pattern of mirroring identity into apublic.userstable./mereplaces an owner/user ID segment within a domain — following the Twitter/Notion convention where/meis an ID substitution within each domain's namespace, not a top-level aggregator (contrast with the Microsoft Graph/Spotify pattern where/meis a top-level namespace mirroring/users/:id)./access-codes/memirrors/access-codes/:userId./users/memirrors/users/:id./credits/balances/memirrors/credits/balances/:id. Each domain owns its own/meroutes, keeping domain boundaries visible.
Current-user route consolidation
The "current user" API surface is split across 4 prefixes. Consolidate under /users/me:
/users/me (was /users/me — unchanged, auto-creates)
/users/me/profile (was /profile — full profile with roles)
/users/me/update (was /user/update — self-service profile update)
/users/me/preferences (was /user/preferences — communication preferences)
/users/me/overview (was /account/overview — aggregate dashboard)
/users/me/reservations (stays — member-friendly query API)
/users/me/waitlist (stays — member-scoped waitlist)
This eliminates the /user (singular), /profile, and /account top-level directories.
Credits consolidation
/credits (unchanged — credit type CRUD)
/credits/packages (was /credit-packages)
/credits/packages/:id (was /credit-packages/:id)
/credits/balances (was /user-credit-balances — staff balance management)
/credits/balances/:id (was /user-credit-balances/:id)
/credits/balances/me (was /users/balances — member-facing)
/credits/balances/check (was /balances/check)
/credits/balances/deduct (was /balances/deduct)
/credits/balances/restore (was /balances/restore)
/credits/org-balances (was /organization-credit-balances)
/credits/org-balances/:id (was /organization-credit-balances/:id)
/credits/subscription/me (was /users/credits/subscription — member-facing)
/credits/transfers/:id/retry (was /admin/credit-transfers/:id/retry)
Physical access consolidation
/access/codes (was /access-codes)
/access/codes/me (was /access-codes/me)
/access/codes/:userId (was /access-codes/:userId)
/access/gates (was /access-gates)
/access/gates/:id (was /access-gates/:id)
/access/locks (was /staff/locks — lock device status)
Per-reservation access codes (/reservations/[id]/access-codes) stay under /reservations — they're reservation-scoped operations.
/admin routes
/admin stays as a cross-cutting prefix for admin-only operations that span domains: audit-logs, jobs, impersonate. Domain-specific admin routes move to their domains:
/admin/credits→/credits(already has staff+ auth)/admin/credit-transfers/:id/retry→/credits/transfers/:id/retry/admin/create-user→/auth/create-user(creates Supabase auth user)/admin/locations/:id/pricingand/admin/resources/:id/pricingstay (pricing admin is a cross-domain concern)/admin/offersstays (offers are their own domain)
Route handler conventions (fix during migration)
Two per-route inconsistencies should be standardized as routes are migrated:
- Auth checking pattern: most routes use
if (auth.error) return auth.error, but some (e.g./account/overview) useif (!auth || !auth.isAuthenticated) throw new AppError(...). Converge on theauth.errorpattern. - Response helpers: some routes use
createNextSuccessResponse/createNextErrorResponse(e.g./users/balances,/users/credits/subscription), while most usecreateSuccessResponse/createErrorResponse. Converge on the non-Nextvariants.
Migration approach
Frontend API calls go through hooks that centralize URLs — each route migration requires updating one hook or API helper, not every call site. API_ENDPOINTS in apps/web/src/lib/config/api.ts is only partially populated; most hooks use inline getApiUrl() template strings.
This migration is incremental — each route can be moved independently with its tests. The old path can temporarily return a 301 redirect during the transition to avoid breaking in-flight frontend deployments.
ADR-009 (service architecture) and ADR-010 (code organization) should land first — services should be in the right directories before routes are reorganized.
Alternatives considered
Leave route paths as-is. Routes work — fragmentation is cosmetic. Rejected because agents place new routes inconsistently when a domain spans many prefixes, and discoverability suffers (an agent working on credits may not realize /balances/check and /admin/credit-transfers exist).
Merge /auth and /users into one prefix. Would reduce top-level directories. Rejected because they operate on different systems — /auth is Supabase identity (sessions, passwords, OAuth) and /users is local profile data (bio, roles, management). The Supabase-recommended pattern of mirroring auth.users into public.users gives these a legitimate architectural reason to be separate.
Top-level /me namespace (Microsoft Graph/Spotify pattern). /me as a top-level alias mirroring /users/:currentUserId, with sub-resources like /me/balances, /me/reservations. More familiar to API consumers. Rejected because it aggregates routes from multiple domains under one prefix, making it impossible for agents to discover all routes for a domain by looking at directory structure. The Twitter/Notion pattern (domain-scoped /me as ID substitution) achieves the same usability while preserving domain boundaries.
Consequences
Benefits:
- Route consolidation makes each domain's API surface discoverable under one prefix — agents find all related routes without searching multiple directories
- Eliminates the confusing
/uservs/usersvs/profilevs/accountsplit - Credits routes go from 8 prefixes to 1
Tradeoffs:
- Route migration touches both API route directories and frontend hooks. Each route move is a coordinated change.
- 301 redirects add temporary complexity during the transition period.
Risks:
- Route migration requires coordinating API and frontend deployments. Temporary 301 redirects from old paths mitigate this, but there's a window where stale frontend builds could hit removed paths. The risk is low since frontend hooks centralize URLs.
- Must be done after ADR-009 and ADR-010 land to avoid moving code twice.