diff --git a/src/modules/users/controllers/users.controller.ts b/src/modules/users/controllers/users.controller.ts index 70359a0..980a820 100644 --- a/src/modules/users/controllers/users.controller.ts +++ b/src/modules/users/controllers/users.controller.ts @@ -3,11 +3,35 @@ * Handles user profile and management endpoints */ -import { Request, Response, NextFunction } from 'express'; +import { Response, NextFunction } from 'express'; import { db } from '../../../shared/database'; import { AuthenticatedRequest } from '../../../core/guards/auth.guard'; -import { User, Profile, UserRole, UserStatus } from '../../auth/types/auth.types'; import { logger } from '../../../shared/utils/logger'; +import { usersService } from '../services/users.service'; +import { + updateProfileSchema, + updateAvatarSchema, + changePasswordSchema, + deleteAccountSchema, +} from '../dto/update-profile.dto'; + +// Type aliases for query parameters +type QueryParamValue = string | number | boolean | null | undefined | Date | object; + +// Interface for user list row +interface UserListRow { + id: string; + email: string; + email_verified: boolean; + role: string; + status: string; + last_login_at: Date | null; + created_at: Date; + first_name: string | null; + last_name: string | null; + display_name: string | null; + avatar_url: string | null; +} /** * Get current user profile @@ -29,70 +53,28 @@ export const getCurrentUser = async ( return; } - const userQuery = ` - SELECT - u.id, u.email, u.email_verified, u.phone, u.phone_verified, - u.primary_auth_provider, u.totp_enabled, u.role, u.status, - u.last_login_at, u.created_at, u.updated_at, - p.first_name, p.last_name, p.display_name, p.avatar_url, - p.date_of_birth, p.country_code, p.timezone, p.language, - p.preferred_currency - FROM auth.users u - LEFT JOIN auth.profiles p ON u.id = p.user_id - WHERE u.id = $1 - `; + const profile = await usersService.getProfile(userId); - const result = await db.query(userQuery, [userId]); - - if (result.rows.length === 0) { + res.json({ + success: true, + data: profile, + }); + } catch (error) { + logger.error('Error getting current user:', error); + if ((error as Error).message === 'User not found') { res.status(404).json({ success: false, error: { message: 'User not found', code: 'NOT_FOUND' }, }); return; } - - const row = result.rows[0]; - - const user = { - id: row.id, - email: row.email, - emailVerified: row.email_verified, - phone: row.phone, - phoneVerified: row.phone_verified, - primaryAuthProvider: row.primary_auth_provider, - totpEnabled: row.totp_enabled, - role: row.role as UserRole, - status: row.status as UserStatus, - lastLoginAt: row.last_login_at, - createdAt: row.created_at, - updatedAt: row.updated_at, - profile: row.first_name || row.last_name ? { - firstName: row.first_name, - lastName: row.last_name, - displayName: row.display_name, - avatarUrl: row.avatar_url, - dateOfBirth: row.date_of_birth, - countryCode: row.country_code, - timezone: row.timezone || 'UTC', - language: row.language || 'en', - preferredCurrency: row.preferred_currency || 'USD', - } : undefined, - }; - - res.json({ - success: true, - data: user, - }); - } catch (error) { - logger.error('Error getting current user:', error); next(error); } }; /** * Update current user profile - * PATCH /api/v1/users/me + * PUT /api/v1/users/me */ export const updateCurrentUser = async ( req: AuthenticatedRequest, @@ -110,67 +92,25 @@ export const updateCurrentUser = async ( return; } - const { - firstName, - lastName, - displayName, - avatarUrl, - dateOfBirth, - countryCode, - timezone, - language, - preferredCurrency, - } = req.body; + // Validate input + const validation = updateProfileSchema.safeParse(req.body); + if (!validation.success) { + res.status(400).json({ + success: false, + error: { + message: 'Validation failed', + code: 'VALIDATION_ERROR', + details: validation.error.errors, + }, + }); + return; + } - // Update or insert profile - const upsertQuery = ` - INSERT INTO auth.profiles ( - user_id, first_name, last_name, display_name, avatar_url, - date_of_birth, country_code, timezone, language, preferred_currency, - updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()) - ON CONFLICT (user_id) DO UPDATE SET - first_name = COALESCE($2, auth.profiles.first_name), - last_name = COALESCE($3, auth.profiles.last_name), - display_name = COALESCE($4, auth.profiles.display_name), - avatar_url = COALESCE($5, auth.profiles.avatar_url), - date_of_birth = COALESCE($6, auth.profiles.date_of_birth), - country_code = COALESCE($7, auth.profiles.country_code), - timezone = COALESCE($8, auth.profiles.timezone), - language = COALESCE($9, auth.profiles.language), - preferred_currency = COALESCE($10, auth.profiles.preferred_currency), - updated_at = NOW() - RETURNING * - `; - - const result = await db.query(upsertQuery, [ - userId, - firstName, - lastName, - displayName, - avatarUrl, - dateOfBirth, - countryCode, - timezone, - language, - preferredCurrency, - ]); - - const profile = result.rows[0]; + const profile = await usersService.updateProfile(userId, validation.data); res.json({ success: true, - data: { - firstName: profile.first_name, - lastName: profile.last_name, - displayName: profile.display_name, - avatarUrl: profile.avatar_url, - dateOfBirth: profile.date_of_birth, - countryCode: profile.country_code, - timezone: profile.timezone, - language: profile.language, - preferredCurrency: profile.preferred_currency, - }, + data: profile, message: 'Profile updated successfully', }); } catch (error) { @@ -179,6 +119,211 @@ export const updateCurrentUser = async ( } }; +/** + * Update current user avatar + * PUT /api/v1/users/me/avatar + */ +export const updateAvatar = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const userId = req.user?.id; + + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Not authenticated', code: 'UNAUTHORIZED' }, + }); + return; + } + + // Validate input + const validation = updateAvatarSchema.safeParse(req.body); + if (!validation.success) { + res.status(400).json({ + success: false, + error: { + message: 'Validation failed', + code: 'VALIDATION_ERROR', + details: validation.error.errors, + }, + }); + return; + } + + await usersService.updateAvatar(userId, validation.data.avatarUrl); + + res.json({ + success: true, + message: 'Avatar updated successfully', + }); + } catch (error) { + logger.error('Error updating avatar:', error); + next(error); + } +}; + +/** + * Change current user password + * PUT /api/v1/users/me/password + */ +export const changePassword = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const userId = req.user?.id; + + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Not authenticated', code: 'UNAUTHORIZED' }, + }); + return; + } + + // Validate input + const validation = changePasswordSchema.safeParse(req.body); + if (!validation.success) { + res.status(400).json({ + success: false, + error: { + message: 'Validation failed', + code: 'VALIDATION_ERROR', + details: validation.error.errors, + }, + }); + return; + } + + await usersService.changePassword( + userId, + validation.data.currentPassword, + validation.data.newPassword + ); + + res.json({ + success: true, + message: 'Password changed successfully', + }); + } catch (error) { + const errorMessage = (error as Error).message; + logger.error('Error changing password:', error); + + if (errorMessage === 'Current password is incorrect') { + res.status(400).json({ + success: false, + error: { message: errorMessage, code: 'INVALID_PASSWORD' }, + }); + return; + } + + if (errorMessage === 'Cannot change password for social login accounts') { + res.status(400).json({ + success: false, + error: { message: errorMessage, code: 'SOCIAL_ACCOUNT' }, + }); + return; + } + + next(error); + } +}; + +/** + * Get public profile of another user + * GET /api/v1/users/:id/public + */ +export const getPublicProfile = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const { id } = req.params; + + const profile = await usersService.getPublicProfile(id); + + res.json({ + success: true, + data: profile, + }); + } catch (error) { + logger.error('Error getting public profile:', error); + if ((error as Error).message === 'User not found') { + res.status(404).json({ + success: false, + error: { message: 'User not found', code: 'NOT_FOUND' }, + }); + return; + } + next(error); + } +}; + +/** + * Delete current user account (soft delete) + * DELETE /api/v1/users/me + */ +export const deleteCurrentUser = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const userId = req.user?.id; + + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Not authenticated', code: 'UNAUTHORIZED' }, + }); + return; + } + + // Validate input + const validation = deleteAccountSchema.safeParse(req.body); + if (!validation.success) { + res.status(400).json({ + success: false, + error: { + message: 'Validation failed', + code: 'VALIDATION_ERROR', + details: validation.error.errors, + }, + }); + return; + } + + await usersService.deleteAccount( + userId, + validation.data.password, + validation.data.reason + ); + + res.json({ + success: true, + message: 'Account deleted successfully', + }); + } catch (error) { + const errorMessage = (error as Error).message; + logger.error('Error deleting account:', error); + + if (errorMessage === 'Invalid password') { + res.status(400).json({ + success: false, + error: { message: errorMessage, code: 'INVALID_PASSWORD' }, + }); + return; + } + + next(error); + } +}; + /** * Get user by ID (admin only) * GET /api/v1/users/:id @@ -263,7 +408,7 @@ export const listUsers = async ( const offset = (pageNum - 1) * limitNum; let whereClause = 'WHERE 1=1'; - const params: any[] = []; + const params: QueryParamValue[] = []; let paramIndex = 1; if (role) { @@ -311,7 +456,7 @@ export const listUsers = async ( params.push(limitNum, offset); const dataResult = await db.query(dataQuery, params); - const users = dataResult.rows.map((row: any) => ({ + const users = dataResult.rows.map((row: UserListRow) => ({ id: row.id, email: row.email, emailVerified: row.email_verified, diff --git a/src/modules/users/dto/update-profile.dto.ts b/src/modules/users/dto/update-profile.dto.ts new file mode 100644 index 0000000..451c037 --- /dev/null +++ b/src/modules/users/dto/update-profile.dto.ts @@ -0,0 +1,100 @@ +// ============================================================================ +// Trading Platform - Users DTOs +// Validation schemas for user profile operations +// ============================================================================ + +import { z } from 'zod'; + +/** + * DTO for updating user profile + */ +export const updateProfileSchema = z.object({ + displayName: z + .string() + .min(2, 'Display name must be at least 2 characters') + .max(200, 'Display name must be at most 200 characters') + .optional(), + firstName: z + .string() + .min(1, 'First name must be at least 1 character') + .max(100, 'First name must be at most 100 characters') + .optional(), + lastName: z + .string() + .min(1, 'Last name must be at least 1 character') + .max(100, 'Last name must be at most 100 characters') + .optional(), + bio: z + .string() + .max(1000, 'Bio must be at most 1000 characters') + .optional(), + phoneNumber: z + .string() + .regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number format') + .optional() + .nullable(), + countryCode: z + .string() + .length(2, 'Country code must be 2 characters (ISO 3166-1 alpha-2)') + .optional() + .nullable(), + timezone: z + .string() + .max(50, 'Timezone must be at most 50 characters') + .optional(), + language: z + .string() + .max(10, 'Language code must be at most 10 characters') + .optional(), +}); + +export type UpdateProfileDto = z.infer; + +/** + * DTO for updating avatar + */ +export const updateAvatarSchema = z.object({ + avatarUrl: z + .string() + .url('Invalid avatar URL') + .max(2048, 'Avatar URL must be at most 2048 characters'), +}); + +export type UpdateAvatarDto = z.infer; + +/** + * DTO for changing password + */ +export const changePasswordSchema = z.object({ + currentPassword: z + .string() + .min(1, 'Current password is required'), + newPassword: z + .string() + .min(8, 'Password must be at least 8 characters') + .max(128, 'Password must be at most 128 characters') + .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') + .regex(/[a-z]/, 'Password must contain at least one lowercase letter') + .regex(/[0-9]/, 'Password must contain at least one number') + .regex( + /[!@#$%^&*(),.?":{}|<>]/, + 'Password must contain at least one special character' + ), +}); + +export type ChangePasswordDto = z.infer; + +/** + * DTO for deleting account + */ +export const deleteAccountSchema = z.object({ + password: z + .string() + .min(1, 'Password is required to delete account'), + reason: z + .string() + .max(500, 'Reason must be at most 500 characters') + .optional(), +}); + +export type DeleteAccountDto = z.infer; diff --git a/src/modules/users/index.ts b/src/modules/users/index.ts new file mode 100644 index 0000000..80b495b --- /dev/null +++ b/src/modules/users/index.ts @@ -0,0 +1,9 @@ +// ============================================================================ +// Trading Platform - Users Module +// User profile management +// ============================================================================ + +export { usersRouter } from './users.routes'; +export { usersService } from './services/users.service'; +export * from './types/users.types'; +export * from './dto/update-profile.dto'; diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts new file mode 100644 index 0000000..582adf1 --- /dev/null +++ b/src/modules/users/services/users.service.ts @@ -0,0 +1,295 @@ +// ============================================================================ +// Trading Platform - Users Service +// Business logic for user profile management +// ============================================================================ + +import bcrypt from 'bcryptjs'; +import { db } from '../../../shared/database'; +import { logger } from '../../../shared/utils/logger'; +import type { + UserProfile, + UpdateProfileInput, + PublicProfile, + UserWithProfileRow, + UserRow, +} from '../types/users.types'; + +class UsersService { + /** + * Get authenticated user's full profile + */ + async getProfile(userId: string): Promise { + const query = ` + SELECT + u.id, u.email, u.phone_number, u.created_at, u.updated_at, + p.first_name, p.last_name, p.display_name, p.avatar_url, + p.bio, p.timezone, p.language, p.country_code + FROM auth.users u + LEFT JOIN auth.user_profiles p ON u.id = p.user_id + WHERE u.id = $1 AND u.deactivated_at IS NULL + `; + + const result = await db.query(query, [userId]); + + if (result.rows.length === 0) { + throw new Error('User not found'); + } + + const row = result.rows[0]; + + return { + id: row.id, + email: row.email, + displayName: row.display_name, + firstName: row.first_name, + lastName: row.last_name, + avatarUrl: row.avatar_url, + bio: row.bio, + phoneNumber: row.phone_number, + countryCode: row.country_code, + timezone: row.timezone || 'UTC', + language: row.language || 'en', + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + /** + * Update authenticated user's profile + */ + async updateProfile( + userId: string, + data: UpdateProfileInput + ): Promise { + const { + displayName, + firstName, + lastName, + bio, + phoneNumber, + countryCode, + timezone, + language, + } = data; + + // Upsert profile + const upsertQuery = ` + INSERT INTO auth.user_profiles ( + user_id, first_name, last_name, display_name, bio, + country_code, timezone, language, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + first_name = COALESCE($2, auth.user_profiles.first_name), + last_name = COALESCE($3, auth.user_profiles.last_name), + display_name = COALESCE($4, auth.user_profiles.display_name), + bio = COALESCE($5, auth.user_profiles.bio), + country_code = COALESCE($6, auth.user_profiles.country_code), + timezone = COALESCE($7, auth.user_profiles.timezone), + language = COALESCE($8, auth.user_profiles.language), + updated_at = NOW() + RETURNING * + `; + + await db.query(upsertQuery, [ + userId, + firstName, + lastName, + displayName, + bio, + countryCode, + timezone, + language, + ]); + + // Update phone number in users table if provided + if (phoneNumber !== undefined) { + await db.query( + 'UPDATE auth.users SET phone_number = $1, updated_at = NOW() WHERE id = $2', + [phoneNumber, userId] + ); + } + + logger.info('User profile updated', { userId }); + + return this.getProfile(userId); + } + + /** + * Update user's avatar URL + */ + async updateAvatar(userId: string, avatarUrl: string): Promise { + const query = ` + INSERT INTO auth.user_profiles (user_id, avatar_url, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + avatar_url = $2, + updated_at = NOW() + `; + + await db.query(query, [userId, avatarUrl]); + + logger.info('User avatar updated', { userId }); + } + + /** + * Change user's password + */ + async changePassword( + userId: string, + currentPassword: string, + newPassword: string + ): Promise { + // Get user with password hash + const userQuery = ` + SELECT id, password_hash + FROM auth.users + WHERE id = $1 AND deactivated_at IS NULL + `; + + const userResult = await db.query(userQuery, [userId]); + + if (userResult.rows.length === 0) { + throw new Error('User not found'); + } + + const user = userResult.rows[0]; + + if (!user.password_hash) { + throw new Error('Cannot change password for social login accounts'); + } + + // Verify current password + const isValid = await bcrypt.compare(currentPassword, user.password_hash); + if (!isValid) { + throw new Error('Current password is incorrect'); + } + + // Hash new password + const newPasswordHash = await bcrypt.hash(newPassword, 12); + + // Update password + await db.query( + 'UPDATE auth.users SET password_hash = $1, updated_at = NOW() WHERE id = $2', + [newPasswordHash, userId] + ); + + // Log password change event + await db.query( + `INSERT INTO audit.audit_logs ( + entity_type, entity_id, action, user_id, changes + ) VALUES ('user', $1, 'password_changed', $1, '{}')`, + [userId] + ); + + logger.info('User password changed', { userId }); + } + + /** + * Get public profile of another user + */ + async getPublicProfile(userId: string): Promise { + const query = ` + SELECT + u.id, u.created_at, + p.display_name, p.avatar_url, p.bio + FROM auth.users u + LEFT JOIN auth.user_profiles p ON u.id = p.user_id + WHERE u.id = $1 + AND u.status = 'active' + AND u.deactivated_at IS NULL + `; + + const result = await db.query(query, [userId]); + + if (result.rows.length === 0) { + throw new Error('User not found'); + } + + const row = result.rows[0]; + + return { + id: row.id, + displayName: row.display_name, + avatarUrl: row.avatar_url, + bio: row.bio, + memberSince: row.created_at, + }; + } + + /** + * Delete (soft delete) user account + */ + async deleteAccount( + userId: string, + password: string, + reason?: string + ): Promise { + // Get user with password hash + const userQuery = ` + SELECT id, password_hash, email + FROM auth.users + WHERE id = $1 AND deactivated_at IS NULL + `; + + const userResult = await db.query( + userQuery, + [userId] + ); + + if (userResult.rows.length === 0) { + throw new Error('User not found'); + } + + const user = userResult.rows[0]; + + if (!user.password_hash) { + throw new Error( + 'Cannot delete account without password. Please contact support.' + ); + } + + // Verify password + const isValid = await bcrypt.compare(password, user.password_hash); + if (!isValid) { + throw new Error('Invalid password'); + } + + // Soft delete: set deactivated_at timestamp + await db.query( + `UPDATE auth.users SET + deactivated_at = NOW(), + status = 'pending_verification', + updated_at = NOW() + WHERE id = $1`, + [userId] + ); + + // Log deletion event + await db.query( + `INSERT INTO audit.audit_logs ( + entity_type, entity_id, action, user_id, changes + ) VALUES ('user', $1, 'account_deleted', $1, $2)`, + [userId, JSON.stringify({ reason: reason || 'User requested deletion' })] + ); + + logger.info('User account deleted (soft delete)', { + userId, + email: user.email, + reason, + }); + } + + /** + * Check if user exists and is active + */ + async userExists(userId: string): Promise { + const result = await db.query( + `SELECT 1 FROM auth.users + WHERE id = $1 AND deactivated_at IS NULL`, + [userId] + ); + return result.rows.length > 0; + } +} + +export const usersService = new UsersService(); diff --git a/src/modules/users/types/users.types.ts b/src/modules/users/types/users.types.ts new file mode 100644 index 0000000..d155963 --- /dev/null +++ b/src/modules/users/types/users.types.ts @@ -0,0 +1,138 @@ +// ============================================================================ +// Trading Platform - Users Types +// Aligned with DDL: auth.users + auth.user_profiles +// ============================================================================ + +/** + * Full user profile (private - only for authenticated user) + * Combines data from auth.users and auth.user_profiles + */ +export interface UserProfile { + id: string; + email: string; + displayName: string | null; + firstName: string | null; + lastName: string | null; + avatarUrl: string | null; + bio: string | null; + phoneNumber: string | null; + countryCode: string | null; + timezone: string; + language: string; + createdAt: Date; + updatedAt: Date; +} + +/** + * Input for updating user profile + */ +export interface UpdateProfileInput { + displayName?: string; + firstName?: string; + lastName?: string; + bio?: string; + phoneNumber?: string | null; + countryCode?: string | null; + timezone?: string; + language?: string; +} + +/** + * Public profile (visible to other users) + */ +export interface PublicProfile { + id: string; + displayName: string | null; + avatarUrl: string | null; + bio: string | null; + memberSince: Date; +} + +/** + * Database row type for user with profile (snake_case) + */ +export interface UserWithProfileRow { + id: string; + email: string; + email_verified: boolean; + phone_number: string | null; + phone_verified: boolean; + status: string; + role: string; + last_login_at: Date | null; + created_at: Date; + updated_at: Date; + // Profile fields + first_name: string | null; + last_name: string | null; + display_name: string | null; + avatar_url: string | null; + bio: string | null; + timezone: string | null; + language: string | null; + country_code: string | null; +} + +/** + * Database row type for user profile (snake_case) + */ +export interface UserProfileRow { + id: string; + user_id: string; + first_name: string | null; + last_name: string | null; + display_name: string | null; + avatar_url: string | null; + bio: string | null; + language: string; + timezone: string; + country_code: string | null; + newsletter_subscribed: boolean; + marketing_emails_enabled: boolean; + notifications_enabled: boolean; + metadata: Record; + created_at: Date; + updated_at: Date; +} + +/** + * Database row for user (snake_case) + */ +export interface UserRow { + id: string; + email: string; + email_verified: boolean; + password_hash: string | null; + phone_number: string | null; + phone_verified: boolean; + status: string; + role: string; + mfa_enabled: boolean; + last_login_at: Date | null; + deactivated_at: Date | null; + created_at: Date; + updated_at: Date; +} + +/** + * Change password input + */ +export interface ChangePasswordInput { + currentPassword: string; + newPassword: string; +} + +/** + * Delete account input + */ +export interface DeleteAccountInput { + password: string; + reason?: string; +} + +/** + * Update avatar input + */ +export interface UpdateAvatarInput { + avatarUrl: string; +} diff --git a/src/modules/users/users.routes.ts b/src/modules/users/users.routes.ts index 72a3a3a..2bc5f4a 100644 --- a/src/modules/users/users.routes.ts +++ b/src/modules/users/users.routes.ts @@ -24,11 +24,38 @@ const authHandler = (fn: Function): RequestHandler => fn as RequestHandler; router.get('/me', requireAuth, authHandler(usersController.getCurrentUser)); /** - * PATCH /api/v1/users/me + * PUT /api/v1/users/me * Update current user profile - * Body: { firstName?, lastName?, displayName?, avatarUrl?, timezone?, language?, preferredCurrency? } + * Body: { displayName?, firstName?, lastName?, bio?, phoneNumber?, countryCode?, timezone?, language? } */ -router.patch('/me', requireAuth, authHandler(usersController.updateCurrentUser)); +router.put('/me', requireAuth, authHandler(usersController.updateCurrentUser)); + +/** + * PUT /api/v1/users/me/avatar + * Update current user avatar + * Body: { avatarUrl: string } + */ +router.put('/me/avatar', requireAuth, authHandler(usersController.updateAvatar)); + +/** + * PUT /api/v1/users/me/password + * Change current user password + * Body: { currentPassword: string, newPassword: string } + */ +router.put('/me/password', requireAuth, authHandler(usersController.changePassword)); + +/** + * DELETE /api/v1/users/me + * Delete current user account (soft delete) + * Body: { password: string, reason?: string } + */ +router.delete('/me', requireAuth, authHandler(usersController.deleteCurrentUser)); + +/** + * GET /api/v1/users/:id/public + * Get public profile of another user + */ +router.get('/:id/public', requireAuth, authHandler(usersController.getPublicProfile)); // ============================================================================ // Admin Routes (Admin only)