Files
motovaultpro/backend/src/features/user-import/api/user-import.controller.ts
Eric Gullickson dd3b58e061
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m40s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 24s
Deploy to Staging / Verify Staging (pull_request) Successful in 10s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
fix: migrate remaining controllers from Auth0 sub to UUID identity (refs #220)
16 controllers still used request.user.sub (Auth0 ID) instead of
request.userContext.userId (UUID) after the user_id column migration,
causing 500 errors on all authenticated endpoints including dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:38:46 -06:00

236 lines
7.3 KiB
TypeScript

/**
* @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.userContext?.userId;
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.userContext?.userId;
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
}
}
}
}
}