feat: user export service. bug and UX fixes. Complete minus outstanding email template fixes.

This commit is contained in:
Eric Gullickson
2025-12-26 14:06:03 -06:00
parent 8c13dc0a55
commit fb52ce398b
35 changed files with 1686 additions and 118 deletions

View File

@@ -28,6 +28,7 @@ import { notificationsRoutes } from './features/notifications';
import { userProfileRoutes } from './features/user-profile';
import { onboardingRoutes } from './features/onboarding';
import { userPreferencesRoutes } from './features/user-preferences';
import { userExportRoutes } from './features/user-export';
import { pool } from './core/config/database';
async function buildApp(): Promise<FastifyInstance> {
@@ -85,7 +86,7 @@ async function buildApp(): Promise<FastifyInstance> {
status: 'healthy',
timestamp: new Date().toISOString(),
environment: process.env['NODE_ENV'],
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences']
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
});
});
@@ -95,7 +96,7 @@ async function buildApp(): Promise<FastifyInstance> {
status: 'healthy',
scope: 'api',
timestamp: new Date().toISOString(),
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences']
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
});
});
@@ -134,6 +135,7 @@ async function buildApp(): Promise<FastifyInstance> {
await app.register(notificationsRoutes, { prefix: '/api' });
await app.register(userProfileRoutes, { prefix: '/api' });
await app.register(userPreferencesRoutes, { prefix: '/api' });
await app.register(userExportRoutes, { prefix: '/api' });
// 404 handler
app.setNotFoundHandler(async (_request, reply) => {

View File

@@ -151,6 +151,40 @@ class Auth0ManagementClientSingleton {
}
}
/**
* Send password reset email to user
* Uses Auth0 Authentication API (not Management API)
* @param email User's email address
*/
async sendPasswordResetEmail(email: string): Promise<void> {
try {
const config = appConfig.getAuth0ManagementConfig();
const response = await fetch(`https://${config.domain}/dbconnections/change_password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: config.clientId,
email,
connection: this.CONNECTION_NAME,
}),
});
if (!response.ok) {
const errorBody = await response.text();
logger.error('Password reset email request failed', { email: email.substring(0, 3) + '***', status: response.status, error: errorBody });
throw new Error(`Password reset email failed: ${response.status}`);
}
logger.info('Password reset email sent', { email: email.substring(0, 3) + '***' });
} catch (error) {
logger.error('Failed to send password reset email', { email: email.substring(0, 3) + '***', error });
throw new Error(`Failed to send password reset email: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Verify user password using Resource Owner Password Grant
* @param email User's email address

View File

@@ -182,4 +182,63 @@ export class AuthController {
});
}
}
/**
* GET /api/auth/security-status
* Get user security status
* Protected endpoint - requires JWT
*/
async getSecurityStatus(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const result = await this.authService.getSecurityStatus(userId);
logger.info('Security status retrieved', {
userId: userId.substring(0, 8) + '...',
emailVerified: result.emailVerified,
});
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Failed to get security status', {
error,
userId: (request as any).user?.sub,
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get security status',
});
}
}
/**
* POST /api/auth/request-password-reset
* Request password reset email
* Protected endpoint - requires JWT
*/
async requestPasswordReset(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const result = await this.authService.requestPasswordReset(userId);
logger.info('Password reset email requested', {
userId: userId.substring(0, 8) + '...',
});
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Failed to request password reset', {
error,
userId: (request as any).user?.sub,
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to send password reset email. Please try again later.',
});
}
}
}

View File

@@ -36,4 +36,16 @@ export const authRoutes: FastifyPluginAsync = async (
preHandler: [fastify.authenticate],
handler: authController.getUserStatus.bind(authController),
});
// GET /api/auth/security-status - Get security status (requires JWT)
fastify.get('/auth/security-status', {
preHandler: [fastify.authenticate],
handler: authController.getSecurityStatus.bind(authController),
});
// POST /api/auth/request-password-reset - Request password reset email (requires JWT)
fastify.post('/auth/request-password-reset', {
preHandler: [fastify.authenticate],
handler: authController.requestPasswordReset.bind(authController),
});
};

View File

@@ -11,6 +11,8 @@ import {
SignupResponse,
VerifyStatusResponse,
ResendVerificationResponse,
SecurityStatusResponse,
PasswordResetResponse,
} from './auth.types';
export class AuthService {
@@ -210,4 +212,61 @@ export class AuthService {
throw error;
}
}
/**
* Get security status for the user
* Returns email verification status and passkey info
*/
async getSecurityStatus(auth0Sub: string): Promise<SecurityStatusResponse> {
try {
// Get user details from Auth0
const auth0User = await auth0ManagementClient.getUser(auth0Sub);
logger.info('Retrieved security status', {
auth0Sub: auth0Sub.substring(0, 8) + '...',
emailVerified: auth0User.emailVerified,
});
return {
emailVerified: auth0User.emailVerified,
email: auth0User.email,
// Passkeys are enabled at the Auth0 connection level, not per-user
// This is informational - actual passkey enrollment happens in Auth0 Universal Login
passkeysEnabled: true,
// Auth0 doesn't expose password last changed date via Management API
// Would require Auth0 Logs API or user_metadata to track this
passwordLastChanged: null,
};
} catch (error) {
logger.error('Failed to get security status', { auth0Sub, error });
throw error;
}
}
/**
* Request password reset email
* Triggers Auth0 to send password reset email to user
*/
async requestPasswordReset(auth0Sub: string): Promise<PasswordResetResponse> {
try {
// Get user email from Auth0
const auth0User = await auth0ManagementClient.getUser(auth0Sub);
// Send password reset email via Auth0
await auth0ManagementClient.sendPasswordResetEmail(auth0User.email);
logger.info('Password reset email requested', {
auth0Sub: auth0Sub.substring(0, 8) + '...',
email: auth0User.email.substring(0, 3) + '***',
});
return {
message: 'Password reset email sent. Please check your inbox.',
success: true,
};
} catch (error) {
logger.error('Failed to request password reset', { auth0Sub, error });
throw error;
}
}
}

View File

@@ -26,3 +26,17 @@ export interface VerifyStatusResponse {
export interface ResendVerificationResponse {
message: string;
}
// Response from security status endpoint
export interface SecurityStatusResponse {
emailVerified: boolean;
email: string;
passkeysEnabled: boolean;
passwordLastChanged: string | null;
}
// Response from password reset request endpoint
export interface PasswordResetResponse {
message: string;
success: boolean;
}

View File

@@ -416,11 +416,12 @@ export class BackupRepository {
next.setDate(next.getDate() + 1);
next.setHours(3, 0, 0, 0);
break;
case 'weekly':
case 'weekly': {
const daysUntilSunday = (7 - next.getDay()) % 7 || 7;
next.setDate(next.getDate() + daysUntilSunday);
next.setHours(3, 0, 0, 0);
break;
}
case 'monthly':
next.setMonth(next.getMonth() + 1);
next.setDate(1);

View File

@@ -384,4 +384,12 @@ export class MaintenanceRepository {
);
return result.rows[0] ? this.mapMaintenanceSchedule(result.rows[0]) : null;
}
async findSchedulesByUserId(userId: string): Promise<MaintenanceSchedule[]> {
const res = await this.db.query(
`SELECT * FROM maintenance_schedules WHERE user_id = $1 ORDER BY created_at DESC`,
[userId]
);
return res.rows.map(row => this.mapMaintenanceSchedule(row));
}
}

View File

@@ -0,0 +1,213 @@
# User Export Feature
Provides user data export functionality, allowing authenticated users to download a complete archive of their personal data including all vehicles, fuel logs, documents, maintenance records, and associated files.
## Overview
This feature creates a TAR.GZ archive containing all user data in JSON format plus actual files (vehicle images, document PDFs). The export is synchronous and streams directly to the client for immediate download.
## Architecture
```
user-export/
├── domain/
│ ├── user-export.types.ts # Type definitions
│ ├── user-export.service.ts # Main service
│ └── user-export-archive.service.ts # Archive creation logic
└── api/
├── user-export.controller.ts # HTTP handlers
└── user-export.routes.ts # Route definitions
```
## Archive Structure
```
motovaultpro_export_YYYY-MM-DDTHH-MM-SS.tar.gz
├── manifest.json # Archive metadata
├── data/
│ ├── vehicles.json # All user vehicles
│ ├── fuel-logs.json # All fuel logs
│ ├── documents.json # All document metadata
│ ├── maintenance-records.json # All maintenance records
│ └── maintenance-schedules.json # All maintenance schedules
└── files/
├── vehicle-images/
│ └── {vehicleId}/
│ └── {filename} # Actual vehicle image files
└── documents/
└── {documentId}/
└── {filename} # Actual document files
```
## API Endpoints
### Download User Export
Downloads a complete archive of all user data.
**Endpoint:** `GET /api/user/export`
**Authentication:** Required (JWT)
**Response:**
- Content-Type: `application/gzip`
- Content-Disposition: `attachment; filename="motovaultpro_export_YYYY-MM-DDTHH-MM-SS.tar.gz"`
- Streaming download
**Example:**
```bash
curl -H "Authorization: Bearer <token>" \
https://app.motovaultpro.com/api/user/export \
--output my_data.tar.gz
```
## Manifest Format
The manifest.json file contains metadata about the export:
```json
{
"version": "1.0.0",
"createdAt": "2025-12-26T10:30:00.000Z",
"applicationVersion": "1.0.0",
"userId": "auth0|123456",
"contents": {
"vehicles": {
"count": 3,
"withImages": 2
},
"fuelLogs": {
"count": 45
},
"documents": {
"count": 8,
"withFiles": 7
},
"maintenanceRecords": {
"count": 12
},
"maintenanceSchedules": {
"count": 5
}
},
"files": {
"vehicleImages": 2,
"documentFiles": 7,
"totalSizeBytes": 15728640
},
"warnings": [
"1 vehicle images not found"
]
}
```
## Data Export Details
### Vehicles
All vehicles owned by the user, including:
- Basic information (make, model, year, VIN)
- Vehicle details (engine, transmission, trim)
- Image metadata (if available)
- Odometer readings
### Fuel Logs
All fuel log entries including:
- Date and odometer readings
- Fuel quantity and cost
- Station and location information
- Calculated MPG (if available)
### Documents
Document metadata plus actual files:
- Document type and title
- Issue and expiration dates
- Associated files (PDFs, images)
- Storage metadata
### Maintenance Records
All maintenance history:
- Service category and subtypes
- Date and odometer reading
- Cost and shop information
- Notes
### Maintenance Schedules
All maintenance schedules:
- Interval-based schedules
- Last service information
- Next due dates/mileage
- Notification settings
## Implementation Details
### User Scoping
All data is strictly scoped to the authenticated user via `userId`. No data from other users is included.
### File Handling
- Vehicle images: Read from `/app/data/documents/{bucket}/{key}`
- Document files: Read from `/app/data/documents/{bucket}/{key}`
- Missing files are logged but don't fail the export
- Warnings are included in the manifest
### Temporary Storage
- Working directory: `/tmp/user-export-work/export-{userId}-{timestamp}/`
- Archive created in `/tmp/user-export-work/`
- Cleanup happens automatically after stream completes
### Streaming
The archive is streamed directly to the client to minimize memory usage for large exports.
### Error Handling
- Missing files are logged as warnings
- Export continues even if some files are missing
- Cleanup happens in `finally` block and stream event handlers
## Dependencies
### Internal
- `VehiclesRepository` - Vehicle data access
- `FuelLogsRepository` - Fuel log data access
- `DocumentsRepository` - Document metadata access
- `MaintenanceRepository` - Maintenance data access
### External
- `tar` - TAR.GZ archive creation
- `fs/promises` - File system operations
## Testing
Unit tests should cover:
- Archive creation with various data sets
- Missing file handling
- Manifest generation
- Cleanup on success and error
Integration tests should cover:
- Complete export workflow
- Authentication enforcement
- Streaming download
- File integrity
## Security Considerations
- User authentication required
- Data strictly scoped to authenticated user
- No cross-user data leakage possible
- Temporary files cleaned up after download
- Archive contains only user's own data
## Performance
- Synchronous operation (immediate response)
- Streaming reduces memory footprint
- Parallel file operations where possible
- Cleanup prevents disk space accumulation
## Future Enhancements
Potential improvements:
- Import functionality to restore exported data
- Selective export (e.g., only vehicles)
- Export format options (JSON only, without files)
- Scheduled exports
- Export history tracking

View File

@@ -0,0 +1,44 @@
/**
* @ai-summary User export controller
* @ai-context Handles HTTP requests for user data export
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { UserExportService } from '../domain/user-export.service';
import { logger } from '../../../core/logging/logger';
export class UserExportController {
private exportService: UserExportService;
constructor() {
this.exportService = new UserExportService();
}
async downloadExport(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const userId = (request as any).user.sub;
logger.info('User export requested', { userId });
try {
const result = await this.exportService.exportUserData(userId);
logger.info('User export ready for download', {
userId,
filename: result.filename,
sizeBytes: result.size,
});
return reply
.header('Content-Type', 'application/gzip')
.header('Content-Disposition', `attachment; filename="${result.filename}"`)
.header('Content-Length', result.size)
.send(result.stream);
} catch (error) {
logger.error('User export failed', {
userId,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
}

View File

@@ -0,0 +1,16 @@
/**
* @ai-summary User export routes
* @ai-context Route definitions for user data export
*/
import { FastifyPluginAsync } from 'fastify';
import { UserExportController } from './user-export.controller';
export const userExportRoutes: FastifyPluginAsync = async (fastify) => {
const controller = new UserExportController();
fastify.get('/user/export', {
preHandler: [(fastify as any).authenticate],
handler: controller.downloadExport.bind(controller),
});
};

View File

@@ -0,0 +1,289 @@
/**
* @ai-summary Service for creating user data export archives
* @ai-context Creates tar.gz archives containing all user data and files
*/
import * as fsp from 'fs/promises';
import * as fs from 'fs';
import * as path from 'path';
import * as tar from 'tar';
import { logger } from '../../../core/logging/logger';
import { VehiclesRepository } from '../../vehicles/data/vehicles.repository';
import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository';
import { DocumentsRepository } from '../../documents/data/documents.repository';
import { MaintenanceRepository } from '../../maintenance/data/maintenance.repository';
import { USER_EXPORT_CONFIG, UserExportManifest, UserExportResult } from './user-export.types';
export class UserExportArchiveService {
private readonly tempPath: string;
private readonly dataPath: string;
constructor(
private readonly vehiclesRepo: VehiclesRepository,
private readonly fuelLogsRepo: FuelLogsRepository,
private readonly documentsRepo: DocumentsRepository,
private readonly maintenanceRepo: MaintenanceRepository
) {
this.tempPath = USER_EXPORT_CONFIG.tempPath;
this.dataPath = '/app/data/documents';
}
/**
* Creates a complete user data export archive
*/
async createUserExport(userId: string): Promise<UserExportResult> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const workDir = path.join(this.tempPath, `export-${userId}-${Date.now()}`);
try {
// Create working directory structure
await fsp.mkdir(workDir, { recursive: true });
await fsp.mkdir(path.join(workDir, 'data'), { recursive: true });
await fsp.mkdir(path.join(workDir, 'files'), { recursive: true });
logger.info('Starting user data export', { userId, workDir });
// Gather user data
const [vehicles, fuelLogs, documents, maintenanceRecords] = await Promise.all([
this.vehiclesRepo.findByUserId(userId),
this.fuelLogsRepo.findByUserId(userId),
this.documentsRepo.listByUser(userId),
this.maintenanceRepo.findRecordsByUserId(userId),
]);
const maintenanceSchedules = await this.maintenanceRepo.findSchedulesByUserId(userId);
// Write JSON data files
await Promise.all([
fsp.writeFile(
path.join(workDir, 'data', 'vehicles.json'),
JSON.stringify(vehicles, null, 2)
),
fsp.writeFile(
path.join(workDir, 'data', 'fuel-logs.json'),
JSON.stringify(fuelLogs, null, 2)
),
fsp.writeFile(
path.join(workDir, 'data', 'documents.json'),
JSON.stringify(documents, null, 2)
),
fsp.writeFile(
path.join(workDir, 'data', 'maintenance-records.json'),
JSON.stringify(maintenanceRecords, null, 2)
),
fsp.writeFile(
path.join(workDir, 'data', 'maintenance-schedules.json'),
JSON.stringify(maintenanceSchedules, null, 2)
),
]);
// Copy vehicle images
const vehicleImageStats = await this.copyVehicleImages(workDir, vehicles);
// Copy document files
const documentFileStats = await this.copyDocumentFiles(workDir, documents);
// Create manifest
const warnings: string[] = [];
if (vehicleImageStats.missingCount > 0) {
warnings.push(`${vehicleImageStats.missingCount} vehicle images not found`);
}
if (documentFileStats.missingCount > 0) {
warnings.push(`${documentFileStats.missingCount} document files not found`);
}
const manifest: UserExportManifest = {
version: USER_EXPORT_CONFIG.archiveVersion,
createdAt: new Date().toISOString(),
applicationVersion: process.env.npm_package_version || '1.0.0',
userId,
contents: {
vehicles: {
count: vehicles.length,
withImages: vehicles.filter(v => v.imageStorageKey).length,
},
fuelLogs: { count: fuelLogs.length },
documents: {
count: documents.length,
withFiles: documents.filter(d => d.storageKey).length,
},
maintenanceRecords: { count: maintenanceRecords.length },
maintenanceSchedules: { count: maintenanceSchedules.length },
},
files: {
vehicleImages: vehicleImageStats.copiedCount,
documentFiles: documentFileStats.copiedCount,
totalSizeBytes: vehicleImageStats.totalSize + documentFileStats.totalSize,
},
warnings,
};
await fsp.writeFile(
path.join(workDir, 'manifest.json'),
JSON.stringify(manifest, null, 2)
);
// Create tar.gz archive
const archiveFilename = `motovaultpro_export_${timestamp}.tar.gz`;
const archivePath = path.join(this.tempPath, archiveFilename);
logger.info('Creating tar.gz archive', { userId, archivePath });
await tar.create(
{
gzip: true,
file: archivePath,
cwd: workDir,
},
['.']
);
const archiveStats = await fsp.stat(archivePath);
logger.info('User export archive created successfully', {
userId,
archivePath,
sizeBytes: archiveStats.size,
});
// Create read stream for download
const stream = fs.createReadStream(archivePath);
// Schedule cleanup after stream ends
stream.on('end', async () => {
await this.cleanupWorkDir(workDir);
await this.cleanupArchive(archivePath);
});
stream.on('error', async () => {
await this.cleanupWorkDir(workDir);
await this.cleanupArchive(archivePath);
});
return {
stream,
filename: archiveFilename,
size: archiveStats.size,
};
} catch (error) {
// Cleanup on error
await this.cleanupWorkDir(workDir);
throw error;
}
}
/**
* Copies vehicle images to the export archive
*/
private async copyVehicleImages(
workDir: string,
vehicles: any[]
): Promise<{ copiedCount: number; missingCount: number; totalSize: number }> {
let copiedCount = 0;
let missingCount = 0;
let totalSize = 0;
for (const vehicle of vehicles) {
if (!vehicle.imageStorageBucket || !vehicle.imageStorageKey) {
continue;
}
const sourcePath = path.join(
this.dataPath,
vehicle.imageStorageBucket,
vehicle.imageStorageKey
);
const destDir = path.join(workDir, 'files', 'vehicle-images', vehicle.id);
const destPath = path.join(destDir, vehicle.imageFileName || 'image.jpg');
try {
await fsp.mkdir(destDir, { recursive: true });
await fsp.copyFile(sourcePath, destPath);
const stats = await fsp.stat(destPath);
totalSize += stats.size;
copiedCount++;
} catch (error) {
logger.warn('Failed to copy vehicle image', {
vehicleId: vehicle.id,
sourcePath,
error: error instanceof Error ? error.message : String(error),
});
missingCount++;
}
}
return { copiedCount, missingCount, totalSize };
}
/**
* Copies document files to the export archive
*/
private async copyDocumentFiles(
workDir: string,
documents: any[]
): Promise<{ copiedCount: number; missingCount: number; totalSize: number }> {
let copiedCount = 0;
let missingCount = 0;
let totalSize = 0;
for (const doc of documents) {
if (!doc.storageBucket || !doc.storageKey) {
continue;
}
const sourcePath = path.join(this.dataPath, doc.storageBucket, doc.storageKey);
const destDir = path.join(workDir, 'files', 'documents', doc.id);
const destPath = path.join(destDir, doc.fileName || 'file.pdf');
try {
await fsp.mkdir(destDir, { recursive: true });
await fsp.copyFile(sourcePath, destPath);
const stats = await fsp.stat(destPath);
totalSize += stats.size;
copiedCount++;
} catch (error) {
logger.warn('Failed to copy document file', {
documentId: doc.id,
sourcePath,
error: error instanceof Error ? error.message : String(error),
});
missingCount++;
}
}
return { copiedCount, missingCount, totalSize };
}
/**
* Cleans up a working directory
*/
private async cleanupWorkDir(workDir: string): Promise<void> {
try {
await fsp.rm(workDir, { recursive: true, force: true });
logger.debug('Cleaned up work directory', { workDir });
} catch (error) {
logger.warn('Failed to cleanup work directory', {
workDir,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Cleans up an archive file
*/
private async cleanupArchive(archivePath: string): Promise<void> {
try {
await fsp.unlink(archivePath);
logger.debug('Cleaned up archive file', { archivePath });
} catch (error) {
logger.warn('Failed to cleanup archive file', {
archivePath,
error: error instanceof Error ? error.message : String(error),
});
}
}
}

View File

@@ -0,0 +1,30 @@
/**
* @ai-summary User export service
* @ai-context Coordinates user data export operations
*/
import { Pool } from 'pg';
import pool from '../../../core/config/database';
import { VehiclesRepository } from '../../vehicles/data/vehicles.repository';
import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository';
import { DocumentsRepository } from '../../documents/data/documents.repository';
import { MaintenanceRepository } from '../../maintenance/data/maintenance.repository';
import { UserExportArchiveService } from './user-export-archive.service';
import { UserExportResult } from './user-export.types';
export class UserExportService {
private archiveService: UserExportArchiveService;
constructor(db: Pool = pool) {
this.archiveService = new UserExportArchiveService(
new VehiclesRepository(db),
new FuelLogsRepository(db),
new DocumentsRepository(db),
new MaintenanceRepository(db)
);
}
async exportUserData(userId: string): Promise<UserExportResult> {
return this.archiveService.createUserExport(userId);
}
}

View File

@@ -0,0 +1,37 @@
/**
* @ai-summary User export types and constants
* @ai-context Types for user data export feature
*/
import * as fs from 'fs';
export interface UserExportManifest {
version: string;
createdAt: string;
applicationVersion: string;
userId: string;
contents: {
vehicles: { count: number; withImages: number };
fuelLogs: { count: number };
documents: { count: number; withFiles: number };
maintenanceRecords: { count: number };
maintenanceSchedules: { count: number };
};
files: {
vehicleImages: number;
documentFiles: number;
totalSizeBytes: number;
};
warnings: string[];
}
export interface UserExportResult {
stream: fs.ReadStream;
filename: string;
size: number;
}
export const USER_EXPORT_CONFIG = {
tempPath: '/tmp/user-export-work',
archiveVersion: '1.0.0',
} as const;

View File

@@ -0,0 +1,6 @@
/**
* @ai-summary User export feature public API
* @ai-context Exports routes for registration in app.ts
*/
export { userExportRoutes } from './api/user-export.routes';

View File

@@ -0,0 +1,41 @@
/**
* @ai-summary Unit tests for user export service
* @ai-context Tests the export service with mocked repositories
*/
import { UserExportService } from '../../domain/user-export.service';
import { Pool } from 'pg';
describe('UserExportService', () => {
let service: UserExportService;
let mockPool: Pool;
beforeEach(() => {
// Create mock pool
mockPool = {
query: jest.fn(),
} as any;
service = new UserExportService(mockPool);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('exportUserData', () => {
it('should be defined', () => {
expect(service).toBeDefined();
expect(service.exportUserData).toBeDefined();
});
it('should accept a userId parameter', async () => {
const userId = 'test-user-id';
// This will fail without actual data setup, but validates the interface
await expect(async () => {
await service.exportUserData(userId);
}).rejects.toThrow();
});
});
});