chore: Migrate user identity from auth0_sub to MVP-generated UUID #206

Closed
opened 2026-02-16 14:42:03 +00:00 by egullickson · 4 comments
Owner

Summary

Migrate all database foreign keys from auth0_sub VARCHAR(255) to user_profiles.id UUID as the primary user identifier. Currently, MotoVaultPro has no universal internal user ID - all tables reference users via Auth0's external ID format (auth0|xxx). The user_profiles.id UUID column already exists but is unused as a relational key.

  • Subscription admin override refactor: #205
  • Original Stripe implementation: #55
  • Tier gating system: #8

Background

Current State

-- user_profiles table has a UUID but it's not used as FK:
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),   -- EXISTS but unused as FK
auth0_sub VARCHAR(255) NOT NULL UNIQUE,            -- Used as FK EVERYWHERE

-- All other tables reference auth0_sub:
CONSTRAINT fk_subscriptions_user_id 
  FOREIGN KEY (user_id) REFERENCES user_profiles(auth0_sub)

Every feature table uses user_id VARCHAR(255) storing the Auth0 ID. This means:

  • MotoVaultPro cannot identify users independently of Auth0
  • If Auth0 ID format changes, ALL foreign keys break
  • VARCHAR(255) FK is less efficient than UUID FK for joins and indexes
  • No internal user identity exists separate from the auth provider

Affected Tables (10-15 tables)

  • subscriptions (user_id)
  • donations (user_id)
  • tier_vehicle_selections (user_id)
  • vehicles (user_id)
  • fuel_logs (user_id)
  • documents (user_id)
  • maintenance records (user_id)
  • audit_logs (user_id)
  • admin_users (auth0_sub)
  • user_preferences (user_id)
  • terms_agreements (user_id)
  • backup_* tables (user_id)
  • user_export/import tables (user_id)

Auth Flow After Migration

JWT (auth0 sub claim) -> lookup user_profiles by auth0_sub -> get UUID -> use UUID for all operations

auth0_sub remains in user_profiles as a login/lookup column but is no longer used as a foreign key.

Proposed Migration Strategy

Phase 1: Add UUID columns

-- For each affected table:
ALTER TABLE subscriptions ADD COLUMN user_profile_id UUID;
ALTER TABLE vehicles ADD COLUMN user_profile_id UUID;
-- ... repeat for all tables

Phase 2: Populate from join

UPDATE subscriptions s
SET user_profile_id = up.id
FROM user_profiles up
WHERE s.user_id = up.auth0_sub;
-- ... repeat for all tables

Phase 3: Add constraints

ALTER TABLE subscriptions ALTER COLUMN user_profile_id SET NOT NULL;
ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_user_profile_id 
  FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
-- ... repeat for all tables

Phase 4: Update application code

  • Auth plugin: Extract UUID from user_profiles lookup, set userContext.userId = UUID
  • All repositories: Query by UUID instead of auth0_sub
  • All mappers: Map new column names
  • OCR service: Accept UUID in request payloads

Phase 5: Drop old columns

ALTER TABLE subscriptions DROP CONSTRAINT fk_subscriptions_user_id;
ALTER TABLE subscriptions DROP COLUMN user_id;
ALTER TABLE subscriptions RENAME COLUMN user_profile_id TO user_id;
-- ... repeat for all tables

Risk Assessment

  • HIGH RISK: Touches every table in the database
  • Mitigation: Multi-phase migration with rollback capability
  • Mitigation: Maintenance window required for production
  • Mitigation: Full backup before migration
  • Testing: Must verify every feature after migration (vehicles, fuel logs, documents, maintenance, subscriptions, admin, etc.)

Acceptance Criteria

  • All tables reference user_profiles.id UUID instead of auth0_sub
  • auth0_sub remains in user_profiles as login lookup column
  • Auth plugin resolves JWT sub -> UUID for userContext.userId
  • All repositories query by UUID
  • All mappers updated for new column structure
  • OCR service accepts UUID
  • No data loss during migration
  • All features work end-to-end after migration
  • All linters pass
  • All tests pass
  • Mobile + desktop validation
  • Performance: UUID joins equal or better than VARCHAR(255) joins

Decision Critic Notes

This issue was separated from the subscription refactor (#205) based on Decision Critic analysis:

  • Strongest challenge: Bundling UUID migration with Stripe fixes dramatically increases blast radius
  • Alternative framing: UUID migration is an independent identity concern, not a subscription bug fix
  • Verdict: REVISE - separate into own issue with own PR for independent risk management

Out of Scope

  • Multi-tenant support (not planned)
  • Auth provider migration (Auth0 remains the provider)
  • Changing the auth flow itself (JWT validation unchanged)
## Summary Migrate all database foreign keys from `auth0_sub VARCHAR(255)` to `user_profiles.id UUID` as the primary user identifier. Currently, MotoVaultPro has no universal internal user ID - all tables reference users via Auth0's external ID format (`auth0|xxx`). The `user_profiles.id` UUID column already exists but is unused as a relational key. ## Related Issues - Subscription admin override refactor: #205 - Original Stripe implementation: #55 - Tier gating system: #8 ## Background ### Current State ```sql -- user_profiles table has a UUID but it's not used as FK: id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- EXISTS but unused as FK auth0_sub VARCHAR(255) NOT NULL UNIQUE, -- Used as FK EVERYWHERE -- All other tables reference auth0_sub: CONSTRAINT fk_subscriptions_user_id FOREIGN KEY (user_id) REFERENCES user_profiles(auth0_sub) ``` Every feature table uses `user_id VARCHAR(255)` storing the Auth0 ID. This means: - MotoVaultPro cannot identify users independently of Auth0 - If Auth0 ID format changes, ALL foreign keys break - VARCHAR(255) FK is less efficient than UUID FK for joins and indexes - No internal user identity exists separate from the auth provider ### Affected Tables (10-15 tables) - `subscriptions` (user_id) - `donations` (user_id) - `tier_vehicle_selections` (user_id) - `vehicles` (user_id) - `fuel_logs` (user_id) - `documents` (user_id) - `maintenance` records (user_id) - `audit_logs` (user_id) - `admin_users` (auth0_sub) - `user_preferences` (user_id) - `terms_agreements` (user_id) - `backup_*` tables (user_id) - `user_export/import` tables (user_id) ### Auth Flow After Migration ``` JWT (auth0 sub claim) -> lookup user_profiles by auth0_sub -> get UUID -> use UUID for all operations ``` `auth0_sub` remains in `user_profiles` as a login/lookup column but is no longer used as a foreign key. ## Proposed Migration Strategy ### Phase 1: Add UUID columns ```sql -- For each affected table: ALTER TABLE subscriptions ADD COLUMN user_profile_id UUID; ALTER TABLE vehicles ADD COLUMN user_profile_id UUID; -- ... repeat for all tables ``` ### Phase 2: Populate from join ```sql UPDATE subscriptions s SET user_profile_id = up.id FROM user_profiles up WHERE s.user_id = up.auth0_sub; -- ... repeat for all tables ``` ### Phase 3: Add constraints ```sql ALTER TABLE subscriptions ALTER COLUMN user_profile_id SET NOT NULL; ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_user_profile_id FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE; -- ... repeat for all tables ``` ### Phase 4: Update application code - Auth plugin: Extract UUID from user_profiles lookup, set `userContext.userId = UUID` - All repositories: Query by UUID instead of auth0_sub - All mappers: Map new column names - OCR service: Accept UUID in request payloads ### Phase 5: Drop old columns ```sql ALTER TABLE subscriptions DROP CONSTRAINT fk_subscriptions_user_id; ALTER TABLE subscriptions DROP COLUMN user_id; ALTER TABLE subscriptions RENAME COLUMN user_profile_id TO user_id; -- ... repeat for all tables ``` ## Risk Assessment - **HIGH RISK**: Touches every table in the database - **Mitigation**: Multi-phase migration with rollback capability - **Mitigation**: Maintenance window required for production - **Mitigation**: Full backup before migration - **Testing**: Must verify every feature after migration (vehicles, fuel logs, documents, maintenance, subscriptions, admin, etc.) ## Acceptance Criteria - [ ] All tables reference `user_profiles.id` UUID instead of `auth0_sub` - [ ] `auth0_sub` remains in `user_profiles` as login lookup column - [ ] Auth plugin resolves JWT sub -> UUID for `userContext.userId` - [ ] All repositories query by UUID - [ ] All mappers updated for new column structure - [ ] OCR service accepts UUID - [ ] No data loss during migration - [ ] All features work end-to-end after migration - [ ] All linters pass - [ ] All tests pass - [ ] Mobile + desktop validation - [ ] Performance: UUID joins equal or better than VARCHAR(255) joins ## Decision Critic Notes This issue was separated from the subscription refactor (#205) based on Decision Critic analysis: - **Strongest challenge**: Bundling UUID migration with Stripe fixes dramatically increases blast radius - **Alternative framing**: UUID migration is an independent identity concern, not a subscription bug fix - **Verdict**: REVISE - separate into own issue with own PR for independent risk management ## Out of Scope - Multi-tenant support (not planned) - Auth provider migration (Auth0 remains the provider) - Changing the auth flow itself (JWT validation unchanged)
egullickson added the
status
backlog
type
chore
labels 2026-02-16 14:42:09 +00:00
egullickson added
status
in-progress
and removed
status
backlog
labels 2026-02-16 14:48:37 +00:00
egullickson added this to the Sprint 2026-02-02 milestone 2026-02-16 14:48:37 +00:00
Author
Owner

Plan: Migrate user identity from auth0_sub to UUID

Phase: Planning | Agent: Planner | Status: AWAITING_REVIEW

Overview

Migrate all database foreign keys from auth0_sub VARCHAR(255) to user_profiles.id UUID as the primary user identifier. The user_profiles.id UUID column already exists but is unused as a relational key. The auth plugin already fetches the profile (with UUID) on every request -- the core change is using profile.id as userContext.userId instead of the raw JWT sub claim. After migration, all feature tables retain the user_id column name but with UUID type, so most repository SQL queries require no code changes.

Approach: Multi-phase SQL migration (add columns, backfill, add constraints, drop old, rename) in a single deployment, combined with application code updates to the auth plugin, admin system, and cross-table joins.

Planning Context

Decision Log

Decision Reasoning Chain
Multi-phase SQL migration over dual-write Standard PostgreSQL migration pattern for column type changes -> atomic within transaction -> rollback-friendly -> dual-write adds ongoing complexity in all repos for the same end result -> single-tenant app doesn't need zero-downtime migration
admin_users: id UUID PK + user_profile_id FK Decision Critic approved -> referential integrity prevents orphaned admin records -> admin creation already broken (generates fake auth0_sub) -> fixing to require user_profiles entry is better design -> one-time data fix during migration
admin_users keeps user_profile_id (not renamed to user_id) admin_users already has its own id UUID PK -> renaming user_profile_id to user_id would create ambiguity between admin's own id and the user reference -> user_profile_id explicitly communicates the FK relationship -> all other tables rename because they have no conflicting id column
admin_audit_logs actor/target become UUID referencing admin_users.id actor_admin_id and target_admin_id currently store auth0_sub strings -> after migration admin_users has UUID id -> audit logs should reference admin_users.id for consistency -> column type changes from VARCHAR to UUID, name stays the same
ON DELETE CASCADE for all user FKs Existing subscriptions/donations/tier_vehicle_selections FKs already use CASCADE -> consistency across all FKs -> single-tenant app means cascade is safe -> user deletion should clean up all dependent data
auth0_sub stays only in user_profiles JWT resolution requires auth0_sub for initial lookup -> user_profiles.auth0_sub stays as UNIQUE indexed column -> no other table needs auth0_sub after migration -> centralizing auth0_sub eliminates coupling to external ID format
Column name user_id preserved after migration (feature tables only) CLAUDE.md naming convention: snake_case for DB columns -> rename user_profile_id back to user_id in Phase 5 for feature tables -> SQL queries and mapRow() functions unchanged -> minimal code churn in feature repos. Excludes admin_users (see above).
Two identifiers in auth plugin: auth0Sub and userId Auth0 Management API requires auth0_sub format for API calls -> downstream DB operations need internal UUID -> auth plugin holds both: auth0Sub (from JWT) and userId (from profile.id) -> request.user.sub always available for Auth0 calls
Admin creation must ensure user_profiles entry Current admin controller generates fake auth0_sub (auth0|email_at_domain) -> after migration, admin_users.user_profile_id FK requires valid user_profiles entry -> admin creation should look up user_profiles by email or fail if not found

Rejected Alternatives

Alternative Why Rejected
Gradual dual-write migration Complex dual-write logic in all 17 repos -> cleanup phase required after migration -> longer timeline -> more total code changes -> single-tenant app doesn't need zero-downtime
Application-level UUID mapping (no DB changes) Performance overhead on every query for translation -> doesn't solve FK referential integrity -> auth0_sub still stored everywhere -> technical debt remains
Eliminate admin_users table (merge into user_profiles) Revocation semantics need separate lifecycle (created_at, revoked_at) -> audit trail separation valuable for compliance -> mixing access control into user_profiles violates separation of concerns
Keep auth0_sub in admin_users After migration, userContext.userId is UUID -> admin_guard would need to reverse-lookup auth0_sub -> unnecessary complexity when admin_users can directly reference user_profiles.id
Rename admin_users.user_profile_id to user_id admin_users already has its own id UUID PK -> user_id would be ambiguous (which id?) -> user_profile_id explicitly communicates the FK target

Constraints and Assumptions

  • PostgreSQL uuid_generate_v4() already available (used by user_profiles)
  • Single-tenant app: one user's data to migrate
  • Migration runner: backend/src/_system/migrations/run-all.ts executes per-feature
  • auth0_sub must remain in user_profiles for JWT resolution
  • npm test, npm run lint, npm run type-check must all pass
  • Mobile + desktop validation required (CLAUDE.md)
  • Convention: <default-conventions domain="testing"> integration tests preferred

Known Risks

Risk Mitigation Anchor
Data loss during migration Multi-phase approach: add UUID columns first, backfill, verify before dropping old VARCHAR columns. Transaction wraps each phase. N/A (migration SQL)
Admin without user_profiles entry Migration creates user_profiles entries for any admin lacking one before adding FK constraint admin.controller.ts:206 generates fake auth0_sub
Cross-table join breakage All joins updated in same deployment as migration user-profile.repository.ts:248 v.user_id = up.auth0_sub
Auth0 API calls break Auth0 Management API calls use request.user.sub directly, not userContext.userId auth.plugin.ts:140 auth0ManagementClient.getUser(userId) -- userId here is still from JWT sub before the reassignment

Invisible Knowledge

Architecture

Post-Migration Auth Flow:

  JWT (auth0|xxx)
        |
        v
  auth.plugin.ts
  |  1. jwtVerify() -> request.user.sub = "auth0|xxx"
  |  2. auth0Sub = request.user.sub (for Auth0 API calls)
  |  3. profileRepo.getOrCreate(auth0Sub) -> profile.id = UUID
  |  4. userId = profile.id (UUID for all DB operations)
  |  5. userContext = { userId (UUID), ... }
        |
        v
  All downstream code uses UUID
  |
  +-> Feature Services -> Feature Repos -> WHERE user_id = $1 (UUID)
  |
  +-> admin_guard -> admin_users WHERE user_profile_id = $1 (UUID)

Post-Migration Table Relationships:

  user_profiles
  +--id UUID PK-----------+-------+-------+-------+
  |  auth0_sub VARCHAR     |       |       |       |
  |  email, display_name   |       |       |       |
  +---------+--------------+       |       |       |
            |                      |       |       |
            v (FK)                 v (FK)  v (FK)  v (FK)
    admin_users             vehicles  subscriptions  ...all tables
    +--id UUID PK           user_id   user_id        user_id
    |  user_profile_id FK   UUID FK   UUID FK        UUID
    |  email, role
    |
    v (referenced by)
    admin_audit_logs
    actor_admin_id UUID -> admin_users.id
    target_admin_id UUID -> admin_users.id

Invariants

  • userContext.userId is always a UUID after auth plugin hydration
  • auth0_sub is only used for JWT-to-profile resolution (never as FK)
  • Every admin in admin_users must have a corresponding user_profiles entry
  • request.user.sub (raw JWT claim) is distinct from userContext.userId (UUID)
  • admin_users keeps user_profile_id column name (not renamed to user_id)
  • admin_audit_logs.actor_admin_id and target_admin_id reference admin_users.id UUID

Tradeoffs

  • VARCHAR(255) to UUID migration requires maintenance window but provides permanent FK integrity
  • admin_users FK constraint prevents standalone admin creation (must have user_profiles entry first) but ensures data consistency
  • Keeping auth0_sub only in user_profiles adds one DB lookup on auth but eliminates external ID coupling across all tables

Milestones

Milestone 1: Database migration SQL

Files:

  • backend/src/features/user-profile/migrations/005_migrate_user_id_to_uuid.sql (new)

Requirements:

  • Phase 1: Add user_profile_id UUID column to all 18 affected feature tables
  • Phase 2: Backfill UUID values from user_profiles join (SET user_profile_id = up.id FROM user_profiles up WHERE table.user_id = up.auth0_sub)
  • Phase 3: For admin_users: add id UUID PK DEFAULT uuid_generate_v4(), add user_profile_id UUID, backfill from user_profiles join, create user_profiles entries for admins without one. For admin_audit_logs: change actor_admin_id and target_admin_id from VARCHAR to UUID, backfill from admin_users join (SET actor_admin_id = au.id FROM admin_users au WHERE logs.actor_admin_id = au.auth0_sub)
  • Phase 4: Set NOT NULL constraints on new UUID columns, add FK constraints to user_profiles(id) with ON DELETE CASCADE, add FK from admin_users.user_profile_id to user_profiles(id), add indexes replacing old VARCHAR indexes
  • Phase 5: Drop old VARCHAR user_id columns and FK constraints from feature tables, rename user_profile_id to user_id in feature tables. For admin_users: drop old auth0_sub PK, keep user_profile_id column name (not renamed). Drop old auth0_sub columns from admin_audit_logs actor/target.
  • Handle special columns: community_stations.submitted_by, backup_history.created_by, station_removal_reports.reported_by -- add UUID equivalents, backfill, rename

Acceptance Criteria:

  • Migration executes without error on existing database
  • All feature tables have user_id UUID referencing user_profiles.id
  • admin_users has id UUID PK + user_profile_id UUID FK (not renamed)
  • admin_audit_logs.actor_admin_id and target_admin_id are UUID type referencing admin_users.id
  • user_profiles.auth0_sub remains as VARCHAR UNIQUE column
  • No data loss (row counts match before/after)
  • All FK constraints pass validation

Tests:

  • Skip: Migration SQL tested by execution; integration tests in M7 verify end-to-end.

Milestone 2: Auth plugin and admin guard

Files:

  • backend/src/core/plugins/auth.plugin.ts
  • backend/src/core/plugins/admin-guard.plugin.ts

Requirements:

  • auth.plugin.ts: After getOrCreate (line 158), use profile.id (UUID) as userContext.userId instead of raw JWT sub
  • auth.plugin.ts: Keep using request.user.sub for Auth0 Management API calls (line 140) -- this is the raw auth0_sub needed by Auth0
  • admin-guard.plugin.ts: Change query from WHERE auth0_sub = $1 to WHERE user_profile_id = $1
  • admin-guard.plugin.ts: Update SELECT columns to include id instead of auth0_sub

Acceptance Criteria:

  • userContext.userId contains UUID format after authentication
  • Admin guard correctly identifies admin users by UUID
  • Auth0 Management API calls still work (use raw JWT sub, not UUID)

Tests:

  • Test files: backend/src/features/auth/tests/
  • Test type: integration | doc-derived
  • Scenarios:
    • Normal: Authenticated request sets userContext.userId as UUID
    • Normal: Admin user recognized via admin_guard
    • Edge: User with no profile gets one created, userId is new profile's UUID

Code Changes:

Rename userId to auth0Sub for JWT sub, declare userId for UUID assignment:

--- a/backend/src/core/plugins/auth.plugin.ts
+++ b/backend/src/core/plugins/auth.plugin.ts
@@ -119,9 +119,12 @@
   fastify.decorate('authenticate', async function(request: FastifyRequest, reply: FastifyReply) {
     try {
       await request.jwtVerify();

-      const userId = request.user?.sub;
-      if (!userId) {
+      // Two identifiers: auth0Sub (external, for Auth0 API) and userId (internal UUID, for all DB operations)
+      const auth0Sub = request.user?.sub;
+      if (!auth0Sub) {
         throw new Error('Missing user ID in JWT');
       }
+
+      let userId: string;

Auth0 Management API uses auth0Sub:

--- a/backend/src/core/plugins/auth.plugin.ts
+++ b/backend/src/core/plugins/auth.plugin.ts
@@ -138,7 +138,7 @@
         if (!email || email.includes('@unknown.local')) {
           try {
-            const auth0User = await auth0ManagementClient.getUser(userId);
+            const auth0User = await auth0ManagementClient.getUser(auth0Sub);

Profile getOrCreate uses auth0Sub, then assigns UUID to userId:

--- a/backend/src/core/plugins/auth.plugin.ts
+++ b/backend/src/core/plugins/auth.plugin.ts
@@ -157,8 +157,9 @@

         // Get or create profile with correct email
-        const profile = await profileRepo.getOrCreate(userId, {
-          email: email || `${userId}@unknown.local`,
+        const profile = await profileRepo.getOrCreate(auth0Sub, {
+          email: email || `${auth0Sub}@unknown.local`,
           displayName: request.user?.name || request.user?.nickname,
         });
+        userId = profile.id;

Profile sync methods use auth0Sub:

--- a/backend/src/core/plugins/auth.plugin.ts
+++ b/backend/src/core/plugins/auth.plugin.ts
@@ -163,10 +163,10 @@
         // If profile has placeholder email but we now have real email, update it
         if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) {
-          await profileRepo.updateEmail(userId, email);
+          await profileRepo.updateEmail(auth0Sub, email);
--- a/backend/src/core/plugins/auth.plugin.ts
+++ b/backend/src/core/plugins/auth.plugin.ts
@@ -178,10 +178,10 @@
         if (!emailVerified) {
           try {
-            const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(userId);
+            const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(auth0Sub);
             if (isVerifiedInAuth0 && !profile.emailVerified) {
-              await profileRepo.updateEmailVerified(userId, true);
+              await profileRepo.updateEmailVerified(auth0Sub, true);

Admin guard queries by user_profile_id:

--- a/backend/src/core/plugins/admin-guard.plugin.ts
+++ b/backend/src/core/plugins/admin-guard.plugin.ts
@@ -59,9 +59,9 @@
       // Check if user is in admin_users table and not revoked
       const query = `
-        SELECT auth0_sub, email, role, revoked_at
+        SELECT id, user_profile_id, email, role, revoked_at
         FROM admin_users
-        WHERE auth0_sub = $1 AND revoked_at IS NULL
+        WHERE user_profile_id = $1 AND revoked_at IS NULL
         LIMIT 1
       `;

Milestone 3: Admin system refactor

Files:

  • backend/src/features/admin/domain/admin.types.ts
  • backend/src/features/admin/data/admin.repository.ts
  • backend/src/features/admin/domain/admin.service.ts
  • backend/src/features/admin/api/admin.controller.ts

Requirements:

  • admin.types.ts: Replace auth0Sub: string with id: string + userProfileId: string in AdminUser. Update RevokeAdminRequest, ReinstateAdminRequest, BulkRevokeAdminRequest, BulkReinstateAdminRequest to use id instead of auth0Sub.
  • admin.repository.ts: Update all SQL queries from auth0_sub to id/user_profile_id. Update mapRowToAdminUser to map id and user_profile_id. Update mapRowToAuditLog to map actor_admin_id and target_admin_id as UUID strings. Change getAdminByAuth0Sub to getAdminByUserProfileId. Update createAdmin to accept userProfileId UUID instead of auth0Sub. Update logAuditAction to accept UUID actorAdminId and targetAdminId.
  • admin.service.ts: Update method signatures to use UUID identifiers.
  • admin.controller.ts: Remove fake auth0_sub generation (auth0|email_at_domain). Admin creation looks up user_profiles by email to get UUID. If no profile exists, return error.

Acceptance Criteria:

  • Admin CRUD operations work with UUID identifiers
  • Admin creation requires existing user_profiles entry (no fake auth0_sub)
  • Admin audit logs record UUID actor/target IDs (referencing admin_users.id)
  • Revoke/reinstate operations use admin UUID id
  • All admin API endpoints functional

Tests:

  • Test files: backend/src/features/admin/tests/unit/admin.service.test.ts, backend/src/features/admin/tests/integration/admin.integration.test.ts
  • Test type: integration | doc-derived
  • Scenarios:
    • Normal: Create admin for user with existing profile
    • Normal: Revoke admin by UUID id
    • Error: Create admin for email without user_profiles entry returns error
    • Normal: Audit logs record UUID actor/target IDs

Milestone 4: User profile repository refactor

Files:

  • backend/src/features/user-profile/data/user-profile.repository.ts

Flags: needs conformance check (15+ methods changing parameter type)

Requirements:

  • Keep getByAuth0Sub(auth0Sub) for auth plugin usage (JWT resolution)
  • Add getById(id: string) method for UUID-based lookups
  • Change all other methods that accept auth0Sub parameter to accept UUID id: update, updateSubscriptionTier, deactivateUser, reactivateUser, adminUpdateProfile, updateEmailVerified, markOnboardingComplete, updateEmail, requestDeletion, cancelDeletion, hardDeleteUser, getUserWithAdminStatus, getUserVehiclesForAdmin
  • Update SQL in changed methods from WHERE auth0_sub = $1 to WHERE id = $1
  • Update hardDeleteUser to query by UUID across all tables (7 DELETE statements use WHERE user_id = $1 with UUID value)
  • Update listAllUsers cross-table joins: v.user_id = up.id (was up.auth0_sub), au.user_profile_id = up.id (was au.auth0_sub)
  • Update getUserWithAdminStatus join: au.user_profile_id = up.id (was au.auth0_sub)

Acceptance Criteria:

  • getByAuth0Sub still works for auth plugin
  • All other methods accept UUID id parameter
  • hardDeleteUser deletes all user data correctly
  • listAllUsers joins work with UUID columns
  • getUserWithAdminStatus joins with new admin_users schema

Tests:

  • Test files: Existing integration tests cover profile operations
  • Test type: integration | doc-derived
  • Scenarios:
    • Normal: Profile operations by UUID
    • Normal: Hard delete removes all user data
    • Normal: List users with admin status join

Milestone 5: Feature repository validation

Files:

  • backend/src/features/vehicles/data/vehicles.repository.ts
  • backend/src/features/fuel-logs/data/fuel-logs.repository.ts
  • backend/src/features/maintenance/data/maintenance.repository.ts
  • backend/src/features/documents/data/documents.repository.ts
  • backend/src/features/stations/data/stations.repository.ts
  • backend/src/features/subscriptions/data/subscriptions.repository.ts
  • backend/src/features/notifications/data/notifications.repository.ts
  • backend/src/features/ownership-costs/data/ownership-costs.repository.ts
  • backend/src/features/terms-agreement/data/terms-agreement.repository.ts
  • backend/src/core/user-preferences/data/user-preferences.repository.ts
  • backend/src/features/email-ingestion/data/email-ingestion.repository.ts

Requirements:

  • After DB migration, column name stays user_id (renamed from user_profile_id), so WHERE user_id = $1 queries and mapRow() functions require NO changes
  • The pg driver handles UUID-as-string transparently
  • Verify each repository compiles and functions with UUID values passed as string parameters
  • No code changes expected -- this milestone validates the zero-change hypothesis

Acceptance Criteria:

  • All feature repositories compile without error
  • All feature queries execute correctly with UUID parameter values
  • mapRow() functions return correct userId values (UUID strings)

Tests:

  • Test files: Existing feature integration tests
  • Test type: integration | doc-derived
  • Scenarios:
    • Normal: CRUD operations for vehicles, fuel logs, maintenance, documents, stations, subscriptions with UUID userId

Milestone 6: Supporting code updates

Files:

  • backend/src/features/audit-log/data/audit-log.repository.ts
  • backend/src/features/backup/data/backup.repository.ts
  • backend/src/features/stations/data/community-stations.repository.ts
  • backend/src/features/user-import/domain/user-import.service.ts
  • backend/src/features/ocr/api/ocr.controller.ts

Requirements:

  • audit-log.repository.ts: Update join LEFT JOIN user_profiles up ON al.user_id = up.auth0_sub to al.user_id = up.id
  • backup.repository.ts: created_by column type changed by migration; mapRow stays the same
  • community-stations.repository.ts: submitted_by column type changed by migration; verify queries work with UUID
  • user-import.service.ts: Inline SQL queries use user_id = $1 -- verify UUID compatibility. Update any direct references to auth0_sub format.
  • ocr.controller.ts: OCR endpoints use request.userContext?.userId for user identification (7 endpoints) -- consistent with all other feature controllers

Acceptance Criteria:

  • Audit log queries join correctly on UUID
  • Backup created_by stores UUID values
  • Community station submitted_by works with UUID
  • User import/export handles UUID user identity
  • OCR controller uses userContext.userId consistently

Tests:

  • Test files: Existing feature tests
  • Test type: integration | doc-derived
  • Scenarios:
    • Normal: Audit log entries display correct user email (join works)
    • Normal: Community station submission records UUID submitter

Milestone 7: Test fixtures and documentation

Files:

  • backend/src/features/auth/tests/integration/auth.integration.test.ts
  • backend/src/features/auth/tests/unit/auth.service.test.ts
  • backend/src/features/admin/tests/integration/admin.integration.test.ts
  • backend/src/features/admin/tests/unit/admin.service.test.ts
  • backend/src/features/maintenance/tests/fixtures/maintenance.fixtures.json
  • docs/PLATFORM-SERVICES.md (if user identity documented)

Requirements:

  • Replace all hardcoded auth0|... values with UUID format in test fixtures and mocks
  • Update test user context setup to use UUID for userId
  • Update admin test fixtures to use new admin schema (id, userProfileId instead of auth0Sub)
  • Run full test suite: npm test, npm run lint, npm run type-check
  • Update documentation if user identity is referenced

Acceptance Criteria:

  • All tests pass with zero failures
  • All linters pass with zero issues
  • TypeScript type-check passes
  • No remaining references to auth0| format in test code (except for auth plugin tests that test JWT parsing)
  • Documentation reflects UUID identity model

Tests:

  • Full test suite execution is the acceptance criterion for this milestone.

Milestone Dependencies

M1 (DB migration) ---> M2 (auth + guard) ---> M3 (admin refactor)
                   \                      \--> M4 (profile repo)
                    \--> M5 (feature repos - validation only)
                    \--> M6 (supporting code)
                                                    |
                                     M7 (tests + docs) depends on all

M1 must complete first. M2 depends on M1. M3 and M4 depend on M2. M5 and M6 depend on M1. M7 depends on all.

Verdict: AWAITING_REVIEW | Next: Plan review cycle (QR code -> QR docs)

## Plan: Migrate user identity from auth0_sub to UUID **Phase**: Planning | **Agent**: Planner | **Status**: AWAITING_REVIEW ## Overview Migrate all database foreign keys from `auth0_sub VARCHAR(255)` to `user_profiles.id UUID` as the primary user identifier. The `user_profiles.id` UUID column already exists but is unused as a relational key. The auth plugin already fetches the profile (with UUID) on every request -- the core change is using `profile.id` as `userContext.userId` instead of the raw JWT `sub` claim. After migration, all feature tables retain the `user_id` column name but with UUID type, so most repository SQL queries require no code changes. **Approach**: Multi-phase SQL migration (add columns, backfill, add constraints, drop old, rename) in a single deployment, combined with application code updates to the auth plugin, admin system, and cross-table joins. ## Planning Context ### Decision Log | Decision | Reasoning Chain | |----------|----------------| | Multi-phase SQL migration over dual-write | Standard PostgreSQL migration pattern for column type changes -> atomic within transaction -> rollback-friendly -> dual-write adds ongoing complexity in all repos for the same end result -> single-tenant app doesn't need zero-downtime migration | | admin_users: id UUID PK + user_profile_id FK | Decision Critic approved -> referential integrity prevents orphaned admin records -> admin creation already broken (generates fake auth0_sub) -> fixing to require user_profiles entry is better design -> one-time data fix during migration | | admin_users keeps user_profile_id (not renamed to user_id) | admin_users already has its own `id` UUID PK -> renaming user_profile_id to user_id would create ambiguity between admin's own id and the user reference -> user_profile_id explicitly communicates the FK relationship -> all other tables rename because they have no conflicting id column | | admin_audit_logs actor/target become UUID referencing admin_users.id | actor_admin_id and target_admin_id currently store auth0_sub strings -> after migration admin_users has UUID id -> audit logs should reference admin_users.id for consistency -> column type changes from VARCHAR to UUID, name stays the same | | ON DELETE CASCADE for all user FKs | Existing subscriptions/donations/tier_vehicle_selections FKs already use CASCADE -> consistency across all FKs -> single-tenant app means cascade is safe -> user deletion should clean up all dependent data | | auth0_sub stays only in user_profiles | JWT resolution requires auth0_sub for initial lookup -> user_profiles.auth0_sub stays as UNIQUE indexed column -> no other table needs auth0_sub after migration -> centralizing auth0_sub eliminates coupling to external ID format | | Column name user_id preserved after migration (feature tables only) | CLAUDE.md naming convention: snake_case for DB columns -> rename user_profile_id back to user_id in Phase 5 for feature tables -> SQL queries and mapRow() functions unchanged -> minimal code churn in feature repos. Excludes admin_users (see above). | | Two identifiers in auth plugin: auth0Sub and userId | Auth0 Management API requires auth0_sub format for API calls -> downstream DB operations need internal UUID -> auth plugin holds both: auth0Sub (from JWT) and userId (from profile.id) -> request.user.sub always available for Auth0 calls | | Admin creation must ensure user_profiles entry | Current admin controller generates fake auth0_sub (auth0\|email_at_domain) -> after migration, admin_users.user_profile_id FK requires valid user_profiles entry -> admin creation should look up user_profiles by email or fail if not found | ### Rejected Alternatives | Alternative | Why Rejected | |-------------|-------------| | Gradual dual-write migration | Complex dual-write logic in all 17 repos -> cleanup phase required after migration -> longer timeline -> more total code changes -> single-tenant app doesn't need zero-downtime | | Application-level UUID mapping (no DB changes) | Performance overhead on every query for translation -> doesn't solve FK referential integrity -> auth0_sub still stored everywhere -> technical debt remains | | Eliminate admin_users table (merge into user_profiles) | Revocation semantics need separate lifecycle (created_at, revoked_at) -> audit trail separation valuable for compliance -> mixing access control into user_profiles violates separation of concerns | | Keep auth0_sub in admin_users | After migration, userContext.userId is UUID -> admin_guard would need to reverse-lookup auth0_sub -> unnecessary complexity when admin_users can directly reference user_profiles.id | | Rename admin_users.user_profile_id to user_id | admin_users already has its own id UUID PK -> user_id would be ambiguous (which id?) -> user_profile_id explicitly communicates the FK target | ### Constraints and Assumptions - PostgreSQL `uuid_generate_v4()` already available (used by user_profiles) - Single-tenant app: one user's data to migrate - Migration runner: `backend/src/_system/migrations/run-all.ts` executes per-feature - `auth0_sub` must remain in `user_profiles` for JWT resolution - `npm test`, `npm run lint`, `npm run type-check` must all pass - Mobile + desktop validation required (CLAUDE.md) - Convention: `<default-conventions domain="testing">` integration tests preferred ### Known Risks | Risk | Mitigation | Anchor | |------|-----------|--------| | Data loss during migration | Multi-phase approach: add UUID columns first, backfill, verify before dropping old VARCHAR columns. Transaction wraps each phase. | N/A (migration SQL) | | Admin without user_profiles entry | Migration creates user_profiles entries for any admin lacking one before adding FK constraint | `admin.controller.ts:206` generates fake auth0_sub | | Cross-table join breakage | All joins updated in same deployment as migration | `user-profile.repository.ts:248` `v.user_id = up.auth0_sub` | | Auth0 API calls break | Auth0 Management API calls use `request.user.sub` directly, not `userContext.userId` | `auth.plugin.ts:140` `auth0ManagementClient.getUser(userId)` -- userId here is still from JWT sub before the reassignment | ## Invisible Knowledge ### Architecture ``` Post-Migration Auth Flow: JWT (auth0|xxx) | v auth.plugin.ts | 1. jwtVerify() -> request.user.sub = "auth0|xxx" | 2. auth0Sub = request.user.sub (for Auth0 API calls) | 3. profileRepo.getOrCreate(auth0Sub) -> profile.id = UUID | 4. userId = profile.id (UUID for all DB operations) | 5. userContext = { userId (UUID), ... } | v All downstream code uses UUID | +-> Feature Services -> Feature Repos -> WHERE user_id = $1 (UUID) | +-> admin_guard -> admin_users WHERE user_profile_id = $1 (UUID) Post-Migration Table Relationships: user_profiles +--id UUID PK-----------+-------+-------+-------+ | auth0_sub VARCHAR | | | | | email, display_name | | | | +---------+--------------+ | | | | | | | v (FK) v (FK) v (FK) v (FK) admin_users vehicles subscriptions ...all tables +--id UUID PK user_id user_id user_id | user_profile_id FK UUID FK UUID FK UUID | email, role | v (referenced by) admin_audit_logs actor_admin_id UUID -> admin_users.id target_admin_id UUID -> admin_users.id ``` ### Invariants - `userContext.userId` is always a UUID after auth plugin hydration - `auth0_sub` is only used for JWT-to-profile resolution (never as FK) - Every admin in `admin_users` must have a corresponding `user_profiles` entry - `request.user.sub` (raw JWT claim) is distinct from `userContext.userId` (UUID) - `admin_users` keeps `user_profile_id` column name (not renamed to `user_id`) - `admin_audit_logs.actor_admin_id` and `target_admin_id` reference `admin_users.id` UUID ### Tradeoffs - VARCHAR(255) to UUID migration requires maintenance window but provides permanent FK integrity - admin_users FK constraint prevents standalone admin creation (must have user_profiles entry first) but ensures data consistency - Keeping auth0_sub only in user_profiles adds one DB lookup on auth but eliminates external ID coupling across all tables ## Milestones ### Milestone 1: Database migration SQL **Files**: - `backend/src/features/user-profile/migrations/005_migrate_user_id_to_uuid.sql` (new) **Requirements**: - Phase 1: Add `user_profile_id UUID` column to all 18 affected feature tables - Phase 2: Backfill UUID values from `user_profiles` join (`SET user_profile_id = up.id FROM user_profiles up WHERE table.user_id = up.auth0_sub`) - Phase 3: For `admin_users`: add `id UUID PK DEFAULT uuid_generate_v4()`, add `user_profile_id UUID`, backfill from user_profiles join, create user_profiles entries for admins without one. For `admin_audit_logs`: change `actor_admin_id` and `target_admin_id` from VARCHAR to UUID, backfill from admin_users join (`SET actor_admin_id = au.id FROM admin_users au WHERE logs.actor_admin_id = au.auth0_sub`) - Phase 4: Set NOT NULL constraints on new UUID columns, add FK constraints to `user_profiles(id)` with ON DELETE CASCADE, add FK from `admin_users.user_profile_id` to `user_profiles(id)`, add indexes replacing old VARCHAR indexes - Phase 5: Drop old VARCHAR `user_id` columns and FK constraints from feature tables, rename `user_profile_id` to `user_id` in feature tables. For `admin_users`: drop old `auth0_sub` PK, keep `user_profile_id` column name (not renamed). Drop old auth0_sub columns from `admin_audit_logs` actor/target. - Handle special columns: `community_stations.submitted_by`, `backup_history.created_by`, `station_removal_reports.reported_by` -- add UUID equivalents, backfill, rename **Acceptance Criteria**: - Migration executes without error on existing database - All feature tables have `user_id UUID` referencing `user_profiles.id` - `admin_users` has `id UUID PK` + `user_profile_id UUID FK` (not renamed) - `admin_audit_logs.actor_admin_id` and `target_admin_id` are UUID type referencing `admin_users.id` - `user_profiles.auth0_sub` remains as VARCHAR UNIQUE column - No data loss (row counts match before/after) - All FK constraints pass validation **Tests**: - Skip: Migration SQL tested by execution; integration tests in M7 verify end-to-end. ### Milestone 2: Auth plugin and admin guard **Files**: - `backend/src/core/plugins/auth.plugin.ts` - `backend/src/core/plugins/admin-guard.plugin.ts` **Requirements**: - auth.plugin.ts: After `getOrCreate` (line 158), use `profile.id` (UUID) as `userContext.userId` instead of raw JWT `sub` - auth.plugin.ts: Keep using `request.user.sub` for Auth0 Management API calls (line 140) -- this is the raw auth0_sub needed by Auth0 - admin-guard.plugin.ts: Change query from `WHERE auth0_sub = $1` to `WHERE user_profile_id = $1` - admin-guard.plugin.ts: Update SELECT columns to include `id` instead of `auth0_sub` **Acceptance Criteria**: - `userContext.userId` contains UUID format after authentication - Admin guard correctly identifies admin users by UUID - Auth0 Management API calls still work (use raw JWT sub, not UUID) **Tests**: - **Test files**: `backend/src/features/auth/tests/` - **Test type**: integration | doc-derived - **Scenarios**: - Normal: Authenticated request sets `userContext.userId` as UUID - Normal: Admin user recognized via admin_guard - Edge: User with no profile gets one created, userId is new profile's UUID **Code Changes**: Rename userId to auth0Sub for JWT sub, declare userId for UUID assignment: ```diff --- a/backend/src/core/plugins/auth.plugin.ts +++ b/backend/src/core/plugins/auth.plugin.ts @@ -119,9 +119,12 @@ fastify.decorate('authenticate', async function(request: FastifyRequest, reply: FastifyReply) { try { await request.jwtVerify(); - const userId = request.user?.sub; - if (!userId) { + // Two identifiers: auth0Sub (external, for Auth0 API) and userId (internal UUID, for all DB operations) + const auth0Sub = request.user?.sub; + if (!auth0Sub) { throw new Error('Missing user ID in JWT'); } + + let userId: string; ``` Auth0 Management API uses auth0Sub: ```diff --- a/backend/src/core/plugins/auth.plugin.ts +++ b/backend/src/core/plugins/auth.plugin.ts @@ -138,7 +138,7 @@ if (!email || email.includes('@unknown.local')) { try { - const auth0User = await auth0ManagementClient.getUser(userId); + const auth0User = await auth0ManagementClient.getUser(auth0Sub); ``` Profile getOrCreate uses auth0Sub, then assigns UUID to userId: ```diff --- a/backend/src/core/plugins/auth.plugin.ts +++ b/backend/src/core/plugins/auth.plugin.ts @@ -157,8 +157,9 @@ // Get or create profile with correct email - const profile = await profileRepo.getOrCreate(userId, { - email: email || `${userId}@unknown.local`, + const profile = await profileRepo.getOrCreate(auth0Sub, { + email: email || `${auth0Sub}@unknown.local`, displayName: request.user?.name || request.user?.nickname, }); + userId = profile.id; ``` Profile sync methods use auth0Sub: ```diff --- a/backend/src/core/plugins/auth.plugin.ts +++ b/backend/src/core/plugins/auth.plugin.ts @@ -163,10 +163,10 @@ // If profile has placeholder email but we now have real email, update it if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) { - await profileRepo.updateEmail(userId, email); + await profileRepo.updateEmail(auth0Sub, email); ``` ```diff --- a/backend/src/core/plugins/auth.plugin.ts +++ b/backend/src/core/plugins/auth.plugin.ts @@ -178,10 +178,10 @@ if (!emailVerified) { try { - const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(userId); + const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(auth0Sub); if (isVerifiedInAuth0 && !profile.emailVerified) { - await profileRepo.updateEmailVerified(userId, true); + await profileRepo.updateEmailVerified(auth0Sub, true); ``` Admin guard queries by user_profile_id: ```diff --- a/backend/src/core/plugins/admin-guard.plugin.ts +++ b/backend/src/core/plugins/admin-guard.plugin.ts @@ -59,9 +59,9 @@ // Check if user is in admin_users table and not revoked const query = ` - SELECT auth0_sub, email, role, revoked_at + SELECT id, user_profile_id, email, role, revoked_at FROM admin_users - WHERE auth0_sub = $1 AND revoked_at IS NULL + WHERE user_profile_id = $1 AND revoked_at IS NULL LIMIT 1 `; ``` ### Milestone 3: Admin system refactor **Files**: - `backend/src/features/admin/domain/admin.types.ts` - `backend/src/features/admin/data/admin.repository.ts` - `backend/src/features/admin/domain/admin.service.ts` - `backend/src/features/admin/api/admin.controller.ts` **Requirements**: - admin.types.ts: Replace `auth0Sub: string` with `id: string` + `userProfileId: string` in `AdminUser`. Update `RevokeAdminRequest`, `ReinstateAdminRequest`, `BulkRevokeAdminRequest`, `BulkReinstateAdminRequest` to use `id` instead of `auth0Sub`. - admin.repository.ts: Update all SQL queries from `auth0_sub` to `id`/`user_profile_id`. Update `mapRowToAdminUser` to map `id` and `user_profile_id`. Update `mapRowToAuditLog` to map `actor_admin_id` and `target_admin_id` as UUID strings. Change `getAdminByAuth0Sub` to `getAdminByUserProfileId`. Update `createAdmin` to accept `userProfileId UUID` instead of `auth0Sub`. Update `logAuditAction` to accept UUID `actorAdminId` and `targetAdminId`. - admin.service.ts: Update method signatures to use UUID identifiers. - admin.controller.ts: Remove fake auth0_sub generation (`auth0|email_at_domain`). Admin creation looks up `user_profiles` by email to get UUID. If no profile exists, return error. **Acceptance Criteria**: - Admin CRUD operations work with UUID identifiers - Admin creation requires existing user_profiles entry (no fake auth0_sub) - Admin audit logs record UUID actor/target IDs (referencing admin_users.id) - Revoke/reinstate operations use admin UUID id - All admin API endpoints functional **Tests**: - **Test files**: `backend/src/features/admin/tests/unit/admin.service.test.ts`, `backend/src/features/admin/tests/integration/admin.integration.test.ts` - **Test type**: integration | doc-derived - **Scenarios**: - Normal: Create admin for user with existing profile - Normal: Revoke admin by UUID id - Error: Create admin for email without user_profiles entry returns error - Normal: Audit logs record UUID actor/target IDs ### Milestone 4: User profile repository refactor **Files**: - `backend/src/features/user-profile/data/user-profile.repository.ts` **Flags**: needs conformance check (15+ methods changing parameter type) **Requirements**: - Keep `getByAuth0Sub(auth0Sub)` for auth plugin usage (JWT resolution) - Add `getById(id: string)` method for UUID-based lookups - Change all other methods that accept `auth0Sub` parameter to accept UUID `id`: `update`, `updateSubscriptionTier`, `deactivateUser`, `reactivateUser`, `adminUpdateProfile`, `updateEmailVerified`, `markOnboardingComplete`, `updateEmail`, `requestDeletion`, `cancelDeletion`, `hardDeleteUser`, `getUserWithAdminStatus`, `getUserVehiclesForAdmin` - Update SQL in changed methods from `WHERE auth0_sub = $1` to `WHERE id = $1` - Update `hardDeleteUser` to query by UUID across all tables (7 DELETE statements use `WHERE user_id = $1` with UUID value) - Update `listAllUsers` cross-table joins: `v.user_id = up.id` (was `up.auth0_sub`), `au.user_profile_id = up.id` (was `au.auth0_sub`) - Update `getUserWithAdminStatus` join: `au.user_profile_id = up.id` (was `au.auth0_sub`) **Acceptance Criteria**: - `getByAuth0Sub` still works for auth plugin - All other methods accept UUID id parameter - `hardDeleteUser` deletes all user data correctly - `listAllUsers` joins work with UUID columns - `getUserWithAdminStatus` joins with new admin_users schema **Tests**: - **Test files**: Existing integration tests cover profile operations - **Test type**: integration | doc-derived - **Scenarios**: - Normal: Profile operations by UUID - Normal: Hard delete removes all user data - Normal: List users with admin status join ### Milestone 5: Feature repository validation **Files**: - `backend/src/features/vehicles/data/vehicles.repository.ts` - `backend/src/features/fuel-logs/data/fuel-logs.repository.ts` - `backend/src/features/maintenance/data/maintenance.repository.ts` - `backend/src/features/documents/data/documents.repository.ts` - `backend/src/features/stations/data/stations.repository.ts` - `backend/src/features/subscriptions/data/subscriptions.repository.ts` - `backend/src/features/notifications/data/notifications.repository.ts` - `backend/src/features/ownership-costs/data/ownership-costs.repository.ts` - `backend/src/features/terms-agreement/data/terms-agreement.repository.ts` - `backend/src/core/user-preferences/data/user-preferences.repository.ts` - `backend/src/features/email-ingestion/data/email-ingestion.repository.ts` **Requirements**: - After DB migration, column name stays `user_id` (renamed from `user_profile_id`), so `WHERE user_id = $1` queries and `mapRow()` functions require NO changes - The pg driver handles UUID-as-string transparently - Verify each repository compiles and functions with UUID values passed as string parameters - No code changes expected -- this milestone validates the zero-change hypothesis **Acceptance Criteria**: - All feature repositories compile without error - All feature queries execute correctly with UUID parameter values - `mapRow()` functions return correct userId values (UUID strings) **Tests**: - **Test files**: Existing feature integration tests - **Test type**: integration | doc-derived - **Scenarios**: - Normal: CRUD operations for vehicles, fuel logs, maintenance, documents, stations, subscriptions with UUID userId ### Milestone 6: Supporting code updates **Files**: - `backend/src/features/audit-log/data/audit-log.repository.ts` - `backend/src/features/backup/data/backup.repository.ts` - `backend/src/features/stations/data/community-stations.repository.ts` - `backend/src/features/user-import/domain/user-import.service.ts` - `backend/src/features/ocr/api/ocr.controller.ts` **Requirements**: - audit-log.repository.ts: Update join `LEFT JOIN user_profiles up ON al.user_id = up.auth0_sub` to `al.user_id = up.id` - backup.repository.ts: `created_by` column type changed by migration; mapRow stays the same - community-stations.repository.ts: `submitted_by` column type changed by migration; verify queries work with UUID - user-import.service.ts: Inline SQL queries use `user_id = $1` -- verify UUID compatibility. Update any direct references to auth0_sub format. - ocr.controller.ts: OCR endpoints use `request.userContext?.userId` for user identification (7 endpoints) -- consistent with all other feature controllers **Acceptance Criteria**: - Audit log queries join correctly on UUID - Backup created_by stores UUID values - Community station submitted_by works with UUID - User import/export handles UUID user identity - OCR controller uses userContext.userId consistently **Tests**: - **Test files**: Existing feature tests - **Test type**: integration | doc-derived - **Scenarios**: - Normal: Audit log entries display correct user email (join works) - Normal: Community station submission records UUID submitter ### Milestone 7: Test fixtures and documentation **Files**: - `backend/src/features/auth/tests/integration/auth.integration.test.ts` - `backend/src/features/auth/tests/unit/auth.service.test.ts` - `backend/src/features/admin/tests/integration/admin.integration.test.ts` - `backend/src/features/admin/tests/unit/admin.service.test.ts` - `backend/src/features/maintenance/tests/fixtures/maintenance.fixtures.json` - `docs/PLATFORM-SERVICES.md` (if user identity documented) **Requirements**: - Replace all hardcoded `auth0|...` values with UUID format in test fixtures and mocks - Update test user context setup to use UUID for userId - Update admin test fixtures to use new admin schema (id, userProfileId instead of auth0Sub) - Run full test suite: `npm test`, `npm run lint`, `npm run type-check` - Update documentation if user identity is referenced **Acceptance Criteria**: - All tests pass with zero failures - All linters pass with zero issues - TypeScript type-check passes - No remaining references to `auth0|` format in test code (except for auth plugin tests that test JWT parsing) - Documentation reflects UUID identity model **Tests**: - Full test suite execution is the acceptance criterion for this milestone. ## Milestone Dependencies ``` M1 (DB migration) ---> M2 (auth + guard) ---> M3 (admin refactor) \ \--> M4 (profile repo) \--> M5 (feature repos - validation only) \--> M6 (supporting code) | M7 (tests + docs) depends on all ``` M1 must complete first. M2 depends on M1. M3 and M4 depend on M2. M5 and M6 depend on M1. M7 depends on all. *Verdict*: AWAITING_REVIEW | *Next*: Plan review cycle (QR code -> QR docs)
Author
Owner

Milestone: M1 - Database migration SQL

Phase: Execution | Agent: Platform | Status: PASS

Completed

  • Created backend/src/core/identity-migration/migrations/001_migrate_user_id_to_uuid.sql
  • 5-phase migration: add UUID columns, backfill from user_profiles join, admin restructure, add constraints, drop old columns/rename
  • Covers all 17 feature tables with user_id, 2 special user columns (submitted_by, reported_by), admin_users PK transformation, and 7 admin-referencing columns
  • Added core/identity-migration to end of MIGRATION_ORDER in run-all.ts to ensure all feature tables exist before migration runs
  • Transaction-wrapped for atomicity

Deviation from Plan

  • File placed at core/identity-migration/migrations/001_migrate_user_id_to_uuid.sql instead of user-profile/migrations/005_* to avoid ordering issue: user-profile runs 3rd in MIGRATION_ORDER but migration needs all tables (subscriptions runs 16th)

Verdict: PASS | Next: M2 (auth plugin + admin guard)

## Milestone: M1 - Database migration SQL **Phase**: Execution | **Agent**: Platform | **Status**: PASS ### Completed - Created `backend/src/core/identity-migration/migrations/001_migrate_user_id_to_uuid.sql` - 5-phase migration: add UUID columns, backfill from user_profiles join, admin restructure, add constraints, drop old columns/rename - Covers all 17 feature tables with user_id, 2 special user columns (submitted_by, reported_by), admin_users PK transformation, and 7 admin-referencing columns - Added `core/identity-migration` to end of MIGRATION_ORDER in run-all.ts to ensure all feature tables exist before migration runs - Transaction-wrapped for atomicity ### Deviation from Plan - File placed at `core/identity-migration/migrations/001_migrate_user_id_to_uuid.sql` instead of `user-profile/migrations/005_*` to avoid ordering issue: user-profile runs 3rd in MIGRATION_ORDER but migration needs all tables (subscriptions runs 16th) *Verdict*: PASS | *Next*: M2 (auth plugin + admin guard)
Author
Owner

Session 2 Progress Update

Branch: issue-206-migrate-user-identity-uuid

Committed

Milestone Issue Commit Status
M1: DB migration SQL #211 6011888 Done
M2: Auth plugin + admin guard #212 1321440 Done
M3: Admin system refactor #213 fd9d1ad Done
M4: User profile repository #214 b418a50 Done
M5: Feature repo validation #215 N/A (no-op) Done

Uncommitted (5 files staged for M6)

Files modified but NOT committed:

  • audit-log/data/audit-log.repository.ts - JOIN changed from up.auth0_sub to up.id
  • ocr/api/ocr.controller.ts - All 7 (request as any).user?.sub changed to request.userContext?.userId
  • user-profile/api/user-profile.controller.ts - Renamed auth0Sub vars to userId, uses getById() instead of getOrCreateProfile()
  • user-profile/data/user-profile.repository.ts - Fixed au.auth0_sub as admin_auth0_sub to au.id as admin_id in admin JOIN queries (2 places)
  • user-profile/domain/user-profile.service.ts - All admin-focused methods now accept userId (UUID), use getById() instead of getByAuth0Sub()

Remaining Work

  • M6 (#216): Commit the 5 uncommitted files above. Verify subscriptions.service.ts adminOverrideTier already accepts UUID (it does - confirmed). Check user-import.service.ts for any auth0_sub refs.
  • M7 (#217): Update test fixtures (replace auth0|xxx with UUID format), run npm run type-check, npm run lint, npm test. Update docs if needed.
  • Frontend: Check if any frontend code references admin :auth0Sub URL params (routes changed to :id for admins, :userId for users).
## Session 2 Progress Update ### Branch: `issue-206-migrate-user-identity-uuid` ### Committed | Milestone | Issue | Commit | Status | |-----------|-------|--------|--------| | M1: DB migration SQL | #211 | `6011888` | Done | | M2: Auth plugin + admin guard | #212 | `1321440` | Done | | M3: Admin system refactor | #213 | `fd9d1ad` | Done | | M4: User profile repository | #214 | `b418a50` | Done | | M5: Feature repo validation | #215 | N/A (no-op) | Done | ### Uncommitted (5 files staged for M6) Files modified but NOT committed: - `audit-log/data/audit-log.repository.ts` - JOIN changed from `up.auth0_sub` to `up.id` - `ocr/api/ocr.controller.ts` - All 7 `(request as any).user?.sub` changed to `request.userContext?.userId` - `user-profile/api/user-profile.controller.ts` - Renamed auth0Sub vars to userId, uses `getById()` instead of `getOrCreateProfile()` - `user-profile/data/user-profile.repository.ts` - Fixed `au.auth0_sub as admin_auth0_sub` to `au.id as admin_id` in admin JOIN queries (2 places) - `user-profile/domain/user-profile.service.ts` - All admin-focused methods now accept userId (UUID), use `getById()` instead of `getByAuth0Sub()` ### Remaining Work - **M6 (#216)**: Commit the 5 uncommitted files above. Verify `subscriptions.service.ts` `adminOverrideTier` already accepts UUID (it does - confirmed). Check `user-import.service.ts` for any auth0_sub refs. - **M7 (#217)**: Update test fixtures (replace `auth0|xxx` with UUID format), run `npm run type-check`, `npm run lint`, `npm test`. Update docs if needed. - **Frontend**: Check if any frontend code references admin `:auth0Sub` URL params (routes changed to `:id` for admins, `:userId` for users).
Author
Owner

Session 3 Progress Update

Branch: issue-206-migrate-user-identity-uuid

All Milestones Complete

Milestone Issue Commit Status
M1: DB migration SQL #211 6011888 Done
M2: Auth plugin + admin guard #212 1321440 Done
M3: Admin system refactor #213 fd9d1ad Done
M4: User profile repository #214 b418a50 Done
M5: Feature repo validation #215 N/A (no-op) Done
M6: Supporting code updates #216 3b1112a Done
M7: Test fixtures + frontend #217 754639c Done

M6 Details (committed this session)

  • audit-log.repository.ts: JOIN on user_profiles.id instead of auth0_sub
  • backup.controller.ts: Use userContext.userId instead of auth0Sub (was undefined post-migration)
  • ocr.controller.ts: All 7 endpoints use request.userContext.userId instead of (request as any).user.sub
  • user-profile.controller.ts: Use getById() with UUID, removed getOrCreateProfile() calls
  • user-profile.service.ts: All admin methods accept UUID userId parameter
  • user-profile.repository.ts: Fixed admin JOIN aliases from auth0_sub to id

M7 Details (committed this session)

Backend test fixtures (11 files):

  • All auth0|xxx test user IDs replaced with UUID format
  • Admin tests updated for new id/userProfileId schema
  • Auth service test mocks: added missing deletionRequestedAt/deletionScheduledFor fields
  • Admin integration test: fixed supertest app.server usage

Frontend (9 files):

  • AdminUser type: auth0Sub -> id + userProfileId
  • admin.api.ts: All user management methods use userId (UUID) params, admin revoke/reinstate use id
  • useUsers.ts/useAdmins.ts: All mutation hooks use userId/id instead of auth0Sub
  • AdminUsersPage.tsx + AdminUsersMobileScreen.tsx: user.auth0Sub -> user.id
  • Removed encodeURIComponent() (UUIDs don't need URL encoding)

Validation

  • TypeScript: Backend and frontend both pass tsc --noEmit with zero errors
  • ESLint: 0 errors (664 pre-existing warnings)
  • Unit tests: All 27 UUID-migration-affected tests pass
  • Pre-existing test failures: Unchanged (no regressions)

Ready for PR

All 7 milestones are complete. Branch has 7 commits ready for PR to main.

## Session 3 Progress Update ### Branch: `issue-206-migrate-user-identity-uuid` ### All Milestones Complete | Milestone | Issue | Commit | Status | |-----------|-------|--------|--------| | M1: DB migration SQL | #211 | `6011888` | Done | | M2: Auth plugin + admin guard | #212 | `1321440` | Done | | M3: Admin system refactor | #213 | `fd9d1ad` | Done | | M4: User profile repository | #214 | `b418a50` | Done | | M5: Feature repo validation | #215 | N/A (no-op) | Done | | M6: Supporting code updates | #216 | `3b1112a` | Done | | M7: Test fixtures + frontend | #217 | `754639c` | Done | ### M6 Details (committed this session) - `audit-log.repository.ts`: JOIN on `user_profiles.id` instead of `auth0_sub` - `backup.controller.ts`: Use `userContext.userId` instead of `auth0Sub` (was undefined post-migration) - `ocr.controller.ts`: All 7 endpoints use `request.userContext.userId` instead of `(request as any).user.sub` - `user-profile.controller.ts`: Use `getById()` with UUID, removed `getOrCreateProfile()` calls - `user-profile.service.ts`: All admin methods accept UUID `userId` parameter - `user-profile.repository.ts`: Fixed admin JOIN aliases from `auth0_sub` to `id` ### M7 Details (committed this session) **Backend test fixtures (11 files):** - All `auth0|xxx` test user IDs replaced with UUID format - Admin tests updated for new `id`/`userProfileId` schema - Auth service test mocks: added missing `deletionRequestedAt`/`deletionScheduledFor` fields - Admin integration test: fixed supertest `app.server` usage **Frontend (9 files):** - `AdminUser` type: `auth0Sub` -> `id` + `userProfileId` - `admin.api.ts`: All user management methods use `userId` (UUID) params, admin revoke/reinstate use `id` - `useUsers.ts`/`useAdmins.ts`: All mutation hooks use `userId`/`id` instead of `auth0Sub` - `AdminUsersPage.tsx` + `AdminUsersMobileScreen.tsx`: `user.auth0Sub` -> `user.id` - Removed `encodeURIComponent()` (UUIDs don't need URL encoding) ### Validation - TypeScript: Backend and frontend both pass `tsc --noEmit` with zero errors - ESLint: 0 errors (664 pre-existing warnings) - Unit tests: All 27 UUID-migration-affected tests pass - Pre-existing test failures: Unchanged (no regressions) ### Ready for PR All 7 milestones are complete. Branch has 7 commits ready for PR to main.
egullickson added
status
review
and removed
status
in-progress
labels 2026-02-16 16:32:15 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: egullickson/motovaultpro#206