diff --git a/backend/src/features/documents/api/documents.controller.ts b/backend/src/features/documents/api/documents.controller.ts index 00caa67..439298d 100644 --- a/backend/src/features/documents/api/documents.controller.ts +++ b/backend/src/features/documents/api/documents.controller.ts @@ -432,6 +432,165 @@ export class DocumentsController { const stream = await storage.getObjectStream(doc.storageBucket, doc.storageKey); return reply.send(stream); } + + async listByVehicle(request: FastifyRequest<{ Params: { vehicleId: string } }>, reply: FastifyReply) { + const userId = (request as any).user?.sub as string; + const vehicleId = request.params.vehicleId; + + logger.info('Documents by vehicle requested', { + operation: 'documents.listByVehicle', + userId, + vehicleId, + }); + + try { + const docs = await this.service.getDocumentsByVehicle(userId, vehicleId); + + logger.info('Documents by vehicle retrieved', { + operation: 'documents.listByVehicle.success', + userId, + vehicleId, + documentCount: docs.length, + }); + + return reply.code(200).send(docs); + } catch (e: any) { + if (e.statusCode === 403) { + logger.warn('Vehicle not found or not owned', { + operation: 'documents.listByVehicle.forbidden', + userId, + vehicleId, + }); + return reply.code(403).send({ error: 'Forbidden', message: e.message }); + } + throw e; + } + } + + async addVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) { + const userId = (request as any).user?.sub as string; + const { id: documentId, vehicleId } = request.params; + + logger.info('Add vehicle to document requested', { + operation: 'documents.addVehicle', + userId, + documentId, + vehicleId, + }); + + try { + const updated = await this.service.addVehicleToDocument(userId, documentId, vehicleId); + + if (!updated) { + logger.warn('Document not updated (possibly duplicate vehicle)', { + operation: 'documents.addVehicle.not_updated', + userId, + documentId, + vehicleId, + }); + return reply.code(400).send({ error: 'Bad Request', message: 'Vehicle could not be added' }); + } + + logger.info('Vehicle added to document', { + operation: 'documents.addVehicle.success', + userId, + documentId, + vehicleId, + sharedVehicleCount: updated.sharedVehicleIds.length, + }); + + return reply.code(200).send(updated); + } catch (e: any) { + if (e.statusCode === 404) { + logger.warn('Document not found for adding vehicle', { + operation: 'documents.addVehicle.not_found', + userId, + documentId, + vehicleId, + }); + return reply.code(404).send({ error: 'Not Found', message: e.message }); + } + if (e.statusCode === 400) { + logger.warn('Bad request for adding vehicle', { + operation: 'documents.addVehicle.bad_request', + userId, + documentId, + vehicleId, + reason: e.message, + }); + return reply.code(400).send({ error: 'Bad Request', message: e.message }); + } + if (e.statusCode === 403) { + logger.warn('Forbidden - vehicle not owned', { + operation: 'documents.addVehicle.forbidden', + userId, + documentId, + vehicleId, + }); + return reply.code(403).send({ error: 'Forbidden', message: e.message }); + } + throw e; + } + } + + async removeVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) { + const userId = (request as any).user?.sub as string; + const { id: documentId, vehicleId } = request.params; + + logger.info('Remove vehicle from document requested', { + operation: 'documents.removeVehicle', + userId, + documentId, + vehicleId, + }); + + try { + const updated = await this.service.removeVehicleFromDocument(userId, documentId, vehicleId); + + if (!updated) { + // Document was soft deleted + logger.info('Document soft deleted (primary vehicle removed, no shared vehicles)', { + operation: 'documents.removeVehicle.deleted', + userId, + documentId, + vehicleId, + }); + return reply.code(204).send(); + } + + logger.info('Vehicle removed from document', { + operation: 'documents.removeVehicle.success', + userId, + documentId, + vehicleId, + sharedVehicleCount: updated.sharedVehicleIds.length, + primaryVehicleId: updated.vehicleId, + }); + + return reply.code(200).send(updated); + } catch (e: any) { + if (e.statusCode === 404) { + logger.warn('Document not found for removing vehicle', { + operation: 'documents.removeVehicle.not_found', + userId, + documentId, + vehicleId, + }); + return reply.code(404).send({ error: 'Not Found', message: e.message }); + } + if (e.statusCode === 400) { + logger.warn('Bad request for removing vehicle', { + operation: 'documents.removeVehicle.bad_request', + userId, + documentId, + vehicleId, + reason: e.message, + }); + return reply.code(400).send({ error: 'Bad Request', message: e.message }); + } + throw e; + } + } } function cryptoRandom(): string { diff --git a/backend/src/features/documents/api/documents.routes.ts b/backend/src/features/documents/api/documents.routes.ts index 2fe5529..4e55e0a 100644 --- a/backend/src/features/documents/api/documents.routes.ts +++ b/backend/src/features/documents/api/documents.routes.ts @@ -22,16 +22,6 @@ export const documentsRoutes: FastifyPluginAsync = async ( handler: ctrl.get.bind(ctrl) }); - fastify.get<{ Params: any }>('/documents/vehicle/:vehicleId', { - preHandler: [requireAuth], - handler: async (req, reply) => { - const userId = (req as any).user?.sub as string; - const query = { vehicleId: (req.params as any).vehicleId }; - const docs = await ctrl['service'].listDocuments(userId, query); - return reply.code(200).send(docs); - } - }); - fastify.post<{ Body: any }>('/documents', { preHandler: [requireAuth], handler: ctrl.create.bind(ctrl) @@ -56,4 +46,20 @@ export const documentsRoutes: FastifyPluginAsync = async ( preHandler: [requireAuth], handler: ctrl.download.bind(ctrl) }); + + // Vehicle management routes + fastify.get<{ Params: any }>('/documents/by-vehicle/:vehicleId', { + preHandler: [requireAuth], + handler: ctrl.listByVehicle.bind(ctrl) + }); + + fastify.post<{ Params: any }>('/documents/:id/vehicles/:vehicleId', { + preHandler: [requireAuth], + handler: ctrl.addVehicle.bind(ctrl) + }); + + fastify.delete<{ Params: any }>('/documents/:id/vehicles/:vehicleId', { + preHandler: [requireAuth], + handler: ctrl.removeVehicle.bind(ctrl) + }); }; diff --git a/backend/src/features/documents/api/documents.validation.ts b/backend/src/features/documents/api/documents.validation.ts index 3bec2b1..b083da8 100644 --- a/backend/src/features/documents/api/documents.validation.ts +++ b/backend/src/features/documents/api/documents.validation.ts @@ -9,6 +9,10 @@ export const ListQuerySchema = z.object({ export const IdParamsSchema = z.object({ id: z.string().uuid() }); export const VehicleParamsSchema = z.object({ vehicleId: z.string().uuid() }); +export const DocumentVehicleParamsSchema = z.object({ + id: z.string().uuid(), + vehicleId: z.string().uuid() +}); export const CreateBodySchema = CreateDocumentBodySchema; export const UpdateBodySchema = UpdateDocumentBodySchema; @@ -16,6 +20,7 @@ export const UpdateBodySchema = UpdateDocumentBodySchema; export type ListQuery = z.infer; export type IdParams = z.infer; export type VehicleParams = z.infer; +export type DocumentVehicleParams = z.infer; export type CreateBody = z.infer; export type UpdateBody = z.infer; diff --git a/backend/src/features/documents/domain/documents.service.ts b/backend/src/features/documents/domain/documents.service.ts index eeff00b..4301af7 100644 --- a/backend/src/features/documents/domain/documents.service.ts +++ b/backend/src/features/documents/domain/documents.service.ts @@ -8,6 +8,20 @@ export class DocumentsService { async createDocument(userId: string, body: CreateDocumentBody): Promise { await this.assertVehicleOwnership(userId, body.vehicleId); + + // Validate shared vehicles if provided (insurance type only) + if (body.sharedVehicleIds && body.sharedVehicleIds.length > 0) { + if (body.documentType !== 'insurance') { + const err: any = new Error('Shared vehicles are only supported for insurance documents'); + err.statusCode = 400; + throw err; + } + // Validate ownership of all shared vehicles + for (const vid of body.sharedVehicleIds) { + await this.assertVehicleOwnership(userId, vid); + } + } + const id = randomUUID(); return this.repo.insert({ id, @@ -21,6 +35,7 @@ export class DocumentsService { expirationDate: body.expirationDate ?? null, emailNotifications: body.emailNotifications ?? false, scanForMaintenance: body.scanForMaintenance ?? false, + sharedVehicleIds: body.sharedVehicleIds ?? [], }); } @@ -35,6 +50,20 @@ export class DocumentsService { async updateDocument(userId: string, id: string, patch: UpdateDocumentBody) { const existing = await this.repo.findById(id, userId); if (!existing) return null; + + // Validate shared vehicles if provided (insurance type only) + if (patch.sharedVehicleIds !== undefined) { + if (existing.documentType !== 'insurance') { + const err: any = new Error('Shared vehicles are only supported for insurance documents'); + err.statusCode = 400; + throw err; + } + // Validate ownership of all shared vehicles + for (const vid of patch.sharedVehicleIds) { + await this.assertVehicleOwnership(userId, vid); + } + } + if (patch && typeof patch === 'object') { return this.repo.updateMetadata(id, userId, patch as any); } @@ -45,6 +74,94 @@ export class DocumentsService { await this.repo.softDelete(id, userId); } + async addVehicleToDocument(userId: string, docId: string, vehicleId: string): Promise { + // Validate document exists and is owned by user + const doc = await this.repo.findById(docId, userId); + if (!doc) { + const err: any = new Error('Document not found'); + err.statusCode = 404; + throw err; + } + + // Only insurance documents support shared vehicles + if (doc.documentType !== 'insurance') { + const err: any = new Error('Shared vehicles are only supported for insurance documents'); + err.statusCode = 400; + throw err; + } + + // Validate vehicle ownership + await this.assertVehicleOwnership(userId, vehicleId); + + // Check if vehicle is already the primary vehicle + if (doc.vehicleId === vehicleId) { + const err: any = new Error('Vehicle is already the primary vehicle for this document'); + err.statusCode = 400; + throw err; + } + + // Add to shared vehicles (repository handles duplicate check) + return this.repo.addSharedVehicle(docId, userId, vehicleId); + } + + async removeVehicleFromDocument(userId: string, docId: string, vehicleId: string): Promise { + // Validate document exists and is owned by user + const doc = await this.repo.findById(docId, userId); + if (!doc) { + const err: any = new Error('Document not found'); + err.statusCode = 404; + throw err; + } + + // Context-aware delete logic + const isSharedVehicle = doc.sharedVehicleIds.includes(vehicleId); + const isPrimaryVehicle = doc.vehicleId === vehicleId; + + if (!isSharedVehicle && !isPrimaryVehicle) { + const err: any = new Error('Vehicle is not associated with this document'); + err.statusCode = 400; + throw err; + } + + // Case 1: Removing from shared vehicles only + if (isSharedVehicle && !isPrimaryVehicle) { + return this.repo.removeSharedVehicle(docId, userId, vehicleId); + } + + // Case 2: Removing primary vehicle with no shared vehicles -> soft delete document + if (isPrimaryVehicle && doc.sharedVehicleIds.length === 0) { + await this.repo.softDelete(docId, userId); + return null; + } + + // Case 3: Removing primary vehicle with shared vehicles -> promote first shared to primary + if (isPrimaryVehicle && doc.sharedVehicleIds.length > 0) { + const newPrimaryId = doc.sharedVehicleIds[0]; + const remainingShared = doc.sharedVehicleIds.slice(1); + + // Update primary vehicle and remaining shared vehicles + return this.repo.updateMetadata(docId, userId, { + sharedVehicleIds: remainingShared, + }).then(async () => { + // Update vehicle_id separately as it's not part of the metadata update + const res = await pool.query( + 'UPDATE documents SET vehicle_id = $1 WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL RETURNING *', + [newPrimaryId, docId, userId] + ); + if (!res.rows[0]) return null; + return this.repo.findById(docId, userId); + }); + } + + return null; + } + + async getDocumentsByVehicle(userId: string, vehicleId: string): Promise { + // Validate vehicle ownership + await this.assertVehicleOwnership(userId, vehicleId); + return this.repo.listByVehicle(userId, vehicleId); + } + private async assertVehicleOwnership(userId: string, vehicleId: string) { const res = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]); if (!res.rows[0]) {