feat: delete users - not tested
This commit is contained in:
186
backend/src/features/onboarding/README.md
Normal file
186
backend/src/features/onboarding/README.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Onboarding Feature Module
|
||||
|
||||
## Overview
|
||||
The onboarding feature manages the user signup workflow after email verification. It handles user preference setup (unit system, currency, timezone) and tracks onboarding completion status.
|
||||
|
||||
## Purpose
|
||||
After a user verifies their email with Auth0, they go through an onboarding flow to:
|
||||
1. Set their preferences (unit system, currency, timezone)
|
||||
2. Optionally add their first vehicle (handled by vehicles feature)
|
||||
3. Mark onboarding as complete
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints require JWT authentication via `fastify.authenticate` preHandler.
|
||||
|
||||
### POST `/api/onboarding/preferences`
|
||||
Save user preferences during onboarding.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"unitSystem": "imperial",
|
||||
"currencyCode": "USD",
|
||||
"timeZone": "America/New_York"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `unitSystem`: Must be either "imperial" or "metric"
|
||||
- `currencyCode`: Must be exactly 3 uppercase letters (ISO 4217)
|
||||
- `timeZone`: 1-100 characters (IANA timezone identifier)
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"preferences": {
|
||||
"unitSystem": "imperial",
|
||||
"currencyCode": "USD",
|
||||
"timeZone": "America/New_York"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `404 Not Found` - User profile not found
|
||||
- `500 Internal Server Error` - Failed to save preferences
|
||||
|
||||
### POST `/api/onboarding/complete`
|
||||
Mark onboarding as complete for the authenticated user.
|
||||
|
||||
**Request Body:** None
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"completedAt": "2025-12-22T15:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `404 Not Found` - User profile not found
|
||||
- `500 Internal Server Error` - Failed to complete onboarding
|
||||
|
||||
### GET `/api/onboarding/status`
|
||||
Get current onboarding status for the authenticated user.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"preferencesSet": true,
|
||||
"onboardingCompleted": true,
|
||||
"onboardingCompletedAt": "2025-12-22T15:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200) - Incomplete:**
|
||||
```json
|
||||
{
|
||||
"preferencesSet": false,
|
||||
"onboardingCompleted": false,
|
||||
"onboardingCompletedAt": null
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `404 Not Found` - User profile not found
|
||||
- `500 Internal Server Error` - Failed to get status
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
backend/src/features/onboarding/
|
||||
├── README.md # This file
|
||||
├── index.ts # Public API exports
|
||||
├── api/ # HTTP layer
|
||||
│ ├── onboarding.controller.ts # Request handlers
|
||||
│ ├── onboarding.routes.ts # Route definitions
|
||||
│ └── onboarding.validation.ts # Zod schemas
|
||||
├── domain/ # Business logic
|
||||
│ ├── onboarding.service.ts # Core logic
|
||||
│ └── onboarding.types.ts # Type definitions
|
||||
└── tests/ # Tests (to be added)
|
||||
├── unit/
|
||||
└── integration/
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Dependencies
|
||||
- **UserProfileRepository** (`features/user-profile`) - For updating `onboarding_completed_at`
|
||||
- **UserPreferencesRepository** (`core/user-preferences`) - For saving unit system, currency, timezone
|
||||
- **Database Pool** (`core/config/database`) - PostgreSQL connection
|
||||
- **Logger** (`core/logging/logger`) - Structured logging
|
||||
|
||||
### Database Tables
|
||||
- `user_profiles` - Stores `onboarding_completed_at` timestamp
|
||||
- `user_preferences` - Stores `unit_system`, `currency_code`, `time_zone`
|
||||
|
||||
## Business Logic
|
||||
|
||||
### Save Preferences Flow
|
||||
1. Extract Auth0 user ID from JWT
|
||||
2. Fetch user profile to get internal user ID
|
||||
3. Check if user_preferences record exists
|
||||
4. If exists, update preferences; otherwise create new record
|
||||
5. Return saved preferences
|
||||
|
||||
### Complete Onboarding Flow
|
||||
1. Extract Auth0 user ID from JWT
|
||||
2. Call `UserProfileRepository.markOnboardingComplete(auth0Sub)`
|
||||
3. Updates `onboarding_completed_at` to NOW() if not already set
|
||||
4. Return completion timestamp
|
||||
|
||||
### Get Status Flow
|
||||
1. Extract Auth0 user ID from JWT
|
||||
2. Fetch user profile
|
||||
3. Check if user_preferences record exists
|
||||
4. Return status object with:
|
||||
- `preferencesSet`: boolean (preferences exist)
|
||||
- `onboardingCompleted`: boolean (onboarding_completed_at is set)
|
||||
- `onboardingCompletedAt`: timestamp or null
|
||||
|
||||
## Key Behaviors
|
||||
|
||||
### User Ownership
|
||||
All operations are scoped to the authenticated user via JWT. No cross-user access is possible.
|
||||
|
||||
### Idempotency
|
||||
- `savePreferences` - Can be called multiple times; updates existing record
|
||||
- `completeOnboarding` - Can be called multiple times; returns existing completion timestamp if already completed
|
||||
|
||||
### Email Verification
|
||||
These routes are in `VERIFICATION_EXEMPT_ROUTES` in `auth.plugin.ts` because users complete onboarding immediately after email verification.
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors are logged with structured logging:
|
||||
```typescript
|
||||
logger.error('Error saving onboarding preferences', {
|
||||
error,
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
});
|
||||
```
|
||||
|
||||
Controller methods return appropriate HTTP status codes:
|
||||
- `200 OK` - Success
|
||||
- `404 Not Found` - User profile not found
|
||||
- `500 Internal Server Error` - Unexpected errors
|
||||
|
||||
## Testing
|
||||
|
||||
Tests to be added:
|
||||
- Unit tests for `OnboardingService` with mocked repositories
|
||||
- Integration tests for API endpoints with test database
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- Add validation for IANA timezone identifiers
|
||||
- Add validation for ISO 4217 currency codes against known list
|
||||
- Track onboarding step completion for analytics
|
||||
- Add onboarding progress percentage
|
||||
- Support for skipping onboarding (mark as complete without preferences)
|
||||
143
backend/src/features/onboarding/api/onboarding.controller.ts
Normal file
143
backend/src/features/onboarding/api/onboarding.controller.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @ai-summary Fastify route handlers for onboarding API
|
||||
* @ai-context HTTP request/response handling for onboarding workflow
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { OnboardingService } from '../domain/onboarding.service';
|
||||
import { UserPreferencesRepository } from '../../../core/user-preferences/data/user-preferences.repository';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { SavePreferencesInput } from './onboarding.validation';
|
||||
|
||||
interface AuthenticatedRequest extends FastifyRequest {
|
||||
user: {
|
||||
sub: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export class OnboardingController {
|
||||
private onboardingService: OnboardingService;
|
||||
|
||||
constructor() {
|
||||
const userPreferencesRepo = new UserPreferencesRepository(pool);
|
||||
const userProfileRepo = new UserProfileRepository(pool);
|
||||
this.onboardingService = new OnboardingService(userPreferencesRepo, userProfileRepo);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/onboarding/preferences
|
||||
* Save user preferences during onboarding
|
||||
*/
|
||||
async savePreferences(
|
||||
request: FastifyRequest<{ Body: SavePreferencesInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const auth0Sub = (request as AuthenticatedRequest).user.sub;
|
||||
|
||||
const preferences = await this.onboardingService.savePreferences(
|
||||
auth0Sub,
|
||||
request.body
|
||||
);
|
||||
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
preferences,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error in savePreferences controller', {
|
||||
error,
|
||||
userId: (request as AuthenticatedRequest).user?.sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User profile not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'User profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to save preferences',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/onboarding/complete
|
||||
* Mark onboarding as complete
|
||||
*/
|
||||
async completeOnboarding(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const auth0Sub = (request as AuthenticatedRequest).user.sub;
|
||||
|
||||
const completedAt = await this.onboardingService.completeOnboarding(auth0Sub);
|
||||
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
completedAt: completedAt.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error in completeOnboarding controller', {
|
||||
error,
|
||||
userId: (request as AuthenticatedRequest).user?.sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User profile not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'User profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to complete onboarding',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/onboarding/status
|
||||
* Get current onboarding status
|
||||
*/
|
||||
async getStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const auth0Sub = (request as AuthenticatedRequest).user.sub;
|
||||
|
||||
const status = await this.onboardingService.getOnboardingStatus(auth0Sub);
|
||||
|
||||
return reply.code(200).send({
|
||||
preferencesSet: status.preferencesSet,
|
||||
onboardingCompleted: status.onboardingCompleted,
|
||||
onboardingCompletedAt: status.onboardingCompletedAt
|
||||
? status.onboardingCompletedAt.toISOString()
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error in getStatus controller', {
|
||||
error,
|
||||
userId: (request as AuthenticatedRequest).user?.sub,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User profile not found') {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'User profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get onboarding status',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
33
backend/src/features/onboarding/api/onboarding.routes.ts
Normal file
33
backend/src/features/onboarding/api/onboarding.routes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @ai-summary Fastify routes for onboarding API
|
||||
* @ai-context Route definitions for user onboarding workflow
|
||||
*/
|
||||
|
||||
import { FastifyInstance, FastifyPluginOptions, FastifyPluginAsync } from 'fastify';
|
||||
import { OnboardingController } from './onboarding.controller';
|
||||
import { SavePreferencesInput } from './onboarding.validation';
|
||||
|
||||
export const onboardingRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
_opts: FastifyPluginOptions
|
||||
) => {
|
||||
const onboardingController = new OnboardingController();
|
||||
|
||||
// POST /api/onboarding/preferences - Save user preferences
|
||||
fastify.post<{ Body: SavePreferencesInput }>('/onboarding/preferences', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: onboardingController.savePreferences.bind(onboardingController),
|
||||
});
|
||||
|
||||
// POST /api/onboarding/complete - Mark onboarding as complete
|
||||
fastify.post('/onboarding/complete', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: onboardingController.completeOnboarding.bind(onboardingController),
|
||||
});
|
||||
|
||||
// GET /api/onboarding/status - Get onboarding status
|
||||
fastify.get('/onboarding/status', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: onboardingController.getStatus.bind(onboardingController),
|
||||
});
|
||||
};
|
||||
21
backend/src/features/onboarding/api/onboarding.validation.ts
Normal file
21
backend/src/features/onboarding/api/onboarding.validation.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @ai-summary Request validation schemas for onboarding API
|
||||
* @ai-context Uses Zod for runtime validation and type safety
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const savePreferencesSchema = z.object({
|
||||
unitSystem: z.enum(['imperial', 'metric'], {
|
||||
errorMap: () => ({ message: 'Unit system must be either "imperial" or "metric"' })
|
||||
}),
|
||||
currencyCode: z.string()
|
||||
.length(3, 'Currency code must be exactly 3 characters (ISO 4217)')
|
||||
.toUpperCase()
|
||||
.regex(/^[A-Z]{3}$/, 'Currency code must be 3 uppercase letters'),
|
||||
timeZone: z.string()
|
||||
.min(1, 'Time zone is required')
|
||||
.max(100, 'Time zone must not exceed 100 characters')
|
||||
}).strict();
|
||||
|
||||
export type SavePreferencesInput = z.infer<typeof savePreferencesSchema>;
|
||||
155
backend/src/features/onboarding/domain/onboarding.service.ts
Normal file
155
backend/src/features/onboarding/domain/onboarding.service.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @ai-summary Business logic for user onboarding workflow
|
||||
* @ai-context Coordinates user preferences and profile updates during onboarding
|
||||
*/
|
||||
|
||||
import { UserPreferencesRepository } from '../../../core/user-preferences/data/user-preferences.repository';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
OnboardingPreferences,
|
||||
OnboardingStatus,
|
||||
SavePreferencesRequest,
|
||||
} from './onboarding.types';
|
||||
|
||||
export class OnboardingService {
|
||||
constructor(
|
||||
private userPreferencesRepo: UserPreferencesRepository,
|
||||
private userProfileRepo: UserProfileRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Save user preferences during onboarding
|
||||
*/
|
||||
async savePreferences(
|
||||
auth0Sub: string,
|
||||
request: SavePreferencesRequest
|
||||
): Promise<OnboardingPreferences> {
|
||||
try {
|
||||
// Validate required fields (should be guaranteed by Zod validation)
|
||||
if (!request.unitSystem || !request.currencyCode || !request.timeZone) {
|
||||
throw new Error('Missing required fields: unitSystem, currencyCode, timeZone');
|
||||
}
|
||||
|
||||
logger.info('Saving onboarding preferences', {
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
unitSystem: request.unitSystem,
|
||||
currencyCode: request.currencyCode,
|
||||
timeZone: request.timeZone,
|
||||
});
|
||||
|
||||
// Get user profile to get internal user ID
|
||||
const profile = await this.userProfileRepo.getByAuth0Sub(auth0Sub);
|
||||
if (!profile) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
|
||||
// Check if preferences already exist
|
||||
const existingPrefs = await this.userPreferencesRepo.findByUserId(profile.id);
|
||||
|
||||
let savedPrefs;
|
||||
if (existingPrefs) {
|
||||
// Update existing preferences
|
||||
savedPrefs = await this.userPreferencesRepo.update(profile.id, {
|
||||
unitSystem: request.unitSystem,
|
||||
currencyCode: request.currencyCode,
|
||||
timeZone: request.timeZone,
|
||||
});
|
||||
} else {
|
||||
// Create new preferences
|
||||
savedPrefs = await this.userPreferencesRepo.create({
|
||||
userId: profile.id,
|
||||
unitSystem: request.unitSystem,
|
||||
currencyCode: request.currencyCode,
|
||||
timeZone: request.timeZone,
|
||||
});
|
||||
}
|
||||
|
||||
if (!savedPrefs) {
|
||||
throw new Error('Failed to save user preferences');
|
||||
}
|
||||
|
||||
logger.info('Onboarding preferences saved successfully', {
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
preferencesId: savedPrefs.id,
|
||||
});
|
||||
|
||||
return {
|
||||
unitSystem: savedPrefs.unitSystem,
|
||||
currencyCode: savedPrefs.currencyCode,
|
||||
timeZone: savedPrefs.timeZone,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error saving onboarding preferences', {
|
||||
error,
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark onboarding as complete
|
||||
*/
|
||||
async completeOnboarding(auth0Sub: string): Promise<Date> {
|
||||
try {
|
||||
logger.info('Completing onboarding', {
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
});
|
||||
|
||||
const profile = await this.userProfileRepo.markOnboardingComplete(auth0Sub);
|
||||
|
||||
logger.info('Onboarding completed successfully', {
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
completedAt: profile.onboardingCompletedAt,
|
||||
});
|
||||
|
||||
return profile.onboardingCompletedAt!;
|
||||
} catch (error) {
|
||||
logger.error('Error completing onboarding', {
|
||||
error,
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current onboarding status
|
||||
*/
|
||||
async getOnboardingStatus(auth0Sub: string): Promise<OnboardingStatus> {
|
||||
try {
|
||||
logger.info('Getting onboarding status', {
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
});
|
||||
|
||||
// Get user profile
|
||||
const profile = await this.userProfileRepo.getByAuth0Sub(auth0Sub);
|
||||
if (!profile) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
|
||||
// Check if preferences exist
|
||||
const preferences = await this.userPreferencesRepo.findByUserId(profile.id);
|
||||
|
||||
const status: OnboardingStatus = {
|
||||
preferencesSet: !!preferences,
|
||||
onboardingCompleted: !!profile.onboardingCompletedAt,
|
||||
onboardingCompletedAt: profile.onboardingCompletedAt,
|
||||
};
|
||||
|
||||
logger.info('Retrieved onboarding status', {
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
status,
|
||||
});
|
||||
|
||||
return status;
|
||||
} catch (error) {
|
||||
logger.error('Error getting onboarding status', {
|
||||
error,
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
backend/src/features/onboarding/domain/onboarding.types.ts
Normal file
40
backend/src/features/onboarding/domain/onboarding.types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for onboarding feature
|
||||
* @ai-context Manages user onboarding flow, preferences, and completion status
|
||||
*/
|
||||
|
||||
export type UnitSystem = 'imperial' | 'metric';
|
||||
|
||||
export interface OnboardingPreferences {
|
||||
unitSystem: UnitSystem;
|
||||
currencyCode: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
export interface OnboardingStatus {
|
||||
preferencesSet: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
onboardingCompletedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface SavePreferencesRequest {
|
||||
unitSystem?: UnitSystem;
|
||||
currencyCode?: string;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export interface SavePreferencesResponse {
|
||||
success: boolean;
|
||||
preferences: OnboardingPreferences;
|
||||
}
|
||||
|
||||
export interface CompleteOnboardingResponse {
|
||||
success: boolean;
|
||||
completedAt: Date;
|
||||
}
|
||||
|
||||
export interface OnboardingStatusResponse {
|
||||
preferencesSet: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
onboardingCompletedAt: string | null;
|
||||
}
|
||||
20
backend/src/features/onboarding/index.ts
Normal file
20
backend/src/features/onboarding/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @ai-summary Public API for onboarding feature capsule
|
||||
* @ai-context This is the ONLY file other features should import from
|
||||
*/
|
||||
|
||||
// Export service for use by other features
|
||||
export { OnboardingService } from './domain/onboarding.service';
|
||||
|
||||
// Export types needed by other features
|
||||
export type {
|
||||
OnboardingPreferences,
|
||||
OnboardingStatus,
|
||||
SavePreferencesRequest,
|
||||
SavePreferencesResponse,
|
||||
CompleteOnboardingResponse,
|
||||
OnboardingStatusResponse,
|
||||
} from './domain/onboarding.types';
|
||||
|
||||
// Internal: Register routes with Fastify app
|
||||
export { onboardingRoutes } from './api/onboarding.routes';
|
||||
Reference in New Issue
Block a user