chore: refactor user profile repository for UUID (refs #214)

Updated user-profile.repository.ts to use UUID instead of auth0_sub:
- Added getById(id) method for UUID-based lookups
- Changed all methods (except getByAuth0Sub, getOrCreate) to accept userId (UUID) instead of auth0Sub
- Updated SQL WHERE clauses from auth0_sub to id for UUID-based queries
- Fixed cross-table joins in listAllUsers and getUserWithAdminStatus to use user_profile_id
- Updated hardDeleteUser to use UUID for all DELETE statements
- Updated auth.plugin.ts to call updateEmail and updateEmailVerified with userId (UUID)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-16 09:39:56 -06:00
parent 1321440cd0
commit b418a503b2
2 changed files with 88 additions and 68 deletions

View File

@@ -166,9 +166,9 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
// If profile has placeholder email but we now have real email, update it // If profile has placeholder email but we now have real email, update it
if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) { if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) {
await profileRepo.updateEmail(auth0Sub, email); await profileRepo.updateEmail(userId, email);
logger.info('Updated profile with correct email from Auth0', { logger.info('Updated profile with correct email from Auth0', {
userId: auth0Sub.substring(0, 8) + '...', userId: userId.substring(0, 8) + '...',
}); });
} }
@@ -184,16 +184,16 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
try { try {
const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(auth0Sub); const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(auth0Sub);
if (isVerifiedInAuth0 && !profile.emailVerified) { if (isVerifiedInAuth0 && !profile.emailVerified) {
await profileRepo.updateEmailVerified(auth0Sub, true); await profileRepo.updateEmailVerified(userId, true);
emailVerified = true; emailVerified = true;
logger.info('Synced email verification status from Auth0', { logger.info('Synced email verification status from Auth0', {
userId: auth0Sub.substring(0, 8) + '...', userId: userId.substring(0, 8) + '...',
}); });
} }
} catch (syncError) { } catch (syncError) {
// Don't fail auth if sync fails, just log // Don't fail auth if sync fails, just log
logger.warn('Failed to sync email verification status', { logger.warn('Failed to sync email verification status', {
userId: auth0Sub.substring(0, 8) + '...', userId: userId.substring(0, 8) + '...',
error: syncError instanceof Error ? syncError.message : 'Unknown error', error: syncError instanceof Error ? syncError.message : 'Unknown error',
}); });
} }

View File

@@ -44,6 +44,26 @@ export class UserProfileRepository {
} }
} }
async getById(id: string): Promise<UserProfile | null> {
const query = `
SELECT ${USER_PROFILE_COLUMNS}
FROM user_profiles
WHERE id = $1
LIMIT 1
`;
try {
const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
return this.mapRowToUserProfile(result.rows[0]);
} catch (error) {
logger.error('Error fetching user profile by id', { error, id });
throw error;
}
}
async getByEmail(email: string): Promise<UserProfile | null> { async getByEmail(email: string): Promise<UserProfile | null> {
const query = ` const query = `
SELECT ${USER_PROFILE_COLUMNS} SELECT ${USER_PROFILE_COLUMNS}
@@ -94,7 +114,7 @@ export class UserProfileRepository {
} }
async update( async update(
auth0Sub: string, userId: string,
updates: { displayName?: string; notificationEmail?: string } updates: { displayName?: string; notificationEmail?: string }
): Promise<UserProfile> { ): Promise<UserProfile> {
const setClauses: string[] = []; const setClauses: string[] = [];
@@ -115,12 +135,12 @@ export class UserProfileRepository {
throw new Error('No fields to update'); throw new Error('No fields to update');
} }
values.push(auth0Sub); values.push(userId);
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET ${setClauses.join(', ')} SET ${setClauses.join(', ')}
WHERE auth0_sub = $${paramIndex} WHERE id = $${paramIndex}
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
@@ -133,7 +153,7 @@ export class UserProfileRepository {
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error updating user profile', { error, auth0Sub, updates }); logger.error('Error updating user profile', { error, userId, updates });
throw error; throw error;
} }
} }
@@ -245,11 +265,11 @@ export class UserProfileRepository {
au.auth0_sub as admin_auth0_sub, au.auth0_sub as admin_auth0_sub,
au.role as admin_role, au.role as admin_role,
(SELECT COUNT(*) FROM vehicles v (SELECT COUNT(*) FROM vehicles v
WHERE v.user_id = up.auth0_sub WHERE v.user_id = up.id
AND v.is_active = true AND v.is_active = true
AND v.deleted_at IS NULL) as vehicle_count AND v.deleted_at IS NULL) as vehicle_count
FROM user_profiles up FROM user_profiles up
LEFT JOIN admin_users au ON up.auth0_sub = au.auth0_sub AND au.revoked_at IS NULL LEFT JOIN admin_users au ON au.user_profile_id = up.id AND au.revoked_at IS NULL
${whereClause} ${whereClause}
ORDER BY ${sortColumn} ${sortOrder === 'desc' ? 'DESC' : 'ASC'} NULLS LAST ORDER BY ${sortColumn} ${sortOrder === 'desc' ? 'DESC' : 'ASC'} NULLS LAST
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
@@ -274,7 +294,7 @@ export class UserProfileRepository {
/** /**
* Get single user with admin status * Get single user with admin status
*/ */
async getUserWithAdminStatus(auth0Sub: string): Promise<UserWithAdminStatus | null> { async getUserWithAdminStatus(userId: string): Promise<UserWithAdminStatus | null> {
const query = ` const query = `
SELECT SELECT
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email, up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
@@ -283,23 +303,23 @@ export class UserProfileRepository {
au.auth0_sub as admin_auth0_sub, au.auth0_sub as admin_auth0_sub,
au.role as admin_role, au.role as admin_role,
(SELECT COUNT(*) FROM vehicles v (SELECT COUNT(*) FROM vehicles v
WHERE v.user_id = up.auth0_sub WHERE v.user_id = up.id
AND v.is_active = true AND v.is_active = true
AND v.deleted_at IS NULL) as vehicle_count AND v.deleted_at IS NULL) as vehicle_count
FROM user_profiles up FROM user_profiles up
LEFT JOIN admin_users au ON up.auth0_sub = au.auth0_sub AND au.revoked_at IS NULL LEFT JOIN admin_users au ON au.user_profile_id = up.id AND au.revoked_at IS NULL
WHERE up.auth0_sub = $1 WHERE up.id = $1
LIMIT 1 LIMIT 1
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
return null; return null;
} }
return this.mapRowToUserWithAdminStatus(result.rows[0]); return this.mapRowToUserWithAdminStatus(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error fetching user with admin status', { error, auth0Sub }); logger.error('Error fetching user with admin status', { error, userId });
throw error; throw error;
} }
} }
@@ -308,24 +328,24 @@ export class UserProfileRepository {
* Update user subscription tier * Update user subscription tier
*/ */
async updateSubscriptionTier( async updateSubscriptionTier(
auth0Sub: string, userId: string,
tier: SubscriptionTier tier: SubscriptionTier
): Promise<UserProfile> { ): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET subscription_tier = $1 SET subscription_tier = $1
WHERE auth0_sub = $2 WHERE id = $2
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [tier, auth0Sub]); const result = await this.pool.query(query, [tier, userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found'); throw new Error('User profile not found');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error updating subscription tier', { error, auth0Sub, tier }); logger.error('Error updating subscription tier', { error, userId, tier });
throw error; throw error;
} }
} }
@@ -333,22 +353,22 @@ export class UserProfileRepository {
/** /**
* Deactivate user (soft delete) * Deactivate user (soft delete)
*/ */
async deactivateUser(auth0Sub: string, deactivatedBy: string): Promise<UserProfile> { async deactivateUser(userId: string, deactivatedBy: string): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET deactivated_at = NOW(), deactivated_by = $1 SET deactivated_at = NOW(), deactivated_by = $1
WHERE auth0_sub = $2 AND deactivated_at IS NULL WHERE id = $2 AND deactivated_at IS NULL
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [deactivatedBy, auth0Sub]); const result = await this.pool.query(query, [deactivatedBy, userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found or already deactivated'); throw new Error('User profile not found or already deactivated');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error deactivating user', { error, auth0Sub, deactivatedBy }); logger.error('Error deactivating user', { error, userId, deactivatedBy });
throw error; throw error;
} }
} }
@@ -356,22 +376,22 @@ export class UserProfileRepository {
/** /**
* Reactivate user * Reactivate user
*/ */
async reactivateUser(auth0Sub: string): Promise<UserProfile> { async reactivateUser(userId: string): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET deactivated_at = NULL, deactivated_by = NULL SET deactivated_at = NULL, deactivated_by = NULL
WHERE auth0_sub = $1 AND deactivated_at IS NOT NULL WHERE id = $1 AND deactivated_at IS NOT NULL
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found or not deactivated'); throw new Error('User profile not found or not deactivated');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error reactivating user', { error, auth0Sub }); logger.error('Error reactivating user', { error, userId });
throw error; throw error;
} }
} }
@@ -380,7 +400,7 @@ export class UserProfileRepository {
* Admin update of user profile (can update email and displayName) * Admin update of user profile (can update email and displayName)
*/ */
async adminUpdateProfile( async adminUpdateProfile(
auth0Sub: string, userId: string,
updates: { email?: string; displayName?: string } updates: { email?: string; displayName?: string }
): Promise<UserProfile> { ): Promise<UserProfile> {
const setClauses: string[] = []; const setClauses: string[] = [];
@@ -401,12 +421,12 @@ export class UserProfileRepository {
throw new Error('No fields to update'); throw new Error('No fields to update');
} }
values.push(auth0Sub); values.push(userId);
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET ${setClauses.join(', ')}, updated_at = NOW() SET ${setClauses.join(', ')}, updated_at = NOW()
WHERE auth0_sub = $${paramIndex} WHERE id = $${paramIndex}
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
@@ -419,7 +439,7 @@ export class UserProfileRepository {
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error admin updating user profile', { error, auth0Sub, updates }); logger.error('Error admin updating user profile', { error, userId, updates });
throw error; throw error;
} }
} }
@@ -427,22 +447,22 @@ export class UserProfileRepository {
/** /**
* Update email verification status * Update email verification status
*/ */
async updateEmailVerified(auth0Sub: string, emailVerified: boolean): Promise<UserProfile> { async updateEmailVerified(userId: string, emailVerified: boolean): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET email_verified = $1, updated_at = NOW() SET email_verified = $1, updated_at = NOW()
WHERE auth0_sub = $2 WHERE id = $2
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [emailVerified, auth0Sub]); const result = await this.pool.query(query, [emailVerified, userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found'); throw new Error('User profile not found');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error updating email verified status', { error, auth0Sub, emailVerified }); logger.error('Error updating email verified status', { error, userId, emailVerified });
throw error; throw error;
} }
} }
@@ -450,19 +470,19 @@ export class UserProfileRepository {
/** /**
* Mark onboarding as complete * Mark onboarding as complete
*/ */
async markOnboardingComplete(auth0Sub: string): Promise<UserProfile> { async markOnboardingComplete(userId: string): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET onboarding_completed_at = NOW(), updated_at = NOW() SET onboarding_completed_at = NOW(), updated_at = NOW()
WHERE auth0_sub = $1 AND onboarding_completed_at IS NULL WHERE id = $1 AND onboarding_completed_at IS NULL
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
// Check if already completed or profile not found // Check if already completed or profile not found
const existing = await this.getByAuth0Sub(auth0Sub); const existing = await this.getById(userId);
if (existing && existing.onboardingCompletedAt) { if (existing && existing.onboardingCompletedAt) {
return existing; // Already completed, return as-is return existing; // Already completed, return as-is
} }
@@ -470,7 +490,7 @@ export class UserProfileRepository {
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error marking onboarding complete', { error, auth0Sub }); logger.error('Error marking onboarding complete', { error, userId });
throw error; throw error;
} }
} }
@@ -478,22 +498,22 @@ export class UserProfileRepository {
/** /**
* Update user email (used when fetching correct email from Auth0) * Update user email (used when fetching correct email from Auth0)
*/ */
async updateEmail(auth0Sub: string, email: string): Promise<UserProfile> { async updateEmail(userId: string, email: string): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET email = $1, updated_at = NOW() SET email = $1, updated_at = NOW()
WHERE auth0_sub = $2 WHERE id = $2
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [email, auth0Sub]); const result = await this.pool.query(query, [email, userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found'); throw new Error('User profile not found');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error updating user email', { error, auth0Sub }); logger.error('Error updating user email', { error, userId });
throw error; throw error;
} }
} }
@@ -502,7 +522,7 @@ export class UserProfileRepository {
* Request account deletion (sets deletion timestamps and deactivates account) * Request account deletion (sets deletion timestamps and deactivates account)
* 30-day grace period before permanent deletion * 30-day grace period before permanent deletion
*/ */
async requestDeletion(auth0Sub: string): Promise<UserProfile> { async requestDeletion(userId: string): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET SET
@@ -510,18 +530,18 @@ export class UserProfileRepository {
deletion_scheduled_for = NOW() + INTERVAL '30 days', deletion_scheduled_for = NOW() + INTERVAL '30 days',
deactivated_at = NOW(), deactivated_at = NOW(),
updated_at = NOW() updated_at = NOW()
WHERE auth0_sub = $1 AND deletion_requested_at IS NULL WHERE id = $1 AND deletion_requested_at IS NULL
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found or deletion already requested'); throw new Error('User profile not found or deletion already requested');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error requesting account deletion', { error, auth0Sub }); logger.error('Error requesting account deletion', { error, userId });
throw error; throw error;
} }
} }
@@ -529,7 +549,7 @@ export class UserProfileRepository {
/** /**
* Cancel deletion request (clears deletion timestamps and reactivates account) * Cancel deletion request (clears deletion timestamps and reactivates account)
*/ */
async cancelDeletion(auth0Sub: string): Promise<UserProfile> { async cancelDeletion(userId: string): Promise<UserProfile> {
const query = ` const query = `
UPDATE user_profiles UPDATE user_profiles
SET SET
@@ -538,18 +558,18 @@ export class UserProfileRepository {
deactivated_at = NULL, deactivated_at = NULL,
deactivated_by = NULL, deactivated_by = NULL,
updated_at = NOW() updated_at = NOW()
WHERE auth0_sub = $1 AND deletion_requested_at IS NOT NULL WHERE id = $1 AND deletion_requested_at IS NOT NULL
RETURNING ${USER_PROFILE_COLUMNS} RETURNING ${USER_PROFILE_COLUMNS}
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
throw new Error('User profile not found or no deletion request pending'); throw new Error('User profile not found or no deletion request pending');
} }
return this.mapRowToUserProfile(result.rows[0]); return this.mapRowToUserProfile(result.rows[0]);
} catch (error) { } catch (error) {
logger.error('Error canceling account deletion', { error, auth0Sub }); logger.error('Error canceling account deletion', { error, userId });
throw error; throw error;
} }
} }
@@ -579,7 +599,7 @@ export class UserProfileRepository {
* Hard delete user and all associated data * Hard delete user and all associated data
* This is a permanent operation - use with caution * This is a permanent operation - use with caution
*/ */
async hardDeleteUser(auth0Sub: string): Promise<void> { async hardDeleteUser(userId: string): Promise<void> {
const client = await this.pool.connect(); const client = await this.pool.connect();
try { try {
@@ -590,51 +610,51 @@ export class UserProfileRepository {
`UPDATE community_stations `UPDATE community_stations
SET submitted_by = 'deleted-user' SET submitted_by = 'deleted-user'
WHERE submitted_by = $1`, WHERE submitted_by = $1`,
[auth0Sub] [userId]
); );
// 2. Delete notification logs // 2. Delete notification logs
await client.query( await client.query(
'DELETE FROM notification_logs WHERE user_id = $1', 'DELETE FROM notification_logs WHERE user_id = $1',
[auth0Sub] [userId]
); );
// 3. Delete user notifications // 3. Delete user notifications
await client.query( await client.query(
'DELETE FROM user_notifications WHERE user_id = $1', 'DELETE FROM user_notifications WHERE user_id = $1',
[auth0Sub] [userId]
); );
// 4. Delete saved stations // 4. Delete saved stations
await client.query( await client.query(
'DELETE FROM saved_stations WHERE user_id = $1', 'DELETE FROM saved_stations WHERE user_id = $1',
[auth0Sub] [userId]
); );
// 5. Delete vehicles (cascades to fuel_logs, maintenance_logs, maintenance_schedules, documents) // 5. Delete vehicles (cascades to fuel_logs, maintenance_logs, maintenance_schedules, documents)
await client.query( await client.query(
'DELETE FROM vehicles WHERE user_id = $1', 'DELETE FROM vehicles WHERE user_id = $1',
[auth0Sub] [userId]
); );
// 6. Delete user preferences // 6. Delete user preferences
await client.query( await client.query(
'DELETE FROM user_preferences WHERE user_id = $1', 'DELETE FROM user_preferences WHERE user_id = $1',
[auth0Sub] [userId]
); );
// 7. Delete user profile (final step) // 7. Delete user profile (final step)
await client.query( await client.query(
'DELETE FROM user_profiles WHERE auth0_sub = $1', 'DELETE FROM user_profiles WHERE id = $1',
[auth0Sub] [userId]
); );
await client.query('COMMIT'); await client.query('COMMIT');
logger.info('User hard deleted successfully', { auth0Sub }); logger.info('User hard deleted successfully', { userId });
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
logger.error('Error hard deleting user', { error, auth0Sub }); logger.error('Error hard deleting user', { error, userId });
throw error; throw error;
} finally { } finally {
client.release(); client.release();
@@ -686,7 +706,7 @@ export class UserProfileRepository {
* Get vehicles for a user (admin view) * Get vehicles for a user (admin view)
* Returns only year, make, model for privacy * Returns only year, make, model for privacy
*/ */
async getUserVehiclesForAdmin(auth0Sub: string): Promise<Array<{ year: number; make: string; model: string }>> { async getUserVehiclesForAdmin(userId: string): Promise<Array<{ year: number; make: string; model: string }>> {
const query = ` const query = `
SELECT year, make, model SELECT year, make, model
FROM vehicles FROM vehicles
@@ -697,14 +717,14 @@ export class UserProfileRepository {
`; `;
try { try {
const result = await this.pool.query(query, [auth0Sub]); const result = await this.pool.query(query, [userId]);
return result.rows.map(row => ({ return result.rows.map(row => ({
year: row.year, year: row.year,
make: row.make, make: row.make,
model: row.model, model: row.model,
})); }));
} catch (error) { } catch (error) {
logger.error('Error getting user vehicles for admin', { error, auth0Sub }); logger.error('Error getting user vehicles for admin', { error, userId });
throw error; throw error;
} }
} }