feat: delete users - not tested
This commit is contained in:
38
backend/package-lock.json
generated
38
backend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@fastify/multipart": "^9.0.1",
|
||||
"@fastify/type-provider-typebox": "^6.1.0",
|
||||
"@sinclair/typebox": "^0.34.0",
|
||||
"auth0": "^4.12.0",
|
||||
"axios": "^1.7.9",
|
||||
"fastify": "^5.2.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
@@ -2516,6 +2517,33 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/auth0": {
|
||||
"version": "4.37.0",
|
||||
"resolved": "https://registry.npmjs.org/auth0/-/auth0-4.37.0.tgz",
|
||||
"integrity": "sha512-+TqJRxh4QvbD4TQIYx1ak2vanykQkG/nIZLuR6o8LoQj425gjVG3tFuUbbOeh/nCpP1rnvU0CCV1ChZHYXLU/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jose": "^4.13.2",
|
||||
"undici-types": "^6.15.0",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/auth0/node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/avvio": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz",
|
||||
@@ -5511,6 +5539,15 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify": {
|
||||
"version": "1.15.4",
|
||||
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
|
||||
@@ -8008,7 +8045,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"get-jwks": "^11.0.3",
|
||||
"file-type": "^16.5.4",
|
||||
"resend": "^3.0.0",
|
||||
"node-cron": "^3.0.3"
|
||||
"node-cron": "^3.0.3",
|
||||
"auth0": "^4.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -15,6 +15,7 @@ import errorPlugin from './core/plugins/error.plugin';
|
||||
import { appConfig } from './core/config/config-loader';
|
||||
|
||||
// Fastify feature routes
|
||||
import { authRoutes } from './features/auth';
|
||||
import { vehiclesRoutes } from './features/vehicles/api/vehicles.routes';
|
||||
import { fuelLogsRoutes } from './features/fuel-logs/api/fuel-logs.routes';
|
||||
import { stationsRoutes } from './features/stations/api/stations.routes';
|
||||
@@ -25,6 +26,7 @@ import { platformRoutes } from './features/platform';
|
||||
import { adminRoutes } from './features/admin/api/admin.routes';
|
||||
import { notificationsRoutes } from './features/notifications';
|
||||
import { userProfileRoutes } from './features/user-profile';
|
||||
import { onboardingRoutes } from './features/onboarding';
|
||||
import { pool } from './core/config/database';
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
@@ -82,7 +84,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env['NODE_ENV'],
|
||||
features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
|
||||
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,7 +94,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
scope: 'api',
|
||||
timestamp: new Date().toISOString(),
|
||||
features: ['admin', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
|
||||
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +120,8 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
});
|
||||
|
||||
// Register Fastify feature routes
|
||||
await app.register(authRoutes, { prefix: '/api' });
|
||||
await app.register(onboardingRoutes, { prefix: '/api' });
|
||||
await app.register(platformRoutes, { prefix: '/api' });
|
||||
await app.register(vehiclesRoutes, { prefix: '/api' });
|
||||
await app.register(documentsRoutes, { prefix: '/api' });
|
||||
|
||||
200
backend/src/core/auth/auth0-management.client.ts
Normal file
200
backend/src/core/auth/auth0-management.client.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Auth0 Management API Client
|
||||
* Provides methods for user management operations via Auth0 Management API
|
||||
*/
|
||||
import { ManagementClient } from 'auth0';
|
||||
import { appConfig } from '../config/config-loader';
|
||||
import { logger } from '../logging/logger';
|
||||
|
||||
interface CreateUserParams {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface UserDetails {
|
||||
userId: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
class Auth0ManagementClientSingleton {
|
||||
private client: ManagementClient | null = null;
|
||||
private readonly CONNECTION_NAME = 'Username-Password-Authentication';
|
||||
|
||||
/**
|
||||
* Lazy initialization of ManagementClient to avoid startup issues
|
||||
*/
|
||||
private getClient(): ManagementClient {
|
||||
if (!this.client) {
|
||||
const config = appConfig.getAuth0ManagementConfig();
|
||||
|
||||
this.client = new ManagementClient({
|
||||
domain: config.domain,
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
});
|
||||
|
||||
logger.info('Auth0 Management API client initialized');
|
||||
}
|
||||
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user in Auth0
|
||||
* @param email User's email address
|
||||
* @param password User's password
|
||||
* @returns Auth0 user ID
|
||||
*/
|
||||
async createUser({ email, password }: CreateUserParams): Promise<string> {
|
||||
try {
|
||||
const client = this.getClient();
|
||||
|
||||
const response = await client.users.create({
|
||||
connection: this.CONNECTION_NAME,
|
||||
email,
|
||||
password,
|
||||
email_verified: false,
|
||||
});
|
||||
|
||||
const user = response.data;
|
||||
if (!user.user_id) {
|
||||
throw new Error('Auth0 did not return a user_id');
|
||||
}
|
||||
|
||||
logger.info('User created in Auth0', { userId: user.user_id, email });
|
||||
return user.user_id;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create user in Auth0', { email, error });
|
||||
throw new Error(`Auth0 user creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user details from Auth0
|
||||
* @param userId Auth0 user ID (format: auth0|xxx or google-oauth2|xxx)
|
||||
* @returns User details including email and verification status
|
||||
*/
|
||||
async getUser(userId: string): Promise<UserDetails> {
|
||||
try {
|
||||
const client = this.getClient();
|
||||
|
||||
const response = await client.users.get({ id: userId });
|
||||
const user = response.data;
|
||||
|
||||
if (!user.email) {
|
||||
throw new Error('User email not found in Auth0 response');
|
||||
}
|
||||
|
||||
logger.info('Retrieved user from Auth0', { userId, email: user.email });
|
||||
|
||||
return {
|
||||
userId: user.user_id || userId,
|
||||
email: user.email,
|
||||
emailVerified: user.email_verified || false,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user from Auth0', { userId, error });
|
||||
throw new Error(`Auth0 user retrieval failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend email verification to user
|
||||
* @param userId Auth0 user ID
|
||||
*/
|
||||
async resendVerificationEmail(userId: string): Promise<void> {
|
||||
try {
|
||||
const client = this.getClient();
|
||||
|
||||
await client.jobs.verifyEmail({
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
logger.info('Verification email sent', { userId });
|
||||
} catch (error) {
|
||||
logger.error('Failed to send verification email', { userId, error });
|
||||
throw new Error(`Failed to send verification email: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user's email is verified
|
||||
* @param userId Auth0 user ID
|
||||
* @returns true if email is verified, false otherwise
|
||||
*/
|
||||
async checkEmailVerified(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const user = await this.getUser(userId);
|
||||
return user.emailVerified;
|
||||
} catch (error) {
|
||||
logger.error('Failed to check email verification status', { userId, error });
|
||||
throw new Error(`Failed to check email verification: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user from Auth0 (permanent deletion)
|
||||
* @param userId Auth0 user ID (format: auth0|xxx or google-oauth2|xxx)
|
||||
*/
|
||||
async deleteUser(userId: string): Promise<void> {
|
||||
try {
|
||||
const client = this.getClient();
|
||||
|
||||
await client.users.delete({ id: userId });
|
||||
|
||||
logger.info('User deleted from Auth0', { userId });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete user from Auth0', { userId, error });
|
||||
throw new Error(`Auth0 user deletion failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user password using Resource Owner Password Grant
|
||||
* @param email User's email address
|
||||
* @param password User's password to verify
|
||||
* @returns true if password is valid, false otherwise
|
||||
*/
|
||||
async verifyPassword(email: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const config = appConfig.getAuth0ManagementConfig();
|
||||
|
||||
const response = await fetch(`https://${config.domain}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: 'password',
|
||||
username: email,
|
||||
password,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
audience: `https://${config.domain}/api/v2/`,
|
||||
scope: 'openid profile email',
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('Password verification successful', { email });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.status === 403 || response.status === 401) {
|
||||
logger.info('Password verification failed - invalid credentials', { email });
|
||||
return false;
|
||||
}
|
||||
|
||||
const errorBody = await response.text();
|
||||
logger.error('Password verification request failed', { email, status: response.status, error: errorBody });
|
||||
throw new Error(`Password verification failed with status ${response.status}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify password', { email, error });
|
||||
throw new Error(`Password verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const auth0ManagementClient = new Auth0ManagementClientSingleton();
|
||||
@@ -122,6 +122,8 @@ const configSchema = z.object({
|
||||
const secretsSchema = z.object({
|
||||
postgres_password: z.string(),
|
||||
auth0_client_secret: z.string(),
|
||||
auth0_management_client_id: z.string(),
|
||||
auth0_management_client_secret: z.string(),
|
||||
google_maps_api_key: z.string(),
|
||||
resend_api_key: z.string(),
|
||||
});
|
||||
@@ -137,6 +139,7 @@ export interface AppConfiguration {
|
||||
getDatabaseUrl(): string;
|
||||
getRedisUrl(): string;
|
||||
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
|
||||
getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string };
|
||||
}
|
||||
|
||||
class ConfigurationLoader {
|
||||
@@ -171,6 +174,8 @@ class ConfigurationLoader {
|
||||
const secretFiles = [
|
||||
'postgres-password',
|
||||
'auth0-client-secret',
|
||||
'auth0-management-client-id',
|
||||
'auth0-management-client-secret',
|
||||
'google-maps-api-key',
|
||||
'resend-api-key',
|
||||
];
|
||||
@@ -227,6 +232,14 @@ class ConfigurationLoader {
|
||||
clientSecret: secrets.auth0_client_secret,
|
||||
};
|
||||
},
|
||||
|
||||
getAuth0ManagementConfig() {
|
||||
return {
|
||||
domain: config.auth0.domain,
|
||||
clientId: secrets.auth0_management_client_id,
|
||||
clientSecret: secrets.auth0_management_client_secret,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Set RESEND_API_KEY in environment for EmailService
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @ai-summary Fastify JWT authentication plugin using Auth0
|
||||
* @ai-context Validates JWT tokens against Auth0 JWKS endpoint, hydrates userContext with profile
|
||||
* @ai-context Includes email verification guard to block unverified users
|
||||
*/
|
||||
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
@@ -10,6 +11,15 @@ import { appConfig } from '../config/config-loader';
|
||||
import { logger } from '../logging/logger';
|
||||
import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository';
|
||||
import { pool } from '../config/database';
|
||||
import { auth0ManagementClient } from '../auth/auth0-management.client';
|
||||
|
||||
// Routes that don't require email verification
|
||||
const VERIFICATION_EXEMPT_ROUTES = [
|
||||
'/api/auth/',
|
||||
'/api/onboarding/',
|
||||
'/api/health',
|
||||
'/health',
|
||||
];
|
||||
|
||||
// Define the Auth0 JWT payload type
|
||||
interface Auth0JwtPayload {
|
||||
@@ -42,6 +52,8 @@ declare module 'fastify' {
|
||||
userId: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
emailVerified: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
isAdmin: boolean;
|
||||
adminRecord?: any;
|
||||
};
|
||||
@@ -97,31 +109,91 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
// Initialize profile repository for user profile hydration
|
||||
const profileRepo = new UserProfileRepository(pool);
|
||||
|
||||
// Helper to check if route is exempt from verification
|
||||
const isVerificationExempt = (url: string): boolean => {
|
||||
return VERIFICATION_EXEMPT_ROUTES.some(route => url.startsWith(route));
|
||||
};
|
||||
|
||||
// Decorate with authenticate function that validates JWT
|
||||
fastify.decorate('authenticate', async function(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
|
||||
const userId = request.user?.sub;
|
||||
if (!userId) {
|
||||
throw new Error('Missing user ID in JWT');
|
||||
}
|
||||
|
||||
// Get or create user profile from database
|
||||
// This ensures we have reliable email/displayName for notifications
|
||||
let email = request.user?.email;
|
||||
let displayName: string | undefined;
|
||||
let emailVerified = false;
|
||||
let onboardingCompleted = false;
|
||||
|
||||
try {
|
||||
// If JWT doesn't have email, fetch from Auth0 Management API
|
||||
if (!email || email.includes('@unknown.local')) {
|
||||
try {
|
||||
const auth0User = await auth0ManagementClient.getUser(userId);
|
||||
if (auth0User.email) {
|
||||
email = auth0User.email;
|
||||
emailVerified = auth0User.emailVerified;
|
||||
logger.info('Fetched email from Auth0 Management API', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
hasEmail: true,
|
||||
});
|
||||
}
|
||||
} catch (auth0Error) {
|
||||
logger.warn('Failed to fetch user from Auth0 Management API', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
error: auth0Error instanceof Error ? auth0Error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create profile with correct email
|
||||
const profile = await profileRepo.getOrCreate(userId, {
|
||||
email: request.user?.email || `${userId}@unknown.local`,
|
||||
email: email || `${userId}@unknown.local`,
|
||||
displayName: request.user?.name || request.user?.nickname,
|
||||
});
|
||||
|
||||
// If profile has placeholder email but we now have real email, update it
|
||||
if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) {
|
||||
await profileRepo.updateEmail(userId, email);
|
||||
logger.info('Updated profile with correct email from Auth0', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
});
|
||||
}
|
||||
|
||||
// Use notificationEmail if set, otherwise fall back to profile email
|
||||
email = profile.notificationEmail || profile.email;
|
||||
displayName = profile.displayName || undefined;
|
||||
emailVerified = profile.emailVerified;
|
||||
onboardingCompleted = profile.onboardingCompletedAt !== null;
|
||||
|
||||
// Sync email verification status from Auth0 if needed
|
||||
if (!emailVerified) {
|
||||
try {
|
||||
const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(userId);
|
||||
if (isVerifiedInAuth0 && !profile.emailVerified) {
|
||||
await profileRepo.updateEmailVerified(userId, true);
|
||||
emailVerified = true;
|
||||
logger.info('Synced email verification status from Auth0', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
});
|
||||
}
|
||||
} catch (syncError) {
|
||||
// Don't fail auth if sync fails, just log
|
||||
logger.warn('Failed to sync email verification status', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
error: syncError instanceof Error ? syncError.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (profileError) {
|
||||
// Log but don't fail auth if profile fetch fails
|
||||
logger.warn('Failed to fetch user profile', {
|
||||
userId: userId?.substring(0, 8) + '...',
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
error: profileError instanceof Error ? profileError.message : 'Unknown error',
|
||||
});
|
||||
// Fall back to JWT email if available
|
||||
@@ -133,13 +205,29 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
userId,
|
||||
email,
|
||||
displayName,
|
||||
emailVerified,
|
||||
onboardingCompleted,
|
||||
isAdmin: false, // Default to false; admin status checked by admin guard
|
||||
};
|
||||
|
||||
// Email verification guard - block unverified users from non-exempt routes
|
||||
if (!emailVerified && !isVerificationExempt(request.url)) {
|
||||
logger.warn('Blocked unverified user from accessing protected route', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
path: request.url,
|
||||
});
|
||||
return reply.code(403).send({
|
||||
error: 'Email not verified',
|
||||
message: 'Please verify your email address before accessing the application',
|
||||
code: 'EMAIL_NOT_VERIFIED',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('JWT authentication successful', {
|
||||
userId: userId?.substring(0, 8) + '...',
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
hasEmail: !!email,
|
||||
audience: auth0Config.audience
|
||||
emailVerified,
|
||||
audience: auth0Config.audience,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('JWT authentication failed', {
|
||||
@@ -148,9 +236,9 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
|
||||
reply.code(401).send({
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or missing JWT token'
|
||||
message: 'Invalid or missing JWT token',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import cron from 'node-cron';
|
||||
import { logger } from '../logging/logger';
|
||||
import { processScheduledNotifications } from '../../features/notifications/jobs/notification-processor.job';
|
||||
import { processAccountPurges } from '../../features/user-profile/jobs/account-purge.job';
|
||||
|
||||
let schedulerInitialized = false;
|
||||
|
||||
@@ -29,8 +30,25 @@ export function initializeScheduler(): void {
|
||||
}
|
||||
});
|
||||
|
||||
// Daily account purge job at 2 AM (GDPR compliance - 30-day grace period)
|
||||
cron.schedule('0 2 * * *', async () => {
|
||||
logger.info('Running account purge job');
|
||||
try {
|
||||
const result = await processAccountPurges();
|
||||
logger.info('Account purge job completed successfully', {
|
||||
processed: result.processed,
|
||||
deleted: result.deleted,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Account purge job failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
schedulerInitialized = true;
|
||||
logger.info('Cron scheduler initialized - notification job scheduled for 8 AM daily');
|
||||
logger.info('Cron scheduler initialized - notification job (8 AM) and account purge job (2 AM) scheduled daily');
|
||||
}
|
||||
|
||||
export function isSchedulerInitialized(): boolean {
|
||||
|
||||
@@ -26,12 +26,12 @@ export class UserPreferencesRepository {
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
||||
const values = [
|
||||
data.userId,
|
||||
data.unitSystem || 'imperial',
|
||||
(data as any).currencyCode || 'USD',
|
||||
(data as any).timeZone || 'UTC'
|
||||
data.currencyCode || 'USD',
|
||||
data.timeZone || 'UTC'
|
||||
];
|
||||
|
||||
const result = await this.db.query(query, values);
|
||||
@@ -47,13 +47,13 @@ export class UserPreferencesRepository {
|
||||
fields.push(`unit_system = $${paramCount++}`);
|
||||
values.push(data.unitSystem);
|
||||
}
|
||||
if ((data as any).currencyCode !== undefined) {
|
||||
if (data.currencyCode !== undefined) {
|
||||
fields.push(`currency_code = $${paramCount++}`);
|
||||
values.push((data as any).currencyCode);
|
||||
values.push(data.currencyCode);
|
||||
}
|
||||
if ((data as any).timeZone !== undefined) {
|
||||
if (data.timeZone !== undefined) {
|
||||
fields.push(`time_zone = $${paramCount++}`);
|
||||
values.push((data as any).timeZone);
|
||||
values.push(data.timeZone);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
@@ -61,12 +61,12 @@ export class UserPreferencesRepository {
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE user_preferences
|
||||
UPDATE user_preferences
|
||||
SET ${fields.join(', ')}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = $${paramCount}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
||||
values.push(userId);
|
||||
const result = await this.db.query(query, values);
|
||||
return result.rows.length > 0 ? this.mapRow(result.rows[0]) : null;
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface UserPreferences {
|
||||
export interface CreateUserPreferencesRequest {
|
||||
userId: string;
|
||||
unitSystem?: UnitSystem;
|
||||
currencyCode?: string;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserPreferencesRequest {
|
||||
|
||||
@@ -155,6 +155,12 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
handler: usersController.promoteToAdmin.bind(usersController)
|
||||
});
|
||||
|
||||
// DELETE /api/admin/users/:auth0Sub - Hard delete user (permanent)
|
||||
fastify.delete<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.hardDeleteUser.bind(usersController)
|
||||
});
|
||||
|
||||
// Phase 3: Catalog CRUD endpoints
|
||||
|
||||
// Makes endpoints
|
||||
|
||||
@@ -486,4 +486,73 @@ export class UsersController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/users/:auth0Sub - Hard delete user (permanent)
|
||||
*/
|
||||
async hardDeleteUser(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
if (!actorId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: paramsResult.error.errors.map(e => e.message).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
|
||||
// Optional reason from query params
|
||||
const reason = (request.query as any)?.reason;
|
||||
|
||||
// Hard delete user
|
||||
await this.userProfileService.adminHardDeleteUser(
|
||||
auth0Sub,
|
||||
actorId,
|
||||
reason
|
||||
);
|
||||
|
||||
return reply.code(200).send({
|
||||
message: 'User permanently deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error hard deleting user', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'Cannot delete your own account') {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad request',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to delete user',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
162
backend/src/features/auth/README.md
Normal file
162
backend/src/features/auth/README.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Auth Feature
|
||||
|
||||
User signup and email verification workflow using Auth0.
|
||||
|
||||
## Overview
|
||||
|
||||
This feature provides API endpoints for user registration and email verification management. It integrates with Auth0 for authentication and manages user profiles in the local database.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **API Layer**: Controllers and routes for HTTP request/response handling
|
||||
- **Domain Layer**: Business logic in AuthService
|
||||
- **Integration**: Auth0 Management API client and UserProfileRepository
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /api/auth/signup (Public)
|
||||
Create a new user account. Auth0 automatically sends verification email upon account creation.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "Password123"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- Email: Valid email format required
|
||||
- Password: Minimum 8 characters, at least one uppercase letter and one number
|
||||
|
||||
**Response (201 Created):**
|
||||
```json
|
||||
{
|
||||
"userId": "auth0|123456",
|
||||
"email": "user@example.com",
|
||||
"message": "Account created successfully. Please check your email to verify your account."
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- 400: Invalid email or weak password
|
||||
- 409: Email already exists
|
||||
- 500: Auth0 API error or database error
|
||||
|
||||
---
|
||||
|
||||
### GET /api/auth/verify-status (Protected)
|
||||
Check email verification status. Updates local database if status changed in Auth0.
|
||||
|
||||
**Authentication:** Requires JWT
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"emailVerified": true,
|
||||
"email": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Unauthorized (no JWT or invalid JWT)
|
||||
- 500: Auth0 API error
|
||||
|
||||
---
|
||||
|
||||
### POST /api/auth/resend-verification (Protected)
|
||||
Resend email verification. Skips if email is already verified.
|
||||
|
||||
**Authentication:** Requires JWT
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"message": "Verification email sent. Please check your inbox."
|
||||
}
|
||||
```
|
||||
|
||||
or if already verified:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Email is already verified"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- 401: Unauthorized (no JWT or invalid JWT)
|
||||
- 500: Auth0 API error
|
||||
|
||||
## Business Logic
|
||||
|
||||
### Signup Flow
|
||||
1. Validate email and password format
|
||||
2. Create user in Auth0 via Management API
|
||||
3. Auth0 automatically sends verification email
|
||||
4. Create local user profile with `emailVerified=false`
|
||||
5. Return success with user ID
|
||||
|
||||
### Verify Status Flow
|
||||
1. Extract Auth0 user ID from JWT
|
||||
2. Query Auth0 Management API for `email_verified` status
|
||||
3. Update local database if status changed
|
||||
4. Return current verification status
|
||||
|
||||
### Resend Verification Flow
|
||||
1. Extract Auth0 user ID from JWT
|
||||
2. Check if already verified (skip if true)
|
||||
3. Call Auth0 Management API to resend verification email
|
||||
4. Return success message
|
||||
|
||||
## Integration Points
|
||||
|
||||
- **Auth0 Management API**: User creation, verification status, resend email
|
||||
- **User Profile Repository**: Local user profile management
|
||||
- **Core Logger**: Structured logging for all operations
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Validation errors** (400): Invalid email format, weak password
|
||||
- **Conflict errors** (409): Email already exists in Auth0
|
||||
- **Unauthorized** (401): Missing or invalid JWT for protected endpoints
|
||||
- **Server errors** (500): Auth0 API failures, database errors
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
Location: `tests/unit/auth.service.test.ts`
|
||||
|
||||
Tests business logic with mocked Auth0 client and repository:
|
||||
- User creation success and failure scenarios
|
||||
- Email verification status retrieval and updates
|
||||
- Resend verification logic
|
||||
|
||||
### Integration Tests
|
||||
Location: `tests/integration/auth.integration.test.ts`
|
||||
|
||||
Tests complete API workflows with test database:
|
||||
- Signup with valid and invalid inputs
|
||||
- Verification status checks
|
||||
- Resend verification email
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
npm test -- features/auth
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Auth0 Management API client (`core/auth/auth0-management.client.ts`)
|
||||
- UserProfileRepository (`features/user-profile/data/user-profile.repository.ts`)
|
||||
- Core logger (`core/logging/logger.ts`)
|
||||
- Database pool (`core/config/database.ts`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Auth0 Management API credentials are configured via environment variables:
|
||||
- `AUTH0_DOMAIN`
|
||||
- `AUTH0_MANAGEMENT_CLIENT_ID`
|
||||
- `AUTH0_MANAGEMENT_CLIENT_SECRET`
|
||||
|
||||
See `core/config/config-loader.ts` for configuration details.
|
||||
122
backend/src/features/auth/api/auth.controller.ts
Normal file
122
backend/src/features/auth/api/auth.controller.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @ai-summary Fastify route handlers for auth API
|
||||
* @ai-context HTTP request/response handling with Fastify reply methods
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { AuthService } from '../domain/auth.service';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { signupSchema } from './auth.validation';
|
||||
|
||||
export class AuthController {
|
||||
private authService: AuthService;
|
||||
|
||||
constructor() {
|
||||
const userProfileRepository = new UserProfileRepository(pool);
|
||||
this.authService = new AuthService(userProfileRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/signup
|
||||
* Create new user account
|
||||
* Public endpoint - no JWT required
|
||||
*/
|
||||
async signup(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const validation = signupSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
message: validation.error.errors[0]?.message || 'Invalid input',
|
||||
details: validation.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { email, password } = validation.data;
|
||||
|
||||
const result = await this.authService.signup({ email, password });
|
||||
|
||||
logger.info('User signup successful', { email, userId: result.userId });
|
||||
|
||||
return reply.code(201).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Signup failed', { error, email: (request.body as any)?.email });
|
||||
|
||||
if (error.message === 'Email already exists') {
|
||||
return reply.code(409).send({
|
||||
error: 'Conflict',
|
||||
message: 'An account with this email already exists',
|
||||
});
|
||||
}
|
||||
|
||||
// Auth0 API errors
|
||||
if (error.message?.includes('Auth0')) {
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to create account. Please try again later.',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to create account',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/verify-status
|
||||
* Check email verification status
|
||||
* Protected endpoint - requires JWT
|
||||
*/
|
||||
async getVerifyStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
const result = await this.authService.getVerifyStatus(userId);
|
||||
|
||||
logger.info('Verification status checked', { userId, emailVerified: result.emailVerified });
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get verification status', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to check verification status',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/resend-verification
|
||||
* Resend verification email
|
||||
* Protected endpoint - requires JWT
|
||||
*/
|
||||
async resendVerification(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
const result = await this.authService.resendVerification(userId);
|
||||
|
||||
logger.info('Verification email resent', { userId });
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to resend verification email', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to resend verification email',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
30
backend/src/features/auth/api/auth.routes.ts
Normal file
30
backend/src/features/auth/api/auth.routes.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @ai-summary Fastify routes for auth API
|
||||
* @ai-context Route definitions with Zod validation and authentication
|
||||
*/
|
||||
|
||||
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { AuthController } from './auth.controller';
|
||||
|
||||
export const authRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
_opts: FastifyPluginOptions
|
||||
) => {
|
||||
const authController = new AuthController();
|
||||
|
||||
// POST /api/auth/signup - Create new user (public, no JWT required)
|
||||
fastify.post('/auth/signup', authController.signup.bind(authController));
|
||||
|
||||
// GET /api/auth/verify-status - Check verification status (requires JWT)
|
||||
fastify.get('/auth/verify-status', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: authController.getVerifyStatus.bind(authController),
|
||||
});
|
||||
|
||||
// POST /api/auth/resend-verification - Resend verification email (requires JWT)
|
||||
fastify.post('/auth/resend-verification', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: authController.resendVerification.bind(authController),
|
||||
});
|
||||
};
|
||||
23
backend/src/features/auth/api/auth.validation.ts
Normal file
23
backend/src/features/auth/api/auth.validation.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @ai-summary Request validation schemas for auth API
|
||||
* @ai-context Uses Zod for runtime validation and type safety
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Password requirements:
|
||||
// - Minimum 8 characters
|
||||
// - At least one uppercase letter
|
||||
// - At least one number
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters long')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number');
|
||||
|
||||
export const signupSchema = z.object({
|
||||
email: z.string().email('Invalid email format'),
|
||||
password: passwordSchema,
|
||||
});
|
||||
|
||||
export type SignupInput = z.infer<typeof signupSchema>;
|
||||
130
backend/src/features/auth/domain/auth.service.ts
Normal file
130
backend/src/features/auth/domain/auth.service.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @ai-summary Auth service business logic
|
||||
* @ai-context Coordinates between Auth0 Management API and local user profile database
|
||||
*/
|
||||
|
||||
import { auth0ManagementClient } from '../../../core/auth/auth0-management.client';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
SignupRequest,
|
||||
SignupResponse,
|
||||
VerifyStatusResponse,
|
||||
ResendVerificationResponse,
|
||||
} from './auth.types';
|
||||
|
||||
export class AuthService {
|
||||
constructor(private userProfileRepository: UserProfileRepository) {}
|
||||
|
||||
/**
|
||||
* Create a new user account
|
||||
* 1. Create user in Auth0 (which automatically sends verification email)
|
||||
* 2. Create local user profile with emailVerified=false
|
||||
*/
|
||||
async signup(request: SignupRequest): Promise<SignupResponse> {
|
||||
const { email, password } = request;
|
||||
|
||||
try {
|
||||
// Create user in Auth0 Management API
|
||||
// Auth0 automatically sends verification email on user creation
|
||||
const auth0UserId = await auth0ManagementClient.createUser({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
logger.info('Auth0 user created', { auth0UserId, email });
|
||||
|
||||
// Create local user profile
|
||||
const userProfile = await this.userProfileRepository.create(
|
||||
auth0UserId,
|
||||
email,
|
||||
undefined // displayName is optional
|
||||
);
|
||||
|
||||
logger.info('User profile created', { userId: userProfile.id, email });
|
||||
|
||||
return {
|
||||
userId: auth0UserId,
|
||||
email,
|
||||
message: 'Account created successfully. Please check your email to verify your account.',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Signup failed', { email, error });
|
||||
|
||||
// Check for duplicate email error from Auth0
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
throw new Error('Email already exists');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check email verification status
|
||||
* Queries Auth0 for current verification status and updates local database if changed
|
||||
*/
|
||||
async getVerifyStatus(auth0Sub: string): Promise<VerifyStatusResponse> {
|
||||
try {
|
||||
// Get user details from Auth0
|
||||
const userDetails = await auth0ManagementClient.getUser(auth0Sub);
|
||||
|
||||
logger.info('Retrieved user verification status from Auth0', {
|
||||
auth0Sub,
|
||||
emailVerified: userDetails.emailVerified,
|
||||
});
|
||||
|
||||
// Update local database if verification status changed
|
||||
const localProfile = await this.userProfileRepository.getByAuth0Sub(auth0Sub);
|
||||
|
||||
if (localProfile && localProfile.emailVerified !== userDetails.emailVerified) {
|
||||
await this.userProfileRepository.updateEmailVerified(
|
||||
auth0Sub,
|
||||
userDetails.emailVerified
|
||||
);
|
||||
logger.info('Local email verification status updated', {
|
||||
auth0Sub,
|
||||
emailVerified: userDetails.emailVerified,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
emailVerified: userDetails.emailVerified,
|
||||
email: userDetails.email,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get verification status', { auth0Sub, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend verification email
|
||||
* Calls Auth0 Management API to trigger verification email
|
||||
*/
|
||||
async resendVerification(auth0Sub: string): Promise<ResendVerificationResponse> {
|
||||
try {
|
||||
// Check if already verified
|
||||
const verified = await auth0ManagementClient.checkEmailVerified(auth0Sub);
|
||||
|
||||
if (verified) {
|
||||
logger.info('Email already verified, skipping resend', { auth0Sub });
|
||||
return {
|
||||
message: 'Email is already verified',
|
||||
};
|
||||
}
|
||||
|
||||
// Request Auth0 to resend verification email
|
||||
await auth0ManagementClient.resendVerificationEmail(auth0Sub);
|
||||
|
||||
logger.info('Verification email resent', { auth0Sub });
|
||||
|
||||
return {
|
||||
message: 'Verification email sent. Please check your inbox.',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to resend verification email', { auth0Sub, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
backend/src/features/auth/domain/auth.types.ts
Normal file
28
backend/src/features/auth/domain/auth.types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @ai-summary Auth domain types
|
||||
* @ai-context Type definitions for authentication and signup workflows
|
||||
*/
|
||||
|
||||
// Request to create a new user account
|
||||
export interface SignupRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Response from signup endpoint
|
||||
export interface SignupResponse {
|
||||
userId: string;
|
||||
email: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Response from verify status endpoint
|
||||
export interface VerifyStatusResponse {
|
||||
emailVerified: boolean;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Response from resend verification endpoint
|
||||
export interface ResendVerificationResponse {
|
||||
message: string;
|
||||
}
|
||||
18
backend/src/features/auth/index.ts
Normal file
18
backend/src/features/auth/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @ai-summary Public API for auth feature capsule
|
||||
* @ai-note This is the ONLY file other features should import from
|
||||
*/
|
||||
|
||||
// Export service for use by other features (if needed)
|
||||
export { AuthService } from './domain/auth.service';
|
||||
|
||||
// Export types needed by other features
|
||||
export type {
|
||||
SignupRequest,
|
||||
SignupResponse,
|
||||
VerifyStatusResponse,
|
||||
ResendVerificationResponse,
|
||||
} from './domain/auth.types';
|
||||
|
||||
// Internal: Register routes with Fastify app
|
||||
export { authRoutes } from './api/auth.routes';
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @ai-summary Integration tests for auth API endpoints
|
||||
* @ai-context Tests complete request/response cycle with mocked Auth0
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import { buildApp } from '../../../../app';
|
||||
import { pool } from '../../../../core/config/database';
|
||||
import { auth0ManagementClient } from '../../../../core/auth/auth0-management.client';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
|
||||
// Mock Auth0 Management client
|
||||
jest.mock('../../../../core/auth/auth0-management.client');
|
||||
const mockAuth0Client = jest.mocked(auth0ManagementClient);
|
||||
|
||||
// Mock auth plugin for protected routes
|
||||
jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||
return {
|
||||
default: fastifyPlugin(async function (fastify) {
|
||||
fastify.decorate('authenticate', async function (request, _reply) {
|
||||
request.user = { sub: 'auth0|test-user-123' };
|
||||
});
|
||||
}, { name: 'auth-plugin' }),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Auth Integration Tests', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
// Ensure user_profiles table exists (should be created by migrations)
|
||||
// We don't need to run migration here as it should already exist
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Clean up test data before each test
|
||||
await pool.query('DELETE FROM user_profiles WHERE auth0_sub = $1', [
|
||||
'auth0|test-user-123',
|
||||
]);
|
||||
await pool.query('DELETE FROM user_profiles WHERE email = $1', [
|
||||
'newuser@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('POST /api/auth/signup', () => {
|
||||
it('should create a new user successfully', async () => {
|
||||
const email = 'newuser@example.com';
|
||||
const password = 'Password123';
|
||||
const auth0UserId = 'auth0|new-user-456';
|
||||
|
||||
mockAuth0Client.createUser.mockResolvedValue(auth0UserId);
|
||||
|
||||
const response = await request(app.server)
|
||||
.post('/api/auth/signup')
|
||||
.send({ email, password })
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
userId: auth0UserId,
|
||||
email,
|
||||
message: expect.stringContaining('check your email'),
|
||||
});
|
||||
|
||||
expect(mockAuth0Client.createUser).toHaveBeenCalledWith({ email, password });
|
||||
|
||||
// Verify user was created in local database
|
||||
const userResult = await pool.query(
|
||||
'SELECT * FROM user_profiles WHERE auth0_sub = $1',
|
||||
[auth0UserId]
|
||||
);
|
||||
expect(userResult.rows).toHaveLength(1);
|
||||
expect(userResult.rows[0].email).toBe(email);
|
||||
expect(userResult.rows[0].email_verified).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject signup with invalid email', async () => {
|
||||
const response = await request(app.server)
|
||||
.post('/api/auth/signup')
|
||||
.send({ email: 'invalid-email', password: 'Password123' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toContain('validation');
|
||||
});
|
||||
|
||||
it('should reject signup with weak password', async () => {
|
||||
const response = await request(app.server)
|
||||
.post('/api/auth/signup')
|
||||
.send({ email: 'test@example.com', password: 'weak' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toContain('validation');
|
||||
});
|
||||
|
||||
it('should reject signup when email already exists', async () => {
|
||||
const email = 'existing@example.com';
|
||||
const password = 'Password123';
|
||||
|
||||
mockAuth0Client.createUser.mockRejectedValue(
|
||||
new Error('User already exists')
|
||||
);
|
||||
|
||||
const response = await request(app.server)
|
||||
.post('/api/auth/signup')
|
||||
.send({ email, password })
|
||||
.expect(409);
|
||||
|
||||
expect(response.body.error).toBe('Conflict');
|
||||
expect(response.body.message).toContain('already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/verify-status', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test user profile
|
||||
await pool.query(
|
||||
'INSERT INTO user_profiles (auth0_sub, email, email_verified) VALUES ($1, $2, $3)',
|
||||
['auth0|test-user-123', 'test@example.com', false]
|
||||
);
|
||||
});
|
||||
|
||||
it('should return verification status', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
mockAuth0Client.getUser.mockResolvedValue({
|
||||
userId: 'auth0|test-user-123',
|
||||
email,
|
||||
emailVerified: false,
|
||||
});
|
||||
|
||||
const response = await request(app.server)
|
||||
.get('/api/auth/verify-status')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
emailVerified: false,
|
||||
email,
|
||||
});
|
||||
|
||||
expect(mockAuth0Client.getUser).toHaveBeenCalledWith('auth0|test-user-123');
|
||||
});
|
||||
|
||||
it('should update local database when verification status changes', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
mockAuth0Client.getUser.mockResolvedValue({
|
||||
userId: 'auth0|test-user-123',
|
||||
email,
|
||||
emailVerified: true,
|
||||
});
|
||||
|
||||
const response = await request(app.server)
|
||||
.get('/api/auth/verify-status')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.emailVerified).toBe(true);
|
||||
|
||||
// Verify local database was updated
|
||||
const userResult = await pool.query(
|
||||
'SELECT email_verified FROM user_profiles WHERE auth0_sub = $1',
|
||||
['auth0|test-user-123']
|
||||
);
|
||||
expect(userResult.rows[0].email_verified).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/resend-verification', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test user profile
|
||||
await pool.query(
|
||||
'INSERT INTO user_profiles (auth0_sub, email, email_verified) VALUES ($1, $2, $3)',
|
||||
['auth0|test-user-123', 'test@example.com', false]
|
||||
);
|
||||
});
|
||||
|
||||
it('should resend verification email when user is not verified', async () => {
|
||||
mockAuth0Client.checkEmailVerified.mockResolvedValue(false);
|
||||
mockAuth0Client.resendVerificationEmail.mockResolvedValue(undefined);
|
||||
|
||||
const response = await request(app.server)
|
||||
.post('/api/auth/resend-verification')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.message).toContain('Verification email sent');
|
||||
expect(mockAuth0Client.checkEmailVerified).toHaveBeenCalledWith(
|
||||
'auth0|test-user-123'
|
||||
);
|
||||
expect(mockAuth0Client.resendVerificationEmail).toHaveBeenCalledWith(
|
||||
'auth0|test-user-123'
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip sending email when user is already verified', async () => {
|
||||
mockAuth0Client.checkEmailVerified.mockResolvedValue(true);
|
||||
|
||||
const response = await request(app.server)
|
||||
.post('/api/auth/resend-verification')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.message).toContain('already verified');
|
||||
expect(mockAuth0Client.resendVerificationEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
205
backend/src/features/auth/tests/unit/auth.service.test.ts
Normal file
205
backend/src/features/auth/tests/unit/auth.service.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for AuthService
|
||||
* @ai-context Tests business logic with mocked dependencies
|
||||
*/
|
||||
|
||||
import { AuthService } from '../../domain/auth.service';
|
||||
import { UserProfileRepository } from '../../../user-profile/data/user-profile.repository';
|
||||
import { auth0ManagementClient } from '../../../../core/auth/auth0-management.client';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../user-profile/data/user-profile.repository');
|
||||
jest.mock('../../../../core/auth/auth0-management.client');
|
||||
|
||||
const mockUserProfileRepository = jest.mocked(UserProfileRepository);
|
||||
const mockAuth0Client = jest.mocked(auth0ManagementClient);
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let repositoryInstance: jest.Mocked<UserProfileRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
repositoryInstance = {
|
||||
create: jest.fn(),
|
||||
getByAuth0Sub: jest.fn(),
|
||||
updateEmailVerified: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockUserProfileRepository.mockImplementation(() => repositoryInstance);
|
||||
service = new AuthService(repositoryInstance);
|
||||
});
|
||||
|
||||
describe('signup', () => {
|
||||
it('creates user in Auth0 and local database successfully', async () => {
|
||||
const email = 'test@example.com';
|
||||
const password = 'Password123';
|
||||
const auth0UserId = 'auth0|123456';
|
||||
|
||||
mockAuth0Client.createUser.mockResolvedValue(auth0UserId);
|
||||
repositoryInstance.create.mockResolvedValue({
|
||||
id: 'user-uuid',
|
||||
auth0Sub: auth0UserId,
|
||||
email,
|
||||
subscriptionTier: 'free',
|
||||
emailVerified: false,
|
||||
onboardingCompletedAt: null,
|
||||
deactivatedAt: null,
|
||||
deactivatedBy: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.signup({ email, password });
|
||||
|
||||
expect(mockAuth0Client.createUser).toHaveBeenCalledWith({ email, password });
|
||||
expect(repositoryInstance.create).toHaveBeenCalledWith(auth0UserId, email, undefined);
|
||||
expect(result).toEqual({
|
||||
userId: auth0UserId,
|
||||
email,
|
||||
message: 'Account created successfully. Please check your email to verify your account.',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws error when email already exists in Auth0', async () => {
|
||||
const email = 'existing@example.com';
|
||||
const password = 'Password123';
|
||||
|
||||
mockAuth0Client.createUser.mockRejectedValue(
|
||||
new Error('User already exists')
|
||||
);
|
||||
|
||||
await expect(service.signup({ email, password })).rejects.toThrow('Email already exists');
|
||||
});
|
||||
|
||||
it('propagates other Auth0 errors', async () => {
|
||||
const email = 'test@example.com';
|
||||
const password = 'Password123';
|
||||
|
||||
mockAuth0Client.createUser.mockRejectedValue(new Error('Auth0 API error'));
|
||||
|
||||
await expect(service.signup({ email, password })).rejects.toThrow('Auth0 API error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVerifyStatus', () => {
|
||||
it('returns verification status from Auth0 and updates local database', async () => {
|
||||
const auth0Sub = 'auth0|123456';
|
||||
const email = 'test@example.com';
|
||||
|
||||
mockAuth0Client.getUser.mockResolvedValue({
|
||||
userId: auth0Sub,
|
||||
email,
|
||||
emailVerified: true,
|
||||
});
|
||||
|
||||
repositoryInstance.getByAuth0Sub.mockResolvedValue({
|
||||
id: 'user-uuid',
|
||||
auth0Sub,
|
||||
email,
|
||||
subscriptionTier: 'free',
|
||||
emailVerified: false,
|
||||
onboardingCompletedAt: null,
|
||||
deactivatedAt: null,
|
||||
deactivatedBy: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
repositoryInstance.updateEmailVerified.mockResolvedValue({
|
||||
id: 'user-uuid',
|
||||
auth0Sub,
|
||||
email,
|
||||
subscriptionTier: 'free',
|
||||
emailVerified: true,
|
||||
onboardingCompletedAt: null,
|
||||
deactivatedAt: null,
|
||||
deactivatedBy: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.getVerifyStatus(auth0Sub);
|
||||
|
||||
expect(mockAuth0Client.getUser).toHaveBeenCalledWith(auth0Sub);
|
||||
expect(repositoryInstance.updateEmailVerified).toHaveBeenCalledWith(auth0Sub, true);
|
||||
expect(result).toEqual({
|
||||
emailVerified: true,
|
||||
email,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update local database if verification status unchanged', async () => {
|
||||
const auth0Sub = 'auth0|123456';
|
||||
const email = 'test@example.com';
|
||||
|
||||
mockAuth0Client.getUser.mockResolvedValue({
|
||||
userId: auth0Sub,
|
||||
email,
|
||||
emailVerified: false,
|
||||
});
|
||||
|
||||
repositoryInstance.getByAuth0Sub.mockResolvedValue({
|
||||
id: 'user-uuid',
|
||||
auth0Sub,
|
||||
email,
|
||||
subscriptionTier: 'free',
|
||||
emailVerified: false,
|
||||
onboardingCompletedAt: null,
|
||||
deactivatedAt: null,
|
||||
deactivatedBy: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.getVerifyStatus(auth0Sub);
|
||||
|
||||
expect(mockAuth0Client.getUser).toHaveBeenCalledWith(auth0Sub);
|
||||
expect(repositoryInstance.updateEmailVerified).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
emailVerified: false,
|
||||
email,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resendVerification', () => {
|
||||
it('sends verification email when user is not verified', async () => {
|
||||
const auth0Sub = 'auth0|123456';
|
||||
|
||||
mockAuth0Client.checkEmailVerified.mockResolvedValue(false);
|
||||
mockAuth0Client.resendVerificationEmail.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.resendVerification(auth0Sub);
|
||||
|
||||
expect(mockAuth0Client.checkEmailVerified).toHaveBeenCalledWith(auth0Sub);
|
||||
expect(mockAuth0Client.resendVerificationEmail).toHaveBeenCalledWith(auth0Sub);
|
||||
expect(result).toEqual({
|
||||
message: 'Verification email sent. Please check your inbox.',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips sending email when user is already verified', async () => {
|
||||
const auth0Sub = 'auth0|123456';
|
||||
|
||||
mockAuth0Client.checkEmailVerified.mockResolvedValue(true);
|
||||
|
||||
const result = await service.resendVerification(auth0Sub);
|
||||
|
||||
expect(mockAuth0Client.checkEmailVerified).toHaveBeenCalledWith(auth0Sub);
|
||||
expect(mockAuth0Client.resendVerificationEmail).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
message: 'Email is already verified',
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates Auth0 errors', async () => {
|
||||
const auth0Sub = 'auth0|123456';
|
||||
|
||||
mockAuth0Client.checkEmailVerified.mockRejectedValue(new Error('Auth0 API error'));
|
||||
|
||||
await expect(service.resendVerification(auth0Sub)).rejects.toThrow('Auth0 API error');
|
||||
});
|
||||
});
|
||||
});
|
||||
186
backend/src/features/onboarding/README.md
Normal file
186
backend/src/features/onboarding/README.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Onboarding Feature Module
|
||||
|
||||
## Overview
|
||||
The onboarding feature manages the user signup workflow after email verification. It handles user preference setup (unit system, currency, timezone) and tracks onboarding completion status.
|
||||
|
||||
## Purpose
|
||||
After a user verifies their email with Auth0, they go through an onboarding flow to:
|
||||
1. Set their preferences (unit system, currency, timezone)
|
||||
2. Optionally add their first vehicle (handled by vehicles feature)
|
||||
3. Mark onboarding as complete
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints require JWT authentication via `fastify.authenticate` preHandler.
|
||||
|
||||
### POST `/api/onboarding/preferences`
|
||||
Save user preferences during onboarding.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"unitSystem": "imperial",
|
||||
"currencyCode": "USD",
|
||||
"timeZone": "America/New_York"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `unitSystem`: Must be either "imperial" or "metric"
|
||||
- `currencyCode`: Must be exactly 3 uppercase letters (ISO 4217)
|
||||
- `timeZone`: 1-100 characters (IANA timezone identifier)
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"preferences": {
|
||||
"unitSystem": "imperial",
|
||||
"currencyCode": "USD",
|
||||
"timeZone": "America/New_York"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `404 Not Found` - User profile not found
|
||||
- `500 Internal Server Error` - Failed to save preferences
|
||||
|
||||
### POST `/api/onboarding/complete`
|
||||
Mark onboarding as complete for the authenticated user.
|
||||
|
||||
**Request Body:** None
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"completedAt": "2025-12-22T15:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `404 Not Found` - User profile not found
|
||||
- `500 Internal Server Error` - Failed to complete onboarding
|
||||
|
||||
### GET `/api/onboarding/status`
|
||||
Get current onboarding status for the authenticated user.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"preferencesSet": true,
|
||||
"onboardingCompleted": true,
|
||||
"onboardingCompletedAt": "2025-12-22T15:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200) - Incomplete:**
|
||||
```json
|
||||
{
|
||||
"preferencesSet": false,
|
||||
"onboardingCompleted": false,
|
||||
"onboardingCompletedAt": null
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `404 Not Found` - User profile not found
|
||||
- `500 Internal Server Error` - Failed to get status
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
backend/src/features/onboarding/
|
||||
├── README.md # This file
|
||||
├── index.ts # Public API exports
|
||||
├── api/ # HTTP layer
|
||||
│ ├── onboarding.controller.ts # Request handlers
|
||||
│ ├── onboarding.routes.ts # Route definitions
|
||||
│ └── onboarding.validation.ts # Zod schemas
|
||||
├── domain/ # Business logic
|
||||
│ ├── onboarding.service.ts # Core logic
|
||||
│ └── onboarding.types.ts # Type definitions
|
||||
└── tests/ # Tests (to be added)
|
||||
├── unit/
|
||||
└── integration/
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Dependencies
|
||||
- **UserProfileRepository** (`features/user-profile`) - For updating `onboarding_completed_at`
|
||||
- **UserPreferencesRepository** (`core/user-preferences`) - For saving unit system, currency, timezone
|
||||
- **Database Pool** (`core/config/database`) - PostgreSQL connection
|
||||
- **Logger** (`core/logging/logger`) - Structured logging
|
||||
|
||||
### Database Tables
|
||||
- `user_profiles` - Stores `onboarding_completed_at` timestamp
|
||||
- `user_preferences` - Stores `unit_system`, `currency_code`, `time_zone`
|
||||
|
||||
## Business Logic
|
||||
|
||||
### Save Preferences Flow
|
||||
1. Extract Auth0 user ID from JWT
|
||||
2. Fetch user profile to get internal user ID
|
||||
3. Check if user_preferences record exists
|
||||
4. If exists, update preferences; otherwise create new record
|
||||
5. Return saved preferences
|
||||
|
||||
### Complete Onboarding Flow
|
||||
1. Extract Auth0 user ID from JWT
|
||||
2. Call `UserProfileRepository.markOnboardingComplete(auth0Sub)`
|
||||
3. Updates `onboarding_completed_at` to NOW() if not already set
|
||||
4. Return completion timestamp
|
||||
|
||||
### Get Status Flow
|
||||
1. Extract Auth0 user ID from JWT
|
||||
2. Fetch user profile
|
||||
3. Check if user_preferences record exists
|
||||
4. Return status object with:
|
||||
- `preferencesSet`: boolean (preferences exist)
|
||||
- `onboardingCompleted`: boolean (onboarding_completed_at is set)
|
||||
- `onboardingCompletedAt`: timestamp or null
|
||||
|
||||
## Key Behaviors
|
||||
|
||||
### User Ownership
|
||||
All operations are scoped to the authenticated user via JWT. No cross-user access is possible.
|
||||
|
||||
### Idempotency
|
||||
- `savePreferences` - Can be called multiple times; updates existing record
|
||||
- `completeOnboarding` - Can be called multiple times; returns existing completion timestamp if already completed
|
||||
|
||||
### Email Verification
|
||||
These routes are in `VERIFICATION_EXEMPT_ROUTES` in `auth.plugin.ts` because users complete onboarding immediately after email verification.
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors are logged with structured logging:
|
||||
```typescript
|
||||
logger.error('Error saving onboarding preferences', {
|
||||
error,
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
});
|
||||
```
|
||||
|
||||
Controller methods return appropriate HTTP status codes:
|
||||
- `200 OK` - Success
|
||||
- `404 Not Found` - User profile not found
|
||||
- `500 Internal Server Error` - Unexpected errors
|
||||
|
||||
## Testing
|
||||
|
||||
Tests to be added:
|
||||
- Unit tests for `OnboardingService` with mocked repositories
|
||||
- Integration tests for API endpoints with test database
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- Add validation for IANA timezone identifiers
|
||||
- Add validation for ISO 4217 currency codes against known list
|
||||
- Track onboarding step completion for analytics
|
||||
- Add onboarding progress percentage
|
||||
- Support for skipping onboarding (mark as complete without preferences)
|
||||
143
backend/src/features/onboarding/api/onboarding.controller.ts
Normal file
143
backend/src/features/onboarding/api/onboarding.controller.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @ai-summary Fastify route handlers for onboarding API
|
||||
* @ai-context HTTP request/response handling for onboarding workflow
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { OnboardingService } from '../domain/onboarding.service';
|
||||
import { UserPreferencesRepository } from '../../../core/user-preferences/data/user-preferences.repository';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { SavePreferencesInput } from './onboarding.validation';
|
||||
|
||||
interface AuthenticatedRequest extends FastifyRequest {
|
||||
user: {
|
||||
sub: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export class OnboardingController {
|
||||
private onboardingService: OnboardingService;
|
||||
|
||||
constructor() {
|
||||
const userPreferencesRepo = new UserPreferencesRepository(pool);
|
||||
const userProfileRepo = new UserProfileRepository(pool);
|
||||
this.onboardingService = new OnboardingService(userPreferencesRepo, userProfileRepo);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/onboarding/preferences
|
||||
* Save user preferences during onboarding
|
||||
*/
|
||||
async savePreferences(
|
||||
request: FastifyRequest<{ Body: SavePreferencesInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const auth0Sub = (request as AuthenticatedRequest).user.sub;
|
||||
|
||||
const preferences = await this.onboardingService.savePreferences(
|
||||
auth0Sub,
|
||||
request.body
|
||||
);
|
||||
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
preferences,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error in savePreferences controller', {
|
||||
error,
|
||||
userId: (request as AuthenticatedRequest).user?.sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User profile not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'User profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to save preferences',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/onboarding/complete
|
||||
* Mark onboarding as complete
|
||||
*/
|
||||
async completeOnboarding(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const auth0Sub = (request as AuthenticatedRequest).user.sub;
|
||||
|
||||
const completedAt = await this.onboardingService.completeOnboarding(auth0Sub);
|
||||
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
completedAt: completedAt.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error in completeOnboarding controller', {
|
||||
error,
|
||||
userId: (request as AuthenticatedRequest).user?.sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User profile not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'User profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to complete onboarding',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/onboarding/status
|
||||
* Get current onboarding status
|
||||
*/
|
||||
async getStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const auth0Sub = (request as AuthenticatedRequest).user.sub;
|
||||
|
||||
const status = await this.onboardingService.getOnboardingStatus(auth0Sub);
|
||||
|
||||
return reply.code(200).send({
|
||||
preferencesSet: status.preferencesSet,
|
||||
onboardingCompleted: status.onboardingCompleted,
|
||||
onboardingCompletedAt: status.onboardingCompletedAt
|
||||
? status.onboardingCompletedAt.toISOString()
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error in getStatus controller', {
|
||||
error,
|
||||
userId: (request as AuthenticatedRequest).user?.sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User profile not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'User profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get onboarding status',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
33
backend/src/features/onboarding/api/onboarding.routes.ts
Normal file
33
backend/src/features/onboarding/api/onboarding.routes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @ai-summary Fastify routes for onboarding API
|
||||
* @ai-context Route definitions for user onboarding workflow
|
||||
*/
|
||||
|
||||
import { FastifyInstance, FastifyPluginOptions, FastifyPluginAsync } from 'fastify';
|
||||
import { OnboardingController } from './onboarding.controller';
|
||||
import { SavePreferencesInput } from './onboarding.validation';
|
||||
|
||||
export const onboardingRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
_opts: FastifyPluginOptions
|
||||
) => {
|
||||
const onboardingController = new OnboardingController();
|
||||
|
||||
// POST /api/onboarding/preferences - Save user preferences
|
||||
fastify.post<{ Body: SavePreferencesInput }>('/onboarding/preferences', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: onboardingController.savePreferences.bind(onboardingController),
|
||||
});
|
||||
|
||||
// POST /api/onboarding/complete - Mark onboarding as complete
|
||||
fastify.post('/onboarding/complete', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: onboardingController.completeOnboarding.bind(onboardingController),
|
||||
});
|
||||
|
||||
// GET /api/onboarding/status - Get onboarding status
|
||||
fastify.get('/onboarding/status', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: onboardingController.getStatus.bind(onboardingController),
|
||||
});
|
||||
};
|
||||
21
backend/src/features/onboarding/api/onboarding.validation.ts
Normal file
21
backend/src/features/onboarding/api/onboarding.validation.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @ai-summary Request validation schemas for onboarding API
|
||||
* @ai-context Uses Zod for runtime validation and type safety
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const savePreferencesSchema = z.object({
|
||||
unitSystem: z.enum(['imperial', 'metric'], {
|
||||
errorMap: () => ({ message: 'Unit system must be either "imperial" or "metric"' })
|
||||
}),
|
||||
currencyCode: z.string()
|
||||
.length(3, 'Currency code must be exactly 3 characters (ISO 4217)')
|
||||
.toUpperCase()
|
||||
.regex(/^[A-Z]{3}$/, 'Currency code must be 3 uppercase letters'),
|
||||
timeZone: z.string()
|
||||
.min(1, 'Time zone is required')
|
||||
.max(100, 'Time zone must not exceed 100 characters')
|
||||
}).strict();
|
||||
|
||||
export type SavePreferencesInput = z.infer<typeof savePreferencesSchema>;
|
||||
155
backend/src/features/onboarding/domain/onboarding.service.ts
Normal file
155
backend/src/features/onboarding/domain/onboarding.service.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @ai-summary Business logic for user onboarding workflow
|
||||
* @ai-context Coordinates user preferences and profile updates during onboarding
|
||||
*/
|
||||
|
||||
import { UserPreferencesRepository } from '../../../core/user-preferences/data/user-preferences.repository';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
OnboardingPreferences,
|
||||
OnboardingStatus,
|
||||
SavePreferencesRequest,
|
||||
} from './onboarding.types';
|
||||
|
||||
export class OnboardingService {
|
||||
constructor(
|
||||
private userPreferencesRepo: UserPreferencesRepository,
|
||||
private userProfileRepo: UserProfileRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Save user preferences during onboarding
|
||||
*/
|
||||
async savePreferences(
|
||||
auth0Sub: string,
|
||||
request: SavePreferencesRequest
|
||||
): Promise<OnboardingPreferences> {
|
||||
try {
|
||||
// Validate required fields (should be guaranteed by Zod validation)
|
||||
if (!request.unitSystem || !request.currencyCode || !request.timeZone) {
|
||||
throw new Error('Missing required fields: unitSystem, currencyCode, timeZone');
|
||||
}
|
||||
|
||||
logger.info('Saving onboarding preferences', {
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
unitSystem: request.unitSystem,
|
||||
currencyCode: request.currencyCode,
|
||||
timeZone: request.timeZone,
|
||||
});
|
||||
|
||||
// Get user profile to get internal user ID
|
||||
const profile = await this.userProfileRepo.getByAuth0Sub(auth0Sub);
|
||||
if (!profile) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
|
||||
// Check if preferences already exist
|
||||
const existingPrefs = await this.userPreferencesRepo.findByUserId(profile.id);
|
||||
|
||||
let savedPrefs;
|
||||
if (existingPrefs) {
|
||||
// Update existing preferences
|
||||
savedPrefs = await this.userPreferencesRepo.update(profile.id, {
|
||||
unitSystem: request.unitSystem,
|
||||
currencyCode: request.currencyCode,
|
||||
timeZone: request.timeZone,
|
||||
});
|
||||
} else {
|
||||
// Create new preferences
|
||||
savedPrefs = await this.userPreferencesRepo.create({
|
||||
userId: profile.id,
|
||||
unitSystem: request.unitSystem,
|
||||
currencyCode: request.currencyCode,
|
||||
timeZone: request.timeZone,
|
||||
});
|
||||
}
|
||||
|
||||
if (!savedPrefs) {
|
||||
throw new Error('Failed to save user preferences');
|
||||
}
|
||||
|
||||
logger.info('Onboarding preferences saved successfully', {
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
preferencesId: savedPrefs.id,
|
||||
});
|
||||
|
||||
return {
|
||||
unitSystem: savedPrefs.unitSystem,
|
||||
currencyCode: savedPrefs.currencyCode,
|
||||
timeZone: savedPrefs.timeZone,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error saving onboarding preferences', {
|
||||
error,
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark onboarding as complete
|
||||
*/
|
||||
async completeOnboarding(auth0Sub: string): Promise<Date> {
|
||||
try {
|
||||
logger.info('Completing onboarding', {
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
});
|
||||
|
||||
const profile = await this.userProfileRepo.markOnboardingComplete(auth0Sub);
|
||||
|
||||
logger.info('Onboarding completed successfully', {
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
completedAt: profile.onboardingCompletedAt,
|
||||
});
|
||||
|
||||
return profile.onboardingCompletedAt!;
|
||||
} catch (error) {
|
||||
logger.error('Error completing onboarding', {
|
||||
error,
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current onboarding status
|
||||
*/
|
||||
async getOnboardingStatus(auth0Sub: string): Promise<OnboardingStatus> {
|
||||
try {
|
||||
logger.info('Getting onboarding status', {
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
});
|
||||
|
||||
// Get user profile
|
||||
const profile = await this.userProfileRepo.getByAuth0Sub(auth0Sub);
|
||||
if (!profile) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
|
||||
// Check if preferences exist
|
||||
const preferences = await this.userPreferencesRepo.findByUserId(profile.id);
|
||||
|
||||
const status: OnboardingStatus = {
|
||||
preferencesSet: !!preferences,
|
||||
onboardingCompleted: !!profile.onboardingCompletedAt,
|
||||
onboardingCompletedAt: profile.onboardingCompletedAt,
|
||||
};
|
||||
|
||||
logger.info('Retrieved onboarding status', {
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
status,
|
||||
});
|
||||
|
||||
return status;
|
||||
} catch (error) {
|
||||
logger.error('Error getting onboarding status', {
|
||||
error,
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
backend/src/features/onboarding/domain/onboarding.types.ts
Normal file
40
backend/src/features/onboarding/domain/onboarding.types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for onboarding feature
|
||||
* @ai-context Manages user onboarding flow, preferences, and completion status
|
||||
*/
|
||||
|
||||
export type UnitSystem = 'imperial' | 'metric';
|
||||
|
||||
export interface OnboardingPreferences {
|
||||
unitSystem: UnitSystem;
|
||||
currencyCode: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
export interface OnboardingStatus {
|
||||
preferencesSet: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
onboardingCompletedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface SavePreferencesRequest {
|
||||
unitSystem?: UnitSystem;
|
||||
currencyCode?: string;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export interface SavePreferencesResponse {
|
||||
success: boolean;
|
||||
preferences: OnboardingPreferences;
|
||||
}
|
||||
|
||||
export interface CompleteOnboardingResponse {
|
||||
success: boolean;
|
||||
completedAt: Date;
|
||||
}
|
||||
|
||||
export interface OnboardingStatusResponse {
|
||||
preferencesSet: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
onboardingCompletedAt: string | null;
|
||||
}
|
||||
20
backend/src/features/onboarding/index.ts
Normal file
20
backend/src/features/onboarding/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @ai-summary Public API for onboarding feature capsule
|
||||
* @ai-context This is the ONLY file other features should import from
|
||||
*/
|
||||
|
||||
// Export service for use by other features
|
||||
export { OnboardingService } from './domain/onboarding.service';
|
||||
|
||||
// Export types needed by other features
|
||||
export type {
|
||||
OnboardingPreferences,
|
||||
OnboardingStatus,
|
||||
SavePreferencesRequest,
|
||||
SavePreferencesResponse,
|
||||
CompleteOnboardingResponse,
|
||||
OnboardingStatusResponse,
|
||||
} from './domain/onboarding.types';
|
||||
|
||||
// Internal: Register routes with Fastify app
|
||||
export { onboardingRoutes } from './api/onboarding.routes';
|
||||
@@ -6,16 +6,24 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { UserProfileService } from '../domain/user-profile.service';
|
||||
import { UserProfileRepository } from '../data/user-profile.repository';
|
||||
import { AdminRepository } from '../../admin/data/admin.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { UpdateProfileInput, updateProfileSchema } from './user-profile.validation';
|
||||
import {
|
||||
UpdateProfileInput,
|
||||
updateProfileSchema,
|
||||
RequestDeletionInput,
|
||||
requestDeletionSchema,
|
||||
} from './user-profile.validation';
|
||||
|
||||
export class UserProfileController {
|
||||
private userProfileService: UserProfileService;
|
||||
|
||||
constructor() {
|
||||
const repository = new UserProfileRepository(pool);
|
||||
const adminRepository = new AdminRepository(pool);
|
||||
this.userProfileService = new UserProfileService(repository);
|
||||
this.userProfileService.setAdminRepository(adminRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,4 +129,178 @@ export class UserProfileController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/delete - Request account deletion
|
||||
*/
|
||||
async requestDeletion(
|
||||
request: FastifyRequest<{ Body: RequestDeletionInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const auth0Sub = request.userContext?.userId;
|
||||
|
||||
if (!auth0Sub) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const validation = requestDeletionSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: validation.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { password, confirmationText } = validation.data;
|
||||
|
||||
// Request deletion
|
||||
const profile = await this.userProfileService.requestDeletion(
|
||||
auth0Sub,
|
||||
password,
|
||||
confirmationText
|
||||
);
|
||||
|
||||
const deletionStatus = this.userProfileService.getDeletionStatus(profile);
|
||||
|
||||
return reply.code(200).send({
|
||||
message: 'Account deletion requested successfully',
|
||||
deletionStatus,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Error requesting account deletion', {
|
||||
error: error.message,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
if (error.message.includes('Invalid password')) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid password',
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('Invalid confirmation')) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Confirmation text must be exactly "DELETE"',
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('already requested')) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Account deletion already requested',
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'User profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to request account deletion',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/cancel-deletion - Cancel account deletion
|
||||
*/
|
||||
async cancelDeletion(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const auth0Sub = request.userContext?.userId;
|
||||
|
||||
if (!auth0Sub) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel deletion
|
||||
const profile = await this.userProfileService.cancelDeletion(auth0Sub);
|
||||
|
||||
return reply.code(200).send({
|
||||
message: 'Account deletion canceled successfully',
|
||||
profile,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Error canceling account deletion', {
|
||||
error: error.message,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
if (error.message.includes('no deletion request')) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'No deletion request pending',
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'User profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to cancel account deletion',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/user/deletion-status - Get deletion status
|
||||
*/
|
||||
async getDeletionStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const auth0Sub = request.userContext?.userId;
|
||||
|
||||
if (!auth0Sub) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Get user data from Auth0 token
|
||||
const auth0User = {
|
||||
sub: auth0Sub,
|
||||
email: (request as any).user?.email || request.userContext?.email || '',
|
||||
name: (request as any).user?.name,
|
||||
};
|
||||
|
||||
// Get or create profile
|
||||
const profile = await this.userProfileService.getOrCreateProfile(
|
||||
auth0Sub,
|
||||
auth0User
|
||||
);
|
||||
|
||||
const deletionStatus = this.userProfileService.getDeletionStatus(profile);
|
||||
|
||||
return reply.code(200).send(deletionStatus);
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting deletion status', {
|
||||
error: error.message,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get deletion status',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { UserProfileController } from './user-profile.controller';
|
||||
import { UpdateProfileInput } from './user-profile.validation';
|
||||
import { UpdateProfileInput, RequestDeletionInput } from './user-profile.validation';
|
||||
|
||||
export const userProfileRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const userProfileController = new UserProfileController();
|
||||
@@ -21,4 +21,22 @@ export const userProfileRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: userProfileController.updateProfile.bind(userProfileController),
|
||||
});
|
||||
|
||||
// POST /api/user/delete - Request account deletion
|
||||
fastify.post<{ Body: RequestDeletionInput }>('/user/delete', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: userProfileController.requestDeletion.bind(userProfileController),
|
||||
});
|
||||
|
||||
// POST /api/user/cancel-deletion - Cancel account deletion
|
||||
fastify.post('/user/cancel-deletion', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: userProfileController.cancelDeletion.bind(userProfileController),
|
||||
});
|
||||
|
||||
// GET /api/user/deletion-status - Get deletion status
|
||||
fastify.get('/user/deletion-status', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: userProfileController.getDeletionStatus.bind(userProfileController),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,3 +16,12 @@ export const updateProfileSchema = z.object({
|
||||
);
|
||||
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||
|
||||
export const requestDeletionSchema = z.object({
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
confirmationText: z.string().refine((val) => val === 'DELETE', {
|
||||
message: 'Confirmation text must be exactly "DELETE"',
|
||||
}),
|
||||
});
|
||||
|
||||
export type RequestDeletionInput = z.infer<typeof requestDeletionSchema>;
|
||||
|
||||
@@ -16,7 +16,8 @@ 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,
|
||||
subscription_tier, email_verified, onboarding_completed_at,
|
||||
deactivated_at, deactivated_by, deletion_requested_at, deletion_scheduled_for,
|
||||
created_at, updated_at
|
||||
`;
|
||||
|
||||
@@ -139,8 +140,12 @@ export class UserProfileRepository {
|
||||
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),
|
||||
};
|
||||
@@ -213,8 +218,8 @@ export class UserProfileRepository {
|
||||
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,
|
||||
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
|
||||
FROM user_profiles up
|
||||
@@ -247,8 +252,8 @@ export class UserProfileRepository {
|
||||
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,
|
||||
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
|
||||
FROM user_profiles up
|
||||
@@ -388,4 +393,221 @@ export class UserProfileRepository {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update email verification status
|
||||
*/
|
||||
async updateEmailVerified(auth0Sub: string, emailVerified: boolean): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET email_verified = $1, updated_at = NOW()
|
||||
WHERE auth0_sub = $2
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [emailVerified, 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 email verified status', { error, auth0Sub, emailVerified });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark onboarding as complete
|
||||
*/
|
||||
async markOnboardingComplete(auth0Sub: string): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET onboarding_completed_at = NOW(), updated_at = NOW()
|
||||
WHERE auth0_sub = $1 AND onboarding_completed_at IS NULL
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
if (result.rows.length === 0) {
|
||||
// Check if already completed or profile not found
|
||||
const existing = await this.getByAuth0Sub(auth0Sub);
|
||||
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, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user email (used when fetching correct email from Auth0)
|
||||
*/
|
||||
async updateEmail(auth0Sub: string, email: string): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET email = $1, updated_at = NOW()
|
||||
WHERE auth0_sub = $2
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [email, 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 user email', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request account deletion (sets deletion timestamps and deactivates account)
|
||||
* 30-day grace period before permanent deletion
|
||||
*/
|
||||
async requestDeletion(auth0Sub: 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 auth0_sub = $1 AND deletion_requested_at IS 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 deletion already requested');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error requesting account deletion', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel deletion request (clears deletion timestamps and reactivates account)
|
||||
*/
|
||||
async cancelDeletion(auth0Sub: 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 auth0_sub = $1 AND deletion_requested_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 no deletion request pending');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error canceling account deletion', { error, auth0Sub });
|
||||
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(auth0Sub: 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`,
|
||||
[auth0Sub]
|
||||
);
|
||||
|
||||
// 2. Delete notification logs
|
||||
await client.query(
|
||||
'DELETE FROM notification_logs WHERE user_id = $1',
|
||||
[auth0Sub]
|
||||
);
|
||||
|
||||
// 3. Delete user notifications
|
||||
await client.query(
|
||||
'DELETE FROM user_notifications WHERE user_id = $1',
|
||||
[auth0Sub]
|
||||
);
|
||||
|
||||
// 4. Delete saved stations
|
||||
await client.query(
|
||||
'DELETE FROM saved_stations WHERE user_id = $1',
|
||||
[auth0Sub]
|
||||
);
|
||||
|
||||
// 5. Delete vehicles (cascades to fuel_logs, maintenance_logs, maintenance_schedules, documents)
|
||||
await client.query(
|
||||
'DELETE FROM vehicles WHERE user_id = $1',
|
||||
[auth0Sub]
|
||||
);
|
||||
|
||||
// 6. Delete user preferences
|
||||
await client.query(
|
||||
'DELETE FROM user_preferences WHERE user_id = $1',
|
||||
[auth0Sub]
|
||||
);
|
||||
|
||||
// 7. Delete user profile (final step)
|
||||
await client.query(
|
||||
'DELETE FROM user_profiles WHERE auth0_sub = $1',
|
||||
[auth0Sub]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logger.info('User hard deleted successfully', { auth0Sub });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Error hard deleting user', { error, auth0Sub });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
ListUsersQuery,
|
||||
ListUsersResponse,
|
||||
SubscriptionTier,
|
||||
DeletionStatus,
|
||||
} from './user-profile.types';
|
||||
import { AdminRepository } from '../../admin/data/admin.repository';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { auth0ManagementClient } from '../../../core/auth/auth0-management.client';
|
||||
|
||||
export class UserProfileService {
|
||||
private adminRepository: AdminRepository | null = null;
|
||||
@@ -320,4 +322,178 @@ export class UserProfileService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Account deletion methods (GDPR compliance)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Request account deletion with password verification
|
||||
* Sets 30-day grace period before permanent deletion
|
||||
*/
|
||||
async requestDeletion(
|
||||
auth0Sub: string,
|
||||
password: string,
|
||||
confirmationText: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Validate confirmation text
|
||||
if (confirmationText !== 'DELETE') {
|
||||
throw new Error('Invalid confirmation text');
|
||||
}
|
||||
|
||||
// Get user profile
|
||||
const profile = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
if (!profile) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Check if deletion already requested
|
||||
if (profile.deletionRequestedAt) {
|
||||
throw new Error('Deletion already requested');
|
||||
}
|
||||
|
||||
// Verify password with Auth0
|
||||
const passwordValid = await auth0ManagementClient.verifyPassword(profile.email, password);
|
||||
if (!passwordValid) {
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
|
||||
// Request deletion
|
||||
const updatedProfile = await this.repository.requestDeletion(auth0Sub);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
auth0Sub,
|
||||
'REQUEST_DELETION',
|
||||
auth0Sub,
|
||||
'user_profile',
|
||||
updatedProfile.id,
|
||||
{
|
||||
deletionScheduledFor: updatedProfile.deletionScheduledFor,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Account deletion requested', {
|
||||
auth0Sub,
|
||||
deletionScheduledFor: updatedProfile.deletionScheduledFor,
|
||||
});
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error requesting account deletion', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending deletion request
|
||||
*/
|
||||
async cancelDeletion(auth0Sub: string): Promise<UserProfile> {
|
||||
try {
|
||||
// Cancel deletion
|
||||
const updatedProfile = await this.repository.cancelDeletion(auth0Sub);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
auth0Sub,
|
||||
'CANCEL_DELETION',
|
||||
auth0Sub,
|
||||
'user_profile',
|
||||
updatedProfile.id,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Account deletion canceled', { auth0Sub });
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error canceling account deletion', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deletion status for a user profile
|
||||
*/
|
||||
getDeletionStatus(profile: UserProfile): DeletionStatus {
|
||||
if (!profile.deletionRequestedAt || !profile.deletionScheduledFor) {
|
||||
return {
|
||||
isPendingDeletion: false,
|
||||
deletionRequestedAt: null,
|
||||
deletionScheduledFor: null,
|
||||
daysRemaining: null,
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const scheduledDate = new Date(profile.deletionScheduledFor);
|
||||
const millisecondsRemaining = scheduledDate.getTime() - now.getTime();
|
||||
const daysRemaining = Math.ceil(millisecondsRemaining / (1000 * 60 * 60 * 24));
|
||||
|
||||
return {
|
||||
isPendingDeletion: true,
|
||||
deletionRequestedAt: profile.deletionRequestedAt,
|
||||
deletionScheduledFor: profile.deletionScheduledFor,
|
||||
daysRemaining: Math.max(0, daysRemaining),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin hard delete user (permanent deletion)
|
||||
* Prevents self-delete
|
||||
*/
|
||||
async adminHardDeleteUser(
|
||||
auth0Sub: string,
|
||||
actorAuth0Sub: string,
|
||||
reason?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Prevent self-delete
|
||||
if (auth0Sub === actorAuth0Sub) {
|
||||
throw new Error('Cannot delete your own account');
|
||||
}
|
||||
|
||||
// Get user profile before deletion for audit log
|
||||
const profile = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
if (!profile) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Log to audit trail before deletion
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
'HARD_DELETE_USER',
|
||||
auth0Sub,
|
||||
'user_profile',
|
||||
profile.id,
|
||||
{
|
||||
reason: reason || 'No reason provided',
|
||||
email: profile.email,
|
||||
displayName: profile.displayName,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Hard delete from database
|
||||
await this.repository.hardDeleteUser(auth0Sub);
|
||||
|
||||
// Delete from Auth0
|
||||
await auth0ManagementClient.deleteUser(auth0Sub);
|
||||
|
||||
logger.info('User hard deleted by admin', {
|
||||
auth0Sub,
|
||||
actorAuth0Sub,
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error hard deleting user', { error, auth0Sub, actorAuth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,12 @@ export interface UserProfile {
|
||||
displayName?: string;
|
||||
notificationEmail?: string;
|
||||
subscriptionTier: SubscriptionTier;
|
||||
emailVerified: boolean;
|
||||
onboardingCompletedAt: Date | null;
|
||||
deactivatedAt: Date | null;
|
||||
deactivatedBy: string | null;
|
||||
deletionRequestedAt: Date | null;
|
||||
deletionScheduledFor: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -64,3 +68,17 @@ export interface Auth0UserData {
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// Request to delete user account
|
||||
export interface RequestDeletionRequest {
|
||||
password: string;
|
||||
confirmationText: string;
|
||||
}
|
||||
|
||||
// Deletion status for user account
|
||||
export interface DeletionStatus {
|
||||
isPendingDeletion: boolean;
|
||||
deletionRequestedAt: Date | null;
|
||||
deletionScheduledFor: Date | null;
|
||||
daysRemaining: number | null;
|
||||
}
|
||||
|
||||
87
backend/src/features/user-profile/jobs/account-purge.job.ts
Normal file
87
backend/src/features/user-profile/jobs/account-purge.job.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @ai-summary Account purge job for GDPR-compliant deletion
|
||||
* @ai-context Processes user accounts past their 30-day grace period
|
||||
*/
|
||||
|
||||
import { UserProfileRepository } from '../data/user-profile.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { auth0ManagementClient } from '../../../core/auth/auth0-management.client';
|
||||
|
||||
interface PurgeResult {
|
||||
processed: number;
|
||||
deleted: number;
|
||||
errors: Array<{ auth0Sub: string; error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process account purges for users past the 30-day grace period
|
||||
* This job runs daily at 2 AM
|
||||
*/
|
||||
export async function processAccountPurges(): Promise<PurgeResult> {
|
||||
const repository = new UserProfileRepository(pool);
|
||||
const result: PurgeResult = {
|
||||
processed: 0,
|
||||
deleted: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info('Starting account purge job');
|
||||
|
||||
// Get users past grace period
|
||||
const usersToPurge = await repository.getUsersPastGracePeriod();
|
||||
|
||||
logger.info('Found users to purge', { count: usersToPurge.length });
|
||||
|
||||
result.processed = usersToPurge.length;
|
||||
|
||||
// Process each user
|
||||
for (const user of usersToPurge) {
|
||||
try {
|
||||
logger.info('Purging user account', {
|
||||
auth0Sub: user.auth0Sub,
|
||||
email: user.email,
|
||||
deletionScheduledFor: user.deletionScheduledFor,
|
||||
});
|
||||
|
||||
// Hard delete from database
|
||||
await repository.hardDeleteUser(user.auth0Sub);
|
||||
|
||||
// Delete from Auth0
|
||||
await auth0ManagementClient.deleteUser(user.auth0Sub);
|
||||
|
||||
result.deleted++;
|
||||
|
||||
logger.info('User account purged successfully', {
|
||||
auth0Sub: user.auth0Sub,
|
||||
email: user.email,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Failed to purge user account', {
|
||||
auth0Sub: user.auth0Sub,
|
||||
email: user.email,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
result.errors.push({
|
||||
auth0Sub: user.auth0Sub,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Account purge job completed', {
|
||||
processed: result.processed,
|
||||
deleted: result.deleted,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Account purge job failed', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Migration: 003_add_email_verified_and_onboarding
|
||||
-- Description: Add email verification status and onboarding tracking columns
|
||||
-- Date: 2025-12-22
|
||||
|
||||
-- Add email verification status column
|
||||
ALTER TABLE user_profiles
|
||||
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Add onboarding completion timestamp column
|
||||
ALTER TABLE user_profiles
|
||||
ADD COLUMN IF NOT EXISTS onboarding_completed_at TIMESTAMP WITH TIME ZONE DEFAULT NULL;
|
||||
|
||||
-- Create index for efficient email verification status queries
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profiles_email_verified
|
||||
ON user_profiles(email_verified);
|
||||
|
||||
-- Create index for onboarding status queries
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profiles_onboarding_completed
|
||||
ON user_profiles(onboarding_completed_at);
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN user_profiles.email_verified IS 'Whether the user has verified their email address via Auth0';
|
||||
COMMENT ON COLUMN user_profiles.onboarding_completed_at IS 'Timestamp when user completed the onboarding flow, NULL if not completed';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Migration: Add user account deletion request tracking
|
||||
-- Description: Adds columns to track GDPR-compliant user account deletion requests with 30-day grace period
|
||||
|
||||
-- Add deletion tracking columns to user_profiles table
|
||||
ALTER TABLE user_profiles
|
||||
ADD COLUMN deletion_requested_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
|
||||
ADD COLUMN deletion_scheduled_for TIMESTAMP WITH TIME ZONE DEFAULT NULL;
|
||||
|
||||
-- Create indexes for efficient purge job queries
|
||||
CREATE INDEX idx_user_profiles_deletion_scheduled ON user_profiles(deletion_scheduled_for)
|
||||
WHERE deletion_scheduled_for IS NOT NULL;
|
||||
|
||||
CREATE INDEX idx_user_profiles_deletion_requested ON user_profiles(deletion_requested_at)
|
||||
WHERE deletion_requested_at IS NOT NULL;
|
||||
|
||||
-- Comment on columns for documentation
|
||||
COMMENT ON COLUMN user_profiles.deletion_requested_at IS 'Timestamp when user requested account deletion (GDPR compliance)';
|
||||
COMMENT ON COLUMN user_profiles.deletion_scheduled_for IS 'Timestamp when account will be permanently deleted (30 days after request)';
|
||||
Reference in New Issue
Block a user