feat: delete users - not tested

This commit is contained in:
Eric Gullickson
2025-12-22 18:20:25 -06:00
parent 91b4534e76
commit 4897f0a52c
73 changed files with 4923 additions and 62 deletions

View File

@@ -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' });

View 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();

View File

@@ -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

View File

@@ -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',
});
}
});

View File

@@ -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 {

View File

@@ -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;

View File

@@ -18,6 +18,8 @@ export interface UserPreferences {
export interface CreateUserPreferencesRequest {
userId: string;
unitSystem?: UnitSystem;
currencyCode?: string;
timeZone?: string;
}
export interface UpdateUserPreferencesRequest {

View File

@@ -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

View File

@@ -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',
});
}
}
}

View 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.

View 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',
});
}
}
}

View 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),
});
};

View 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>;

View 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;
}
}
}

View 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;
}

View 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';

View File

@@ -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();
});
});
});

View 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');
});
});
});

View 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)

View 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',
});
}
}
}

View 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),
});
};

View 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>;

View 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;
}
}
}

View 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;
}

View 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';

View File

@@ -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',
});
}
}
}

View File

@@ -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),
});
};

View File

@@ -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>;

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}

View 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;
}
}

View File

@@ -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';

View File

@@ -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)';