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.
-- user_profiles table has a UUID but it's not used as FK:
idUUIDPRIMARYKEYDEFAULTuuid_generate_v4(),-- EXISTS but unused as FK
auth0_subVARCHAR(255)NOTNULLUNIQUE,-- Used as FK EVERYWHERE
-- All other tables reference auth0_sub:
CONSTRAINTfk_subscriptions_user_idFOREIGNKEY(user_id)REFERENCESuser_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:
ALTERTABLEsubscriptionsADDCOLUMNuser_profile_idUUID;ALTERTABLEvehiclesADDCOLUMNuser_profile_idUUID;-- ... repeat for all tables
Phase 2: Populate from join
UPDATEsubscriptionssSETuser_profile_id=up.idFROMuser_profilesupWHEREs.user_id=up.auth0_sub;-- ... repeat for all tables
Phase 3: Add constraints
ALTERTABLEsubscriptionsALTERCOLUMNuser_profile_idSETNOTNULL;ALTERTABLEsubscriptionsADDCONSTRAINTfk_subscriptions_user_profile_idFOREIGNKEY(user_profile_id)REFERENCESuser_profiles(id)ONDELETECASCADE;-- ... 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
ALTERTABLEsubscriptionsDROPCONSTRAINTfk_subscriptions_user_id;ALTERTABLEsubscriptionsDROPCOLUMNuser_id;ALTERTABLEsubscriptionsRENAMECOLUMNuser_profile_idTOuser_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:
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)
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)
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.
--- 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/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
`;
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
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
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)
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)
## 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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Migrate all database foreign keys from
auth0_sub VARCHAR(255)touser_profiles.id UUIDas the primary user identifier. Currently, MotoVaultPro has no universal internal user ID - all tables reference users via Auth0's external ID format (auth0|xxx). Theuser_profiles.idUUID column already exists but is unused as a relational key.Related Issues
Background
Current State
Every feature table uses
user_id VARCHAR(255)storing the Auth0 ID. This means: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)maintenancerecords (user_id)audit_logs(user_id)admin_users(auth0_sub)user_preferences(user_id)terms_agreements(user_id)backup_*tables (user_id)user_export/importtables (user_id)Auth Flow After Migration
auth0_subremains inuser_profilesas a login/lookup column but is no longer used as a foreign key.Proposed Migration Strategy
Phase 1: Add UUID columns
Phase 2: Populate from join
Phase 3: Add constraints
Phase 4: Update application code
userContext.userId = UUIDPhase 5: Drop old columns
Risk Assessment
Acceptance Criteria
user_profiles.idUUID instead ofauth0_subauth0_subremains inuser_profilesas login lookup columnuserContext.userIdDecision Critic Notes
This issue was separated from the subscription refactor (#205) based on Decision Critic analysis:
Out of Scope
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)touser_profiles.id UUIDas the primary user identifier. Theuser_profiles.idUUID 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 usingprofile.idasuserContext.userIdinstead of the raw JWTsubclaim. After migration, all feature tables retain theuser_idcolumn 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
idUUID 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 columnRejected Alternatives
Constraints and Assumptions
uuid_generate_v4()already available (used by user_profiles)backend/src/_system/migrations/run-all.tsexecutes per-featureauth0_submust remain inuser_profilesfor JWT resolutionnpm test,npm run lint,npm run type-checkmust all pass<default-conventions domain="testing">integration tests preferredKnown Risks
admin.controller.ts:206generates fake auth0_subuser-profile.repository.ts:248v.user_id = up.auth0_subrequest.user.subdirectly, notuserContext.userIdauth.plugin.ts:140auth0ManagementClient.getUser(userId)-- userId here is still from JWT sub before the reassignmentInvisible Knowledge
Architecture
Invariants
userContext.userIdis always a UUID after auth plugin hydrationauth0_subis only used for JWT-to-profile resolution (never as FK)admin_usersmust have a correspondinguser_profilesentryrequest.user.sub(raw JWT claim) is distinct fromuserContext.userId(UUID)admin_userskeepsuser_profile_idcolumn name (not renamed touser_id)admin_audit_logs.actor_admin_idandtarget_admin_idreferenceadmin_users.idUUIDTradeoffs
Milestones
Milestone 1: Database migration SQL
Files:
backend/src/features/user-profile/migrations/005_migrate_user_id_to_uuid.sql(new)Requirements:
user_profile_id UUIDcolumn to all 18 affected feature tablesuser_profilesjoin (SET user_profile_id = up.id FROM user_profiles up WHERE table.user_id = up.auth0_sub)admin_users: addid UUID PK DEFAULT uuid_generate_v4(), adduser_profile_id UUID, backfill from user_profiles join, create user_profiles entries for admins without one. Foradmin_audit_logs: changeactor_admin_idandtarget_admin_idfrom 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)user_profiles(id)with ON DELETE CASCADE, add FK fromadmin_users.user_profile_idtouser_profiles(id), add indexes replacing old VARCHAR indexesuser_idcolumns and FK constraints from feature tables, renameuser_profile_idtouser_idin feature tables. Foradmin_users: drop oldauth0_subPK, keepuser_profile_idcolumn name (not renamed). Drop old auth0_sub columns fromadmin_audit_logsactor/target.community_stations.submitted_by,backup_history.created_by,station_removal_reports.reported_by-- add UUID equivalents, backfill, renameAcceptance Criteria:
user_id UUIDreferencinguser_profiles.idadmin_usershasid UUID PK+user_profile_id UUID FK(not renamed)admin_audit_logs.actor_admin_idandtarget_admin_idare UUID type referencingadmin_users.iduser_profiles.auth0_subremains as VARCHAR UNIQUE columnTests:
Milestone 2: Auth plugin and admin guard
Files:
backend/src/core/plugins/auth.plugin.tsbackend/src/core/plugins/admin-guard.plugin.tsRequirements:
getOrCreate(line 158), useprofile.id(UUID) asuserContext.userIdinstead of raw JWTsubrequest.user.subfor Auth0 Management API calls (line 140) -- this is the raw auth0_sub needed by Auth0WHERE auth0_sub = $1toWHERE user_profile_id = $1idinstead ofauth0_subAcceptance Criteria:
userContext.userIdcontains UUID format after authenticationTests:
backend/src/features/auth/tests/userContext.userIdas UUIDCode Changes:
Rename userId to auth0Sub for JWT sub, declare userId for UUID assignment:
Auth0 Management API uses auth0Sub:
Profile getOrCreate uses auth0Sub, then assigns UUID to userId:
Profile sync methods use auth0Sub:
Admin guard queries by user_profile_id:
Milestone 3: Admin system refactor
Files:
backend/src/features/admin/domain/admin.types.tsbackend/src/features/admin/data/admin.repository.tsbackend/src/features/admin/domain/admin.service.tsbackend/src/features/admin/api/admin.controller.tsRequirements:
auth0Sub: stringwithid: string+userProfileId: stringinAdminUser. UpdateRevokeAdminRequest,ReinstateAdminRequest,BulkRevokeAdminRequest,BulkReinstateAdminRequestto useidinstead ofauth0Sub.auth0_subtoid/user_profile_id. UpdatemapRowToAdminUserto mapidanduser_profile_id. UpdatemapRowToAuditLogto mapactor_admin_idandtarget_admin_idas UUID strings. ChangegetAdminByAuth0SubtogetAdminByUserProfileId. UpdatecreateAdminto acceptuserProfileId UUIDinstead ofauth0Sub. UpdatelogAuditActionto accept UUIDactorAdminIdandtargetAdminId.auth0|email_at_domain). Admin creation looks upuser_profilesby email to get UUID. If no profile exists, return error.Acceptance Criteria:
Tests:
backend/src/features/admin/tests/unit/admin.service.test.ts,backend/src/features/admin/tests/integration/admin.integration.test.tsMilestone 4: User profile repository refactor
Files:
backend/src/features/user-profile/data/user-profile.repository.tsFlags: needs conformance check (15+ methods changing parameter type)
Requirements:
getByAuth0Sub(auth0Sub)for auth plugin usage (JWT resolution)getById(id: string)method for UUID-based lookupsauth0Subparameter to accept UUIDid:update,updateSubscriptionTier,deactivateUser,reactivateUser,adminUpdateProfile,updateEmailVerified,markOnboardingComplete,updateEmail,requestDeletion,cancelDeletion,hardDeleteUser,getUserWithAdminStatus,getUserVehiclesForAdminWHERE auth0_sub = $1toWHERE id = $1hardDeleteUserto query by UUID across all tables (7 DELETE statements useWHERE user_id = $1with UUID value)listAllUserscross-table joins:v.user_id = up.id(wasup.auth0_sub),au.user_profile_id = up.id(wasau.auth0_sub)getUserWithAdminStatusjoin:au.user_profile_id = up.id(wasau.auth0_sub)Acceptance Criteria:
getByAuth0Substill works for auth pluginhardDeleteUserdeletes all user data correctlylistAllUsersjoins work with UUID columnsgetUserWithAdminStatusjoins with new admin_users schemaTests:
Milestone 5: Feature repository validation
Files:
backend/src/features/vehicles/data/vehicles.repository.tsbackend/src/features/fuel-logs/data/fuel-logs.repository.tsbackend/src/features/maintenance/data/maintenance.repository.tsbackend/src/features/documents/data/documents.repository.tsbackend/src/features/stations/data/stations.repository.tsbackend/src/features/subscriptions/data/subscriptions.repository.tsbackend/src/features/notifications/data/notifications.repository.tsbackend/src/features/ownership-costs/data/ownership-costs.repository.tsbackend/src/features/terms-agreement/data/terms-agreement.repository.tsbackend/src/core/user-preferences/data/user-preferences.repository.tsbackend/src/features/email-ingestion/data/email-ingestion.repository.tsRequirements:
user_id(renamed fromuser_profile_id), soWHERE user_id = $1queries andmapRow()functions require NO changesAcceptance Criteria:
mapRow()functions return correct userId values (UUID strings)Tests:
Milestone 6: Supporting code updates
Files:
backend/src/features/audit-log/data/audit-log.repository.tsbackend/src/features/backup/data/backup.repository.tsbackend/src/features/stations/data/community-stations.repository.tsbackend/src/features/user-import/domain/user-import.service.tsbackend/src/features/ocr/api/ocr.controller.tsRequirements:
LEFT JOIN user_profiles up ON al.user_id = up.auth0_subtoal.user_id = up.idcreated_bycolumn type changed by migration; mapRow stays the samesubmitted_bycolumn type changed by migration; verify queries work with UUIDuser_id = $1-- verify UUID compatibility. Update any direct references to auth0_sub format.request.userContext?.userIdfor user identification (7 endpoints) -- consistent with all other feature controllersAcceptance Criteria:
Tests:
Milestone 7: Test fixtures and documentation
Files:
backend/src/features/auth/tests/integration/auth.integration.test.tsbackend/src/features/auth/tests/unit/auth.service.test.tsbackend/src/features/admin/tests/integration/admin.integration.test.tsbackend/src/features/admin/tests/unit/admin.service.test.tsbackend/src/features/maintenance/tests/fixtures/maintenance.fixtures.jsondocs/PLATFORM-SERVICES.md(if user identity documented)Requirements:
auth0|...values with UUID format in test fixtures and mocksnpm test,npm run lint,npm run type-checkAcceptance Criteria:
auth0|format in test code (except for auth plugin tests that test JWT parsing)Tests:
Milestone Dependencies
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)
Milestone: M1 - Database migration SQL
Phase: Execution | Agent: Platform | Status: PASS
Completed
backend/src/core/identity-migration/migrations/001_migrate_user_id_to_uuid.sqlcore/identity-migrationto end of MIGRATION_ORDER in run-all.ts to ensure all feature tables exist before migration runsDeviation from Plan
core/identity-migration/migrations/001_migrate_user_id_to_uuid.sqlinstead ofuser-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)
Session 2 Progress Update
Branch:
issue-206-migrate-user-identity-uuidCommitted
60118881321440fd9d1adb418a50Uncommitted (5 files staged for M6)
Files modified but NOT committed:
audit-log/data/audit-log.repository.ts- JOIN changed fromup.auth0_subtoup.idocr/api/ocr.controller.ts- All 7(request as any).user?.subchanged torequest.userContext?.userIduser-profile/api/user-profile.controller.ts- Renamed auth0Sub vars to userId, usesgetById()instead ofgetOrCreateProfile()user-profile/data/user-profile.repository.ts- Fixedau.auth0_sub as admin_auth0_subtoau.id as admin_idin admin JOIN queries (2 places)user-profile/domain/user-profile.service.ts- All admin-focused methods now accept userId (UUID), usegetById()instead ofgetByAuth0Sub()Remaining Work
subscriptions.service.tsadminOverrideTieralready accepts UUID (it does - confirmed). Checkuser-import.service.tsfor any auth0_sub refs.auth0|xxxwith UUID format), runnpm run type-check,npm run lint,npm test. Update docs if needed.:auth0SubURL params (routes changed to:idfor admins,:userIdfor users).Session 3 Progress Update
Branch:
issue-206-migrate-user-identity-uuidAll Milestones Complete
60118881321440fd9d1adb418a503b1112a754639cM6 Details (committed this session)
audit-log.repository.ts: JOIN onuser_profiles.idinstead ofauth0_subbackup.controller.ts: UseuserContext.userIdinstead ofauth0Sub(was undefined post-migration)ocr.controller.ts: All 7 endpoints userequest.userContext.userIdinstead of(request as any).user.subuser-profile.controller.ts: UsegetById()with UUID, removedgetOrCreateProfile()callsuser-profile.service.ts: All admin methods accept UUIDuserIdparameteruser-profile.repository.ts: Fixed admin JOIN aliases fromauth0_subtoidM7 Details (committed this session)
Backend test fixtures (11 files):
auth0|xxxtest user IDs replaced with UUID formatid/userProfileIdschemadeletionRequestedAt/deletionScheduledForfieldsapp.serverusageFrontend (9 files):
AdminUsertype:auth0Sub->id+userProfileIdadmin.api.ts: All user management methods useuserId(UUID) params, admin revoke/reinstate useiduseUsers.ts/useAdmins.ts: All mutation hooks useuserId/idinstead ofauth0SubAdminUsersPage.tsx+AdminUsersMobileScreen.tsx:user.auth0Sub->user.idencodeURIComponent()(UUIDs don't need URL encoding)Validation
tsc --noEmitwith zero errorsReady for PR
All 7 milestones are complete. Branch has 7 commits ready for PR to main.