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

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