feat: add import service and API layer (refs #26)
Implements Milestone 3: Backend import service and API with: Service Layer (user-import.service.ts): - generatePreview(): extract archive, validate, detect VIN conflicts - executeMerge(): chunk-based import (100 records/batch), UPDATE existing by VIN, INSERT new via batchInsert - executeReplace(): transactional DELETE all user data, batchInsert all records - Conflict detection: VIN duplicates in vehicles - Error handling: collect errors per record, continue, report in summary - File handling: copy vehicle images and documents from archive to storage - Cleanup: delete temp directory in finally block API Layer: - POST /api/user/import: multipart upload, mode selection (merge/replace) - POST /api/user/import/preview: preview without executing import - Authentication: fastify.authenticate preHandler - Content-Type validation: application/gzip or application/x-gzip - Magic byte validation: FileType.fromBuffer verifies tar.gz - Request validation: Zod schema for mode selection - Response: ImportResult with success, mode, summary, warnings Files Created: - backend/src/features/user-import/domain/user-import.service.ts - backend/src/features/user-import/api/user-import.controller.ts - backend/src/features/user-import/api/user-import.routes.ts - backend/src/features/user-import/api/user-import.validation.ts Files Updated: - backend/src/app.ts: register userImportRoutes with /api prefix Quality: - Type-check: PASS (0 errors) - Linting: PASS (0 errors, 470 warnings - all pre-existing) - Repository pattern: snake_case→camelCase conversion - User-scoped: all queries filter by user_id - Transaction boundaries: Replace mode atomic, Merge mode per-batch Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
235
backend/src/features/user-import/api/user-import.controller.ts
Normal file
235
backend/src/features/user-import/api/user-import.controller.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @ai-summary Controller for user data import endpoints
|
||||
* @ai-context Handles multipart uploads, validation, and import orchestration
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import FileType from 'file-type';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { UserImportService } from '../domain/user-import.service';
|
||||
import { importRequestSchema } from './user-import.validation';
|
||||
|
||||
export class UserImportController {
|
||||
private readonly importService: UserImportService;
|
||||
|
||||
constructor() {
|
||||
this.importService = new UserImportService(pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/import
|
||||
* Uploads and imports user data archive
|
||||
*/
|
||||
async uploadAndImport(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const userId = request.user?.sub;
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
logger.info('Processing user data import request', { userId });
|
||||
|
||||
let tempFilePath: string | null = null;
|
||||
|
||||
try {
|
||||
// Get multipart file
|
||||
const data = await request.file();
|
||||
|
||||
if (!data) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'No file uploaded',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate Content-Type header
|
||||
const contentType = data.mimetype;
|
||||
const allowedTypes = ['application/gzip', 'application/x-gzip', 'application/x-tar'];
|
||||
|
||||
if (!allowedTypes.includes(contentType)) {
|
||||
logger.warn('Invalid Content-Type for import upload', {
|
||||
userId,
|
||||
contentType,
|
||||
fileName: data.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: 'Only tar.gz archives are allowed (application/gzip)',
|
||||
});
|
||||
}
|
||||
|
||||
// Read file to buffer for magic byte validation
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of data.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const fileBuffer = Buffer.concat(chunks);
|
||||
|
||||
// Validate actual file content using magic bytes
|
||||
const detectedType = await FileType.fromBuffer(fileBuffer);
|
||||
|
||||
if (!detectedType || detectedType.mime !== 'application/gzip') {
|
||||
logger.warn('File content does not match gzip format', {
|
||||
userId,
|
||||
detectedType: detectedType?.mime,
|
||||
fileName: data.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: 'File content is not a valid gzip archive',
|
||||
});
|
||||
}
|
||||
|
||||
// Save to temp file for processing
|
||||
const timestamp = Date.now();
|
||||
tempFilePath = path.join('/tmp', `import-upload-${userId}-${timestamp}.tar.gz`);
|
||||
await fsp.writeFile(tempFilePath, fileBuffer);
|
||||
|
||||
logger.info('Import archive uploaded and validated', { userId, tempFilePath });
|
||||
|
||||
// Parse request body for mode (if provided)
|
||||
const fields: Record<string, any> = {};
|
||||
if (data.fields) {
|
||||
for (const [key, value] of Object.entries(data.fields)) {
|
||||
fields[key] = (value as any).value;
|
||||
}
|
||||
}
|
||||
|
||||
const validatedFields = importRequestSchema.parse(fields);
|
||||
const mode = validatedFields.mode || 'merge';
|
||||
|
||||
// Execute import based on mode
|
||||
let result;
|
||||
if (mode === 'replace') {
|
||||
result = await this.importService.executeReplace(userId, tempFilePath);
|
||||
} else {
|
||||
result = await this.importService.executeMerge(userId, tempFilePath);
|
||||
}
|
||||
|
||||
logger.info('Import completed', { userId, mode, result });
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error) {
|
||||
logger.error('Import failed', {
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: error instanceof Error ? error.message : 'Import failed',
|
||||
});
|
||||
} finally {
|
||||
// Cleanup temp upload file
|
||||
if (tempFilePath) {
|
||||
try {
|
||||
await fsp.unlink(tempFilePath);
|
||||
} catch {
|
||||
// Cleanup failed, but continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/import/preview
|
||||
* Generates preview of import data without executing import
|
||||
*/
|
||||
async generatePreview(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const userId = request.user?.sub;
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
logger.info('Generating import preview', { userId });
|
||||
|
||||
let tempFilePath: string | null = null;
|
||||
|
||||
try {
|
||||
// Get multipart file
|
||||
const data = await request.file();
|
||||
|
||||
if (!data) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'No file uploaded',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate Content-Type header
|
||||
const contentType = data.mimetype;
|
||||
const allowedTypes = ['application/gzip', 'application/x-gzip', 'application/x-tar'];
|
||||
|
||||
if (!allowedTypes.includes(contentType)) {
|
||||
logger.warn('Invalid Content-Type for preview upload', {
|
||||
userId,
|
||||
contentType,
|
||||
fileName: data.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: 'Only tar.gz archives are allowed (application/gzip)',
|
||||
});
|
||||
}
|
||||
|
||||
// Read file to buffer for magic byte validation
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of data.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const fileBuffer = Buffer.concat(chunks);
|
||||
|
||||
// Validate actual file content using magic bytes
|
||||
const detectedType = await FileType.fromBuffer(fileBuffer);
|
||||
|
||||
if (!detectedType || detectedType.mime !== 'application/gzip') {
|
||||
logger.warn('File content does not match gzip format', {
|
||||
userId,
|
||||
detectedType: detectedType?.mime,
|
||||
fileName: data.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: 'File content is not a valid gzip archive',
|
||||
});
|
||||
}
|
||||
|
||||
// Save to temp file for processing
|
||||
const timestamp = Date.now();
|
||||
tempFilePath = path.join('/tmp', `import-preview-${userId}-${timestamp}.tar.gz`);
|
||||
await fsp.writeFile(tempFilePath, fileBuffer);
|
||||
|
||||
logger.info('Preview archive uploaded and validated', { userId, tempFilePath });
|
||||
|
||||
// Generate preview
|
||||
const preview = await this.importService.generatePreview(userId, tempFilePath);
|
||||
|
||||
logger.info('Preview generated', { userId, preview });
|
||||
|
||||
return reply.code(200).send(preview);
|
||||
} catch (error) {
|
||||
logger.error('Preview generation failed', {
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: error instanceof Error ? error.message : 'Preview generation failed',
|
||||
});
|
||||
} finally {
|
||||
// Cleanup temp upload file
|
||||
if (tempFilePath) {
|
||||
try {
|
||||
await fsp.unlink(tempFilePath);
|
||||
} catch {
|
||||
// Cleanup failed, but continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user