/** * @ai-summary Catalog API controller for platform vehicle data management * @ai-context Handles HTTP requests for CRUD operations on makes, models, years, trims, engines */ import { FastifyRequest, FastifyReply } from 'fastify'; import { VehicleCatalogService } from '../domain/vehicle-catalog.service'; import { CatalogImportService } from '../domain/catalog-import.service'; import { logger } from '../../../core/logging/logger'; export class CatalogController { private importService: CatalogImportService | null = null; constructor(private catalogService: VehicleCatalogService) {} setImportService(importService: CatalogImportService): void { this.importService = importService; } // MAKES ENDPOINTS async getMakes(_request: FastifyRequest, reply: FastifyReply): Promise { try { const makes = await this.catalogService.getAllMakes(); reply.code(200).send({ makes }); } catch (error) { logger.error('Error getting makes', { error }); reply.code(500).send({ error: 'Failed to retrieve makes' }); } } async createMake( request: FastifyRequest<{ Body: { name: string } }>, reply: FastifyReply ): Promise { try { const { name } = request.body; const actorId = request.userContext?.userId || 'unknown'; if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Make name is required' }); return; } const make = await this.catalogService.createMake(name.trim(), actorId); reply.code(201).send(make); } catch (error) { logger.error('Error creating make', { error }); reply.code(500).send({ error: 'Failed to create make' }); } } async updateMake( request: FastifyRequest<{ Params: { makeId: string }; Body: { name: string } }>, reply: FastifyReply ): Promise { try { const makeId = parseInt(request.params.makeId); const { name } = request.body; const actorId = request.userContext?.userId || 'unknown'; if (isNaN(makeId)) { reply.code(400).send({ error: 'Invalid make ID' }); return; } if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Make name is required' }); return; } const make = await this.catalogService.updateMake(makeId, name.trim(), actorId); reply.code(200).send(make); } catch (error: any) { logger.error('Error updating make', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to update make' }); } } } async deleteMake( request: FastifyRequest<{ Params: { makeId: string } }>, reply: FastifyReply ): Promise { try { const makeId = parseInt(request.params.makeId); const actorId = request.userContext?.userId || 'unknown'; if (isNaN(makeId)) { reply.code(400).send({ error: 'Invalid make ID' }); return; } await this.catalogService.deleteMake(makeId, actorId); reply.code(204).send(); } catch (error: any) { logger.error('Error deleting make', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else if (error.message?.includes('existing models')) { reply.code(409).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to delete make' }); } } } // MODELS ENDPOINTS async getModels( request: FastifyRequest<{ Params: { makeId: string } }>, reply: FastifyReply ): Promise { try { const makeId = parseInt(request.params.makeId); if (isNaN(makeId)) { reply.code(400).send({ error: 'Invalid make ID' }); return; } const models = await this.catalogService.getModelsByMake(makeId); reply.code(200).send({ models }); } catch (error) { logger.error('Error getting models', { error }); reply.code(500).send({ error: 'Failed to retrieve models' }); } } async createModel( request: FastifyRequest<{ Body: { makeId: number; name: string } }>, reply: FastifyReply ): Promise { try { const { makeId, name } = request.body; const actorId = request.userContext?.userId || 'unknown'; const parsedMakeId = Number(makeId); if (!Number.isFinite(parsedMakeId) || parsedMakeId <= 0) { reply.code(400).send({ error: 'Valid make ID is required' }); return; } if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Make ID and model name are required' }); return; } const model = await this.catalogService.createModel(parsedMakeId, name.trim(), actorId); reply.code(201).send(model); } catch (error: any) { logger.error('Error creating model', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to create model' }); } } } async updateModel( request: FastifyRequest<{ Params: { modelId: string }; Body: { makeId: number; name: string } }>, reply: FastifyReply ): Promise { try { const modelId = parseInt(request.params.modelId); const { makeId, name } = request.body; const actorId = request.userContext?.userId || 'unknown'; const parsedMakeId = Number(makeId); if (isNaN(modelId)) { reply.code(400).send({ error: 'Invalid model ID' }); return; } if (!Number.isFinite(parsedMakeId) || parsedMakeId <= 0) { reply.code(400).send({ error: 'Valid make ID is required' }); return; } if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Make ID and model name are required' }); return; } const model = await this.catalogService.updateModel(modelId, parsedMakeId, name.trim(), actorId); reply.code(200).send(model); } catch (error: any) { logger.error('Error updating model', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to update model' }); } } } async deleteModel( request: FastifyRequest<{ Params: { modelId: string } }>, reply: FastifyReply ): Promise { try { const modelId = parseInt(request.params.modelId); const actorId = request.userContext?.userId || 'unknown'; if (isNaN(modelId)) { reply.code(400).send({ error: 'Invalid model ID' }); return; } await this.catalogService.deleteModel(modelId, actorId); reply.code(204).send(); } catch (error: any) { logger.error('Error deleting model', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else if (error.message?.includes('existing years')) { reply.code(409).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to delete model' }); } } } // YEARS ENDPOINTS async getYears( request: FastifyRequest<{ Params: { modelId: string } }>, reply: FastifyReply ): Promise { try { const modelId = parseInt(request.params.modelId); if (isNaN(modelId)) { reply.code(400).send({ error: 'Invalid model ID' }); return; } const years = await this.catalogService.getYearsByModel(modelId); reply.code(200).send({ years }); } catch (error) { logger.error('Error getting years', { error }); reply.code(500).send({ error: 'Failed to retrieve years' }); } } async createYear( request: FastifyRequest<{ Body: { modelId: number; year: number } }>, reply: FastifyReply ): Promise { try { const { modelId, year } = request.body; const actorId = request.userContext?.userId || 'unknown'; const parsedModelId = Number(modelId); const parsedYear = Number(year); if (!Number.isFinite(parsedModelId) || parsedModelId <= 0) { reply.code(400).send({ error: 'Valid model ID is required' }); return; } if (!Number.isInteger(parsedYear) || parsedYear < 1900 || parsedYear > 2100) { reply.code(400).send({ error: 'Valid model ID and year (1900-2100) are required' }); return; } const yearData = await this.catalogService.createYear(parsedModelId, parsedYear, actorId); reply.code(201).send(yearData); } catch (error: any) { logger.error('Error creating year', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to create year' }); } } } async updateYear( request: FastifyRequest<{ Params: { yearId: string }; Body: { modelId: number; year: number } }>, reply: FastifyReply ): Promise { try { const yearId = parseInt(request.params.yearId); const { modelId, year } = request.body; const actorId = request.userContext?.userId || 'unknown'; const parsedModelId = Number(modelId); const parsedYear = Number(year); if (isNaN(yearId)) { reply.code(400).send({ error: 'Invalid year ID' }); return; } if (!Number.isFinite(parsedModelId) || parsedModelId <= 0) { reply.code(400).send({ error: 'Valid model ID is required' }); return; } if (!Number.isInteger(parsedYear) || parsedYear < 1900 || parsedYear > 2100) { reply.code(400).send({ error: 'Valid model ID and year (1900-2100) are required' }); return; } const yearData = await this.catalogService.updateYear(yearId, parsedModelId, parsedYear, actorId); reply.code(200).send(yearData); } catch (error: any) { logger.error('Error updating year', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to update year' }); } } } async deleteYear( request: FastifyRequest<{ Params: { yearId: string } }>, reply: FastifyReply ): Promise { try { const yearId = parseInt(request.params.yearId); const actorId = request.userContext?.userId || 'unknown'; if (isNaN(yearId)) { reply.code(400).send({ error: 'Invalid year ID' }); return; } await this.catalogService.deleteYear(yearId, actorId); reply.code(204).send(); } catch (error: any) { logger.error('Error deleting year', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else if (error.message?.includes('existing trims')) { reply.code(409).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to delete year' }); } } } // TRIMS ENDPOINTS async getTrims( request: FastifyRequest<{ Params: { yearId: string } }>, reply: FastifyReply ): Promise { try { const yearId = parseInt(request.params.yearId); if (isNaN(yearId)) { reply.code(400).send({ error: 'Invalid year ID' }); return; } const trims = await this.catalogService.getTrimsByYear(yearId); reply.code(200).send({ trims }); } catch (error) { logger.error('Error getting trims', { error }); reply.code(500).send({ error: 'Failed to retrieve trims' }); } } async createTrim( request: FastifyRequest<{ Body: { yearId: number; name: string } }>, reply: FastifyReply ): Promise { try { const { yearId, name } = request.body; const actorId = request.userContext?.userId || 'unknown'; const parsedYearId = Number(yearId); if (!Number.isFinite(parsedYearId) || parsedYearId <= 0) { reply.code(400).send({ error: 'Valid year ID is required' }); return; } if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Year ID and trim name are required' }); return; } const trim = await this.catalogService.createTrim(parsedYearId, name.trim(), actorId); reply.code(201).send(trim); } catch (error: any) { logger.error('Error creating trim', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to create trim' }); } } } async updateTrim( request: FastifyRequest<{ Params: { trimId: string }; Body: { yearId: number; name: string } }>, reply: FastifyReply ): Promise { try { const trimId = parseInt(request.params.trimId); const { yearId, name } = request.body; const actorId = request.userContext?.userId || 'unknown'; const parsedYearId = Number(yearId); if (isNaN(trimId)) { reply.code(400).send({ error: 'Invalid trim ID' }); return; } if (!Number.isFinite(parsedYearId) || parsedYearId <= 0) { reply.code(400).send({ error: 'Valid year ID is required' }); return; } if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Year ID and trim name are required' }); return; } const trim = await this.catalogService.updateTrim(trimId, parsedYearId, name.trim(), actorId); reply.code(200).send(trim); } catch (error: any) { logger.error('Error updating trim', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to update trim' }); } } } async deleteTrim( request: FastifyRequest<{ Params: { trimId: string } }>, reply: FastifyReply ): Promise { try { const trimId = parseInt(request.params.trimId); const actorId = request.userContext?.userId || 'unknown'; if (isNaN(trimId)) { reply.code(400).send({ error: 'Invalid trim ID' }); return; } await this.catalogService.deleteTrim(trimId, actorId); reply.code(204).send(); } catch (error: any) { logger.error('Error deleting trim', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else if (error.message?.includes('existing engines')) { reply.code(409).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to delete trim' }); } } } // ENGINES ENDPOINTS async getEngines( request: FastifyRequest<{ Params: { trimId: string } }>, reply: FastifyReply ): Promise { try { const trimId = parseInt(request.params.trimId); if (isNaN(trimId)) { reply.code(400).send({ error: 'Invalid trim ID' }); return; } const engines = await this.catalogService.getEnginesByTrim(trimId); reply.code(200).send({ engines }); } catch (error) { logger.error('Error getting engines', { error }); reply.code(500).send({ error: 'Failed to retrieve engines' }); } } async createEngine( request: FastifyRequest<{ Body: { trimId: number; name: string; description?: string } }>, reply: FastifyReply ): Promise { try { const { trimId, name, description } = request.body; const actorId = request.userContext?.userId || 'unknown'; const parsedTrimId = Number(trimId); if (!Number.isFinite(parsedTrimId) || parsedTrimId <= 0) { reply.code(400).send({ error: 'Valid trim ID is required' }); return; } if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Trim ID and engine name are required' }); return; } const engine = await this.catalogService.createEngine(parsedTrimId, name.trim(), description, actorId); reply.code(201).send(engine); } catch (error: any) { logger.error('Error creating engine', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to create engine' }); } } } async updateEngine( request: FastifyRequest<{ Params: { engineId: string }; Body: { trimId: number; name: string; description?: string } }>, reply: FastifyReply ): Promise { try { const engineId = parseInt(request.params.engineId); const { trimId, name, description } = request.body; const actorId = request.userContext?.userId || 'unknown'; const parsedTrimId = Number(trimId); if (isNaN(engineId)) { reply.code(400).send({ error: 'Invalid engine ID' }); return; } if (!Number.isFinite(parsedTrimId) || parsedTrimId <= 0) { reply.code(400).send({ error: 'Valid trim ID is required' }); return; } if (!name || name.trim().length === 0) { reply.code(400).send({ error: 'Trim ID and engine name are required' }); return; } const engine = await this.catalogService.updateEngine( engineId, parsedTrimId, name.trim(), description, actorId ); reply.code(200).send(engine); } catch (error: any) { logger.error('Error updating engine', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to update engine' }); } } } async deleteEngine( request: FastifyRequest<{ Params: { engineId: string } }>, reply: FastifyReply ): Promise { try { const engineId = parseInt(request.params.engineId); const actorId = request.userContext?.userId || 'unknown'; if (isNaN(engineId)) { reply.code(400).send({ error: 'Invalid engine ID' }); return; } await this.catalogService.deleteEngine(engineId, actorId); reply.code(204).send(); } catch (error: any) { logger.error('Error deleting engine', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to delete engine' }); } } } // CHANGE LOG ENDPOINT async getChangeLogs( request: FastifyRequest<{ Querystring: { limit?: string; offset?: string } }>, reply: FastifyReply ): Promise { try { const limit = parseInt(request.query.limit || '100'); const offset = parseInt(request.query.offset || '0'); const result = await this.catalogService.getChangeLogs(limit, offset); reply.code(200).send(result); } catch (error) { logger.error('Error getting change logs', { error }); reply.code(500).send({ error: 'Failed to retrieve change logs' }); } } // SEARCH ENDPOINT async searchCatalog( request: FastifyRequest<{ Querystring: { q?: string; page?: string; pageSize?: string } }>, reply: FastifyReply ): Promise { try { const query = request.query.q || ''; const page = parseInt(request.query.page || '1'); const pageSize = Math.min(parseInt(request.query.pageSize || '50'), 100); // Max 100 per page if (isNaN(page) || page < 1) { reply.code(400).send({ error: 'Invalid page number' }); return; } if (isNaN(pageSize) || pageSize < 1) { reply.code(400).send({ error: 'Invalid page size' }); return; } const result = await this.catalogService.searchCatalog(query, page, pageSize); reply.code(200).send(result); } catch (error) { logger.error('Error searching catalog', { error }); reply.code(500).send({ error: 'Failed to search catalog' }); } } // CASCADE DELETE ENDPOINTS async deleteMakeCascade( request: FastifyRequest<{ Params: { makeId: string } }>, reply: FastifyReply ): Promise { try { const makeId = parseInt(request.params.makeId); const actorId = request.userContext?.userId || 'unknown'; if (isNaN(makeId)) { reply.code(400).send({ error: 'Invalid make ID' }); return; } const result = await this.catalogService.deleteMakeCascade(makeId, actorId); reply.code(200).send(result); } catch (error: any) { logger.error('Error cascade deleting make', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to cascade delete make' }); } } } async deleteModelCascade( request: FastifyRequest<{ Params: { modelId: string } }>, reply: FastifyReply ): Promise { try { const modelId = parseInt(request.params.modelId); const actorId = request.userContext?.userId || 'unknown'; if (isNaN(modelId)) { reply.code(400).send({ error: 'Invalid model ID' }); return; } const result = await this.catalogService.deleteModelCascade(modelId, actorId); reply.code(200).send(result); } catch (error: any) { logger.error('Error cascade deleting model', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to cascade delete model' }); } } } async deleteYearCascade( request: FastifyRequest<{ Params: { yearId: string } }>, reply: FastifyReply ): Promise { try { const yearId = parseInt(request.params.yearId); const actorId = request.userContext?.userId || 'unknown'; if (isNaN(yearId)) { reply.code(400).send({ error: 'Invalid year ID' }); return; } const result = await this.catalogService.deleteYearCascade(yearId, actorId); reply.code(200).send(result); } catch (error: any) { logger.error('Error cascade deleting year', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to cascade delete year' }); } } } async deleteTrimCascade( request: FastifyRequest<{ Params: { trimId: string } }>, reply: FastifyReply ): Promise { try { const trimId = parseInt(request.params.trimId); const actorId = request.userContext?.userId || 'unknown'; if (isNaN(trimId)) { reply.code(400).send({ error: 'Invalid trim ID' }); return; } const result = await this.catalogService.deleteTrimCascade(trimId, actorId); reply.code(200).send(result); } catch (error: any) { logger.error('Error cascade deleting trim', { error }); if (error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else { reply.code(500).send({ error: 'Failed to cascade delete trim' }); } } } // IMPORT/EXPORT ENDPOINTS async importPreview( request: FastifyRequest, reply: FastifyReply ): Promise { try { if (!this.importService) { reply.code(500).send({ error: 'Import service not configured' }); return; } const data = await request.file(); if (!data) { reply.code(400).send({ error: 'No file uploaded' }); return; } const buffer = await data.toBuffer(); const csvContent = buffer.toString('utf-8'); const result = await this.importService.previewImport(csvContent); reply.code(200).send(result); } catch (error: any) { logger.error('Error previewing import', { error }); reply.code(500).send({ error: error.message || 'Failed to preview import' }); } } async importApply( request: FastifyRequest<{ Body: { previewId: string } }>, reply: FastifyReply ): Promise { try { if (!this.importService) { reply.code(500).send({ error: 'Import service not configured' }); return; } const { previewId } = request.body; const actorId = request.userContext?.userId || 'unknown'; if (!previewId) { reply.code(400).send({ error: 'Preview ID is required' }); return; } const result = await this.importService.applyImport(previewId, actorId); reply.code(200).send(result); } catch (error: any) { logger.error('Error applying import', { error }); if (error.message?.includes('expired') || error.message?.includes('not found')) { reply.code(404).send({ error: error.message }); } else if (error.message?.includes('validation errors')) { reply.code(400).send({ error: error.message }); } else { reply.code(500).send({ error: error.message || 'Failed to apply import' }); } } } async exportCatalog( _request: FastifyRequest, reply: FastifyReply ): Promise { try { if (!this.importService) { reply.code(500).send({ error: 'Import service not configured' }); return; } const csvContent = await this.importService.exportCatalog(); reply .header('Content-Type', 'text/csv') .header('Content-Disposition', 'attachment; filename="vehicle-catalog.csv"') .code(200) .send(csvContent); } catch (error: any) { logger.error('Error exporting catalog', { error }); reply.code(500).send({ error: 'Failed to export catalog' }); } } // BULK DELETE ENDPOINT async bulkDeleteCatalogEntity( request: FastifyRequest<{ Params: { entity: string }; Body: { ids: number[] } }>, reply: FastifyReply ): Promise { try { const { entity } = request.params; const { ids } = request.body; const actorId = request.userContext?.userId || 'unknown'; // Validate entity type const validEntities = ['makes', 'models', 'years', 'trims', 'engines']; if (!validEntities.includes(entity)) { reply.code(400).send({ error: 'Invalid entity type', message: `Entity must be one of: ${validEntities.join(', ')}` }); return; } // Validate IDs are provided if (!ids || !Array.isArray(ids) || ids.length === 0) { reply.code(400).send({ error: 'Invalid request', message: 'At least one ID must be provided' }); return; } // Validate all IDs are valid integers const invalidIds = ids.filter(id => !Number.isInteger(id) || id <= 0); if (invalidIds.length > 0) { reply.code(400).send({ error: 'Invalid IDs', message: 'All IDs must be positive integers' }); return; } const deleted: number[] = []; const failed: Array<{ id: number; error: string }> = []; // Map entity to delete method const deleteMethodMap: Record Promise> = { makes: (id, actor) => this.catalogService.deleteMake(id, actor), models: (id, actor) => this.catalogService.deleteModel(id, actor), years: (id, actor) => this.catalogService.deleteYear(id, actor), trims: (id, actor) => this.catalogService.deleteTrim(id, actor), engines: (id, actor) => this.catalogService.deleteEngine(id, actor) }; const deleteMethod = deleteMethodMap[entity]; // Process each deletion sequentially to maintain data consistency for (const id of ids) { try { await deleteMethod(id, actorId); deleted.push(id); } catch (error: any) { logger.error(`Error deleting ${entity} in bulk operation`, { error: error.message, entity, id, actorId }); failed.push({ id, error: error.message || `Failed to delete ${entity}` }); } } const response = { deleted, failed }; // Return 207 Multi-Status if there were any failures, 204 if all succeeded if (failed.length > 0) { reply.code(207).send(response); } else { reply.code(204).send(); } } catch (error: any) { logger.error('Error in bulk delete catalog entity', { error: error.message, entity: request.params.entity, actorId: request.userContext?.userId }); reply.code(500).send({ error: 'Internal server error', message: 'Failed to process bulk deletion' }); } } }