Files
motovaultpro/backend/src/features/user-profile/data/user-profile.repository.ts
Eric Gullickson b418a503b2 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>
2026-02-16 09:39:56 -06:00

732 lines
21 KiB
TypeScript

/**
* @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<UserProfile | null> {
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<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}
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<UserProfile> {
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<UserProfile> {
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<UserProfile> {
// 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<ListUsersResponse> {
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<string, string> = {
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<UserWithAdminStatus | null> {
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<UserProfile> {
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<UserProfile> {
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<UserProfile> {
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<UserProfile> {
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<UserProfile> {
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<UserProfile> {
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<UserProfile> {
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<UserProfile> {
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<UserProfile> {
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<UserProfile[]> {
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<void> {
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<number> {
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<number> {
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<Array<{ year: number; make: string; model: string }>> {
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;
}
}
}