Notification updates

This commit is contained in:
Eric Gullickson
2025-12-21 19:56:52 -06:00
parent 144f1d5bb0
commit 719c80ecd8
80 changed files with 7552 additions and 678 deletions

View File

@@ -0,0 +1,391 @@
/**
* @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, deactivated_at, deactivated_by,
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 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(
auth0Sub: 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(auth0Sub);
const query = `
UPDATE user_profiles
SET ${setClauses.join(', ')}
WHERE auth0_sub = $${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, auth0Sub, 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',
deactivatedAt: row.deactivated_at ? new Date(row.deactivated_at) : null,
deactivatedBy: row.deactivated_by || 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,
};
}
/**
* 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',
};
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
const dataQuery = `
SELECT
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
up.subscription_tier, up.deactivated_at, up.deactivated_by,
up.created_at, up.updated_at,
au.auth0_sub as admin_auth0_sub,
au.role as admin_role
FROM user_profiles up
LEFT JOIN admin_users au ON up.auth0_sub = au.auth0_sub 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(auth0Sub: string): Promise<UserWithAdminStatus | null> {
const query = `
SELECT
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
up.subscription_tier, up.deactivated_at, up.deactivated_by,
up.created_at, up.updated_at,
au.auth0_sub as admin_auth0_sub,
au.role as admin_role
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
LIMIT 1
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
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 });
throw error;
}
}
/**
* Update user subscription tier
*/
async updateSubscriptionTier(
auth0Sub: string,
tier: SubscriptionTier
): Promise<UserProfile> {
const query = `
UPDATE user_profiles
SET subscription_tier = $1
WHERE auth0_sub = $2
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [tier, auth0Sub]);
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 });
throw error;
}
}
/**
* Deactivate user (soft delete)
*/
async deactivateUser(auth0Sub: 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
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [deactivatedBy, auth0Sub]);
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 });
throw error;
}
}
/**
* Reactivate user
*/
async reactivateUser(auth0Sub: 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
RETURNING ${USER_PROFILE_COLUMNS}
`;
try {
const result = await this.pool.query(query, [auth0Sub]);
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 });
throw error;
}
}
/**
* Admin update of user profile (can update email and displayName)
*/
async adminUpdateProfile(
auth0Sub: 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(auth0Sub);
const query = `
UPDATE user_profiles
SET ${setClauses.join(', ')}, updated_at = NOW()
WHERE auth0_sub = $${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, auth0Sub, updates });
throw error;
}
}
}