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.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', {
userId: auth0Sub.substring(0, 8) + '...',
userId: userId.substring(0, 8) + '...',
});
}
@@ -184,16 +184,16 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
try {
const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(auth0Sub);
if (isVerifiedInAuth0 && !profile.emailVerified) {
await profileRepo.updateEmailVerified(auth0Sub, true);
await profileRepo.updateEmailVerified(userId, true);
emailVerified = true;
logger.info('Synced email verification status from Auth0', {
userId: auth0Sub.substring(0, 8) + '...',
userId: userId.substring(0, 8) + '...',
});
}
} catch (syncError) {
// Don't fail auth if sync fails, just log
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',
});
}

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