Skip to content

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

  1. Domain routes live under their domain prefix — not under the accessor. /users/balances is a credit route and belongs under /credits.
  2. /auth and /users are correctly separate/auth operates on Supabase auth.users (identity, sessions, passwords). /users operates on the local User table (profiles, roles, management). This follows the Supabase-recommended pattern of mirroring identity into a public.users table.
  3. /me replaces an owner/user ID segment within a domain — following the Twitter/Notion convention where /me is an ID substitution within each domain's namespace, not a top-level aggregator (contrast with the Microsoft Graph/Spotify pattern where /me is a top-level namespace mirroring /users/:id). /access-codes/me mirrors /access-codes/:userId. /users/me mirrors /users/:id. /credits/balances/me mirrors /credits/balances/:id. Each domain owns its own /me routes, 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/pricing and /admin/resources/:id/pricing stay (pricing admin is a cross-domain concern)
  • /admin/offers stays (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) use if (!auth || !auth.isAuthenticated) throw new AppError(...). Converge on the auth.error pattern.
  • Response helpers: some routes use createNextSuccessResponse/createNextErrorResponse (e.g. /users/balances, /users/credits/subscription), while most use createSuccessResponse/createErrorResponse. Converge on the non-Next variants.

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 /user vs /users vs /profile vs /account split
  • 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.