/** * @ai-summary User profile data access layer * @ai-context Provides parameterized SQL queries for user profile operations */ import { Pool } from 'pg'; import { UserProfile, UserWithAdminStatus, ListUsersQuery, ListUsersResponse, SubscriptionTier, } from '../domain/user-profile.types'; import { logger } from '../../../core/logging/logger'; // Base columns for user profile queries const USER_PROFILE_COLUMNS = ` id, auth0_sub, email, display_name, notification_email, subscription_tier, email_verified, onboarding_completed_at, deactivated_at, deactivated_by, deletion_requested_at, deletion_scheduled_for, created_at, updated_at `; export class UserProfileRepository { constructor(private pool: Pool) {} async getByAuth0Sub(auth0Sub: string): Promise { const query = ` SELECT ${USER_PROFILE_COLUMNS} FROM user_profiles WHERE auth0_sub = $1 LIMIT 1 `; try { const result = await this.pool.query(query, [auth0Sub]); if (result.rows.length === 0) { return null; } return this.mapRowToUserProfile(result.rows[0]); } catch (error) { logger.error('Error fetching user profile by auth0_sub', { error, auth0Sub }); throw error; } } async getById(id: string): Promise { 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 { const query = ` SELECT ${USER_PROFILE_COLUMNS} FROM user_profiles WHERE email = $1 LIMIT 1 `; try { const result = await this.pool.query(query, [email]); if (result.rows.length === 0) { return null; } return this.mapRowToUserProfile(result.rows[0]); } catch (error) { logger.error('Error fetching user profile by email', { error }); throw error; } } async create( auth0Sub: string, email: string, displayName?: string ): Promise { const query = ` INSERT INTO user_profiles (auth0_sub, email, display_name, subscription_tier) VALUES ($1, $2, $3, 'free') RETURNING ${USER_PROFILE_COLUMNS} `; try { const result = await this.pool.query(query, [ auth0Sub, email, displayName || null, ]); if (result.rows.length === 0) { throw new Error('Failed to create user profile'); } return this.mapRowToUserProfile(result.rows[0]); } catch (error) { logger.error('Error creating user profile', { error, auth0Sub, email }); throw error; } } async update( userId: string, updates: { displayName?: string; notificationEmail?: string } ): Promise { const setClauses: string[] = []; const values: any[] = []; let paramIndex = 1; if (updates.displayName !== undefined) { setClauses.push(`display_name = $${paramIndex++}`); values.push(updates.displayName); } if (updates.notificationEmail !== undefined) { setClauses.push(`notification_email = $${paramIndex++}`); values.push(updates.notificationEmail); } if (setClauses.length === 0) { throw new Error('No fields to update'); } values.push(userId); const query = ` UPDATE user_profiles SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING ${USER_PROFILE_COLUMNS} `; try { const result = await this.pool.query(query, values); 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 profile', { error, userId, updates }); throw error; } } async getOrCreate( auth0Sub: string, defaults: { email: string; displayName?: string } ): Promise { // Try to find existing profile const existing = await this.getByAuth0Sub(auth0Sub); if (existing) { return existing; } // Create new profile if not found return await this.create(auth0Sub, defaults.email, defaults.displayName); } private mapRowToUserProfile(row: any): UserProfile { return { id: row.id, auth0Sub: row.auth0_sub, email: row.email, displayName: row.display_name, notificationEmail: row.notification_email, subscriptionTier: row.subscription_tier || 'free', emailVerified: row.email_verified ?? false, onboardingCompletedAt: row.onboarding_completed_at ? new Date(row.onboarding_completed_at) : null, deactivatedAt: row.deactivated_at ? new Date(row.deactivated_at) : null, deactivatedBy: row.deactivated_by || null, deletionRequestedAt: row.deletion_requested_at ? new Date(row.deletion_requested_at) : null, deletionScheduledFor: row.deletion_scheduled_for ? new Date(row.deletion_scheduled_for) : null, createdAt: new Date(row.created_at), updatedAt: new Date(row.updated_at), }; } private mapRowToUserWithAdminStatus(row: any): UserWithAdminStatus { return { ...this.mapRowToUserProfile(row), isAdmin: !!row.admin_auth0_sub, adminRole: row.admin_role || null, vehicleCount: parseInt(row.vehicle_count, 10) || 0, }; } /** * List all users with pagination, search, and filters * Includes admin status by joining with admin_users table */ async listAllUsers(query: ListUsersQuery): Promise { const page = query.page || 1; const pageSize = query.pageSize || 20; const offset = (page - 1) * pageSize; const sortBy = query.sortBy || 'createdAt'; const sortOrder = query.sortOrder || 'desc'; // Map sortBy to column names const sortColumnMap: Record = { email: 'up.email', createdAt: 'up.created_at', displayName: 'up.display_name', subscriptionTier: 'up.subscription_tier', vehicleCount: 'vehicle_count', }; const sortColumn = sortColumnMap[sortBy] || 'up.created_at'; const whereClauses: string[] = []; const values: any[] = []; let paramIndex = 1; // Search filter (email or display_name) if (query.search) { whereClauses.push(`(up.email ILIKE $${paramIndex} OR up.display_name ILIKE $${paramIndex})`); values.push(`%${query.search}%`); paramIndex++; } // Tier filter if (query.tier) { whereClauses.push(`up.subscription_tier = $${paramIndex}`); values.push(query.tier); paramIndex++; } // Status filter if (query.status === 'active') { whereClauses.push('up.deactivated_at IS NULL'); } else if (query.status === 'deactivated') { whereClauses.push('up.deactivated_at IS NOT NULL'); } // 'all' means no filter const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''; // Count query const countQuery = ` SELECT COUNT(*) as total FROM user_profiles up ${whereClause} `; // Data query with admin status join and vehicle count const dataQuery = ` SELECT up.id, up.auth0_sub, up.email, up.display_name, up.notification_email, up.subscription_tier, up.email_verified, up.onboarding_completed_at, up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at, au.auth0_sub as admin_auth0_sub, au.role as admin_role, (SELECT COUNT(*) FROM vehicles v 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 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} `; try { const [countResult, dataResult] = await Promise.all([ this.pool.query(countQuery, values), this.pool.query(dataQuery, [...values, pageSize, offset]), ]); const total = parseInt(countResult.rows[0]?.total || '0', 10); const users = dataResult.rows.map((row) => this.mapRowToUserWithAdminStatus(row)); return { users, total, page, pageSize }; } catch (error) { logger.error('Error listing users', { error, query }); throw error; } } /** * Get single user with admin status */ async getUserWithAdminStatus(userId: string): Promise { const query = ` SELECT up.id, up.auth0_sub, up.email, up.display_name, up.notification_email, up.subscription_tier, up.email_verified, up.onboarding_completed_at, up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at, au.auth0_sub as admin_auth0_sub, au.role as admin_role, (SELECT COUNT(*) FROM vehicles v 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 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, [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, userId }); throw error; } } /** * Update user subscription tier */ async updateSubscriptionTier( userId: string, tier: SubscriptionTier ): Promise { const query = ` UPDATE user_profiles SET subscription_tier = $1 WHERE id = $2 RETURNING ${USER_PROFILE_COLUMNS} `; try { 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, userId, tier }); throw error; } } /** * Deactivate user (soft delete) */ async deactivateUser(userId: string, deactivatedBy: string): Promise { const query = ` UPDATE user_profiles SET deactivated_at = NOW(), deactivated_by = $1 WHERE id = $2 AND deactivated_at IS NULL RETURNING ${USER_PROFILE_COLUMNS} `; try { 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, userId, deactivatedBy }); throw error; } } /** * Reactivate user */ async reactivateUser(userId: string): Promise { const query = ` UPDATE user_profiles SET deactivated_at = NULL, deactivated_by = NULL WHERE id = $1 AND deactivated_at IS NOT NULL RETURNING ${USER_PROFILE_COLUMNS} `; try { 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, userId }); throw error; } } /** * Admin update of user profile (can update email and displayName) */ async adminUpdateProfile( userId: string, updates: { email?: string; displayName?: string } ): Promise { const setClauses: string[] = []; const values: any[] = []; let paramIndex = 1; if (updates.email !== undefined) { setClauses.push(`email = $${paramIndex++}`); values.push(updates.email); } if (updates.displayName !== undefined) { setClauses.push(`display_name = $${paramIndex++}`); values.push(updates.displayName); } if (setClauses.length === 0) { throw new Error('No fields to update'); } values.push(userId); const query = ` UPDATE user_profiles SET ${setClauses.join(', ')}, updated_at = NOW() WHERE id = $${paramIndex} RETURNING ${USER_PROFILE_COLUMNS} `; try { const result = await this.pool.query(query, values); if (result.rows.length === 0) { throw new Error('User profile not found'); } return this.mapRowToUserProfile(result.rows[0]); } catch (error) { logger.error('Error admin updating user profile', { error, userId, updates }); throw error; } } /** * Update email verification status */ async updateEmailVerified(userId: string, emailVerified: boolean): Promise { const query = ` UPDATE user_profiles SET email_verified = $1, updated_at = NOW() WHERE id = $2 RETURNING ${USER_PROFILE_COLUMNS} `; try { 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, userId, emailVerified }); throw error; } } /** * Mark onboarding as complete */ async markOnboardingComplete(userId: string): Promise { const query = ` UPDATE user_profiles SET onboarding_completed_at = NOW(), updated_at = NOW() WHERE id = $1 AND onboarding_completed_at IS NULL RETURNING ${USER_PROFILE_COLUMNS} `; try { 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.getById(userId); if (existing && existing.onboardingCompletedAt) { return existing; // Already completed, return as-is } throw new Error('User profile not found'); } return this.mapRowToUserProfile(result.rows[0]); } catch (error) { logger.error('Error marking onboarding complete', { error, userId }); throw error; } } /** * Update user email (used when fetching correct email from Auth0) */ async updateEmail(userId: string, email: string): Promise { const query = ` UPDATE user_profiles SET email = $1, updated_at = NOW() WHERE id = $2 RETURNING ${USER_PROFILE_COLUMNS} `; try { 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, userId }); throw error; } } /** * Request account deletion (sets deletion timestamps and deactivates account) * 30-day grace period before permanent deletion */ async requestDeletion(userId: string): Promise { const query = ` UPDATE user_profiles SET deletion_requested_at = NOW(), deletion_scheduled_for = NOW() + INTERVAL '30 days', deactivated_at = NOW(), updated_at = NOW() WHERE id = $1 AND deletion_requested_at IS NULL RETURNING ${USER_PROFILE_COLUMNS} `; try { 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, userId }); throw error; } } /** * Cancel deletion request (clears deletion timestamps and reactivates account) */ async cancelDeletion(userId: string): Promise { const query = ` UPDATE user_profiles SET deletion_requested_at = NULL, deletion_scheduled_for = NULL, deactivated_at = NULL, deactivated_by = NULL, updated_at = NOW() WHERE id = $1 AND deletion_requested_at IS NOT NULL RETURNING ${USER_PROFILE_COLUMNS} `; try { 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, userId }); throw error; } } /** * Get users whose deletion grace period has expired */ async getUsersPastGracePeriod(): Promise { const query = ` SELECT ${USER_PROFILE_COLUMNS} FROM user_profiles WHERE deletion_scheduled_for IS NOT NULL AND deletion_scheduled_for <= NOW() ORDER BY deletion_scheduled_for ASC `; try { const result = await this.pool.query(query); return result.rows.map(row => this.mapRowToUserProfile(row)); } catch (error) { logger.error('Error fetching users past grace period', { error }); throw error; } } /** * Hard delete user and all associated data * This is a permanent operation - use with caution */ async hardDeleteUser(userId: string): Promise { const client = await this.pool.connect(); try { await client.query('BEGIN'); // 1. Anonymize community station submissions (keep data but remove user reference) await client.query( `UPDATE community_stations SET submitted_by = 'deleted-user' WHERE submitted_by = $1`, [userId] ); // 2. Delete notification logs await client.query( 'DELETE FROM notification_logs WHERE user_id = $1', [userId] ); // 3. Delete user notifications await client.query( 'DELETE FROM user_notifications WHERE user_id = $1', [userId] ); // 4. Delete saved stations await client.query( 'DELETE FROM saved_stations WHERE user_id = $1', [userId] ); // 5. Delete vehicles (cascades to fuel_logs, maintenance_logs, maintenance_schedules, documents) await client.query( 'DELETE FROM vehicles WHERE user_id = $1', [userId] ); // 6. Delete user preferences await client.query( 'DELETE FROM user_preferences WHERE user_id = $1', [userId] ); // 7. Delete user profile (final step) await client.query( 'DELETE FROM user_profiles WHERE id = $1', [userId] ); await client.query('COMMIT'); logger.info('User hard deleted successfully', { userId }); } catch (error) { await client.query('ROLLBACK'); logger.error('Error hard deleting user', { error, userId }); throw error; } finally { client.release(); } } /** * Get total count of active vehicles across all users * Used by admin stats endpoint for dashboard widget */ async getTotalVehicleCount(): Promise { const query = ` SELECT COUNT(*) as total FROM vehicles WHERE is_active = true AND deleted_at IS NULL `; try { const result = await this.pool.query(query); return parseInt(result.rows[0]?.total || '0', 10); } catch (error) { logger.error('Error getting total vehicle count', { error }); throw error; } } /** * Get total count of active users * Used by admin stats endpoint for dashboard widget */ async getTotalUserCount(): Promise { const query = ` SELECT COUNT(*) as total FROM user_profiles WHERE deactivated_at IS NULL `; try { const result = await this.pool.query(query); return parseInt(result.rows[0]?.total || '0', 10); } catch (error) { logger.error('Error getting total user count', { error }); throw error; } } /** * Get vehicles for a user (admin view) * Returns only year, make, model for privacy */ async getUserVehiclesForAdmin(userId: string): Promise> { const query = ` SELECT year, make, model FROM vehicles WHERE user_id = $1 AND is_active = true AND deleted_at IS NULL ORDER BY year DESC, make ASC, model ASC `; try { 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, userId }); throw error; } } }