Notification updates
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user