/** * @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 { 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 = {}; 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 { 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 } } } } }