From 57debe42527b7ae7dc4687948a4511711bc1f43c Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:24:34 -0600 Subject: [PATCH 1/7] feat: add shared_vehicle_ids schema and repository methods (refs #31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add migration 004_add_shared_vehicle_ids.sql with UUID array column and GIN index - Update DocumentRecord interface to include sharedVehicleIds field - Add sharedVehicleIds to CreateDocumentBody and UpdateDocumentBody schemas - Update repository mapDocumentRecord() to map shared_vehicle_ids from database - Update insert() and batchInsert() to handle sharedVehicleIds - Update updateMetadata() to support sharedVehicleIds updates - Add addSharedVehicle() method using atomic array_append() - Add removeSharedVehicle() method using atomic array_remove() - Add listByVehicle() method to query by primary or shared vehicle 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../documents/data/documents.repository.ts | 69 +++++++++++++++++-- .../documents/domain/documents.types.ts | 3 + .../migrations/004_add_shared_vehicle_ids.sql | 18 +++++ 3 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 backend/src/features/documents/migrations/004_add_shared_vehicle_ids.sql diff --git a/backend/src/features/documents/data/documents.repository.ts b/backend/src/features/documents/data/documents.repository.ts index e0d101e..6d46542 100644 --- a/backend/src/features/documents/data/documents.repository.ts +++ b/backend/src/features/documents/data/documents.repository.ts @@ -28,6 +28,7 @@ export class DocumentsRepository { expirationDate: row.expiration_date, emailNotifications: row.email_notifications, scanForMaintenance: row.scan_for_maintenance, + sharedVehicleIds: row.shared_vehicle_ids || [], createdAt: row.created_at, updatedAt: row.updated_at, deletedAt: row.deleted_at @@ -50,11 +51,12 @@ export class DocumentsRepository { expirationDate?: string | null; emailNotifications?: boolean; scanForMaintenance?: boolean; + sharedVehicleIds?: string[]; }): Promise { const res = await this.db.query( `INSERT INTO documents ( - id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications, scan_for_maintenance - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications, scan_for_maintenance, shared_vehicle_ids + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING *`, [ doc.id, @@ -68,6 +70,7 @@ export class DocumentsRepository { doc.expirationDate ?? null, doc.emailNotifications ?? false, doc.scanForMaintenance ?? false, + doc.sharedVehicleIds ?? [], ] ); return this.mapDocumentRecord(res.rows[0]); @@ -103,6 +106,7 @@ export class DocumentsRepository { expirationDate?: string | null; emailNotifications?: boolean; scanForMaintenance?: boolean; + sharedVehicleIds?: string[]; }>, client?: any ): Promise { @@ -128,17 +132,18 @@ export class DocumentsRepository { doc.issuedDate ?? null, doc.expirationDate ?? null, doc.emailNotifications ?? false, - doc.scanForMaintenance ?? false + doc.scanForMaintenance ?? false, + doc.sharedVehicleIds ?? [] ]; - const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`; + const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`; placeholders.push(placeholder); values.push(...docParams); }); const query = ` INSERT INTO documents ( - id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications, scan_for_maintenance + id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications, scan_for_maintenance, shared_vehicle_ids ) VALUES ${placeholders.join(', ')} RETURNING * @@ -152,7 +157,7 @@ export class DocumentsRepository { await this.db.query(`UPDATE documents SET deleted_at = NOW() WHERE id = $1 AND user_id = $2`, [id, userId]); } - async updateMetadata(id: string, userId: string, patch: Partial>): Promise { + async updateMetadata(id: string, userId: string, patch: Partial>): Promise { const fields: string[] = []; const params: any[] = []; let i = 1; @@ -163,6 +168,7 @@ export class DocumentsRepository { if (patch.expirationDate !== undefined) { fields.push(`expiration_date = $${i++}`); params.push(patch.expirationDate); } if (patch.emailNotifications !== undefined) { fields.push(`email_notifications = $${i++}`); params.push(patch.emailNotifications); } if (patch.scanForMaintenance !== undefined) { fields.push(`scan_for_maintenance = $${i++}`); params.push(patch.scanForMaintenance); } + if (patch.sharedVehicleIds !== undefined) { fields.push(`shared_vehicle_ids = $${i++}`); params.push(patch.sharedVehicleIds); } if (!fields.length) return this.findById(id, userId); params.push(id, userId); const sql = `UPDATE documents SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} AND deleted_at IS NULL RETURNING *`; @@ -187,5 +193,56 @@ export class DocumentsRepository { ); return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null; } + + // ======================== + // Shared Vehicle Operations (Atomic) + // ======================== + + /** + * Atomically add a vehicle to the shared_vehicle_ids array. + * Uses PostgreSQL array_append() to avoid race conditions. + */ + async addSharedVehicle(docId: string, userId: string, vehicleId: string): Promise { + const res = await this.db.query( + `UPDATE documents + SET shared_vehicle_ids = array_append(shared_vehicle_ids, $1::uuid) + WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL + AND NOT ($1::uuid = ANY(shared_vehicle_ids)) + RETURNING *`, + [vehicleId, docId, userId] + ); + return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null; + } + + /** + * Atomically remove a vehicle from the shared_vehicle_ids array. + * Uses PostgreSQL array_remove() to avoid race conditions. + */ + async removeSharedVehicle(docId: string, userId: string, vehicleId: string): Promise { + const res = await this.db.query( + `UPDATE documents + SET shared_vehicle_ids = array_remove(shared_vehicle_ids, $1::uuid) + WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL + RETURNING *`, + [vehicleId, docId, userId] + ); + return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null; + } + + /** + * List all documents associated with a vehicle (either as primary or shared). + * Returns documents where vehicle_id = vehicleId OR vehicleId = ANY(shared_vehicle_ids). + */ + async listByVehicle(userId: string, vehicleId: string): Promise { + const res = await this.db.query( + `SELECT * FROM documents + WHERE user_id = $1 + AND deleted_at IS NULL + AND (vehicle_id = $2 OR $2::uuid = ANY(shared_vehicle_ids)) + ORDER BY created_at DESC`, + [userId, vehicleId] + ); + return res.rows.map(row => this.mapDocumentRecord(row)); + } } diff --git a/backend/src/features/documents/domain/documents.types.ts b/backend/src/features/documents/domain/documents.types.ts index a748cad..164c0c1 100644 --- a/backend/src/features/documents/domain/documents.types.ts +++ b/backend/src/features/documents/domain/documents.types.ts @@ -22,6 +22,7 @@ export interface DocumentRecord { expirationDate?: string | null; emailNotifications?: boolean; scanForMaintenance?: boolean; + sharedVehicleIds: string[]; createdAt: string; updatedAt: string; deletedAt?: string | null; @@ -38,6 +39,7 @@ export const CreateDocumentBodySchema = z.object({ expirationDate: z.string().optional(), emailNotifications: z.boolean().optional(), scanForMaintenance: z.boolean().optional(), + sharedVehicleIds: z.array(z.string().uuid()).optional(), }); export type CreateDocumentBody = z.infer; @@ -49,6 +51,7 @@ export const UpdateDocumentBodySchema = z.object({ expirationDate: z.string().nullable().optional(), emailNotifications: z.boolean().optional(), scanForMaintenance: z.boolean().optional(), + sharedVehicleIds: z.array(z.string().uuid()).optional(), }); export type UpdateDocumentBody = z.infer; diff --git a/backend/src/features/documents/migrations/004_add_shared_vehicle_ids.sql b/backend/src/features/documents/migrations/004_add_shared_vehicle_ids.sql new file mode 100644 index 0000000..24c6238 --- /dev/null +++ b/backend/src/features/documents/migrations/004_add_shared_vehicle_ids.sql @@ -0,0 +1,18 @@ +-- Migration: Add shared_vehicle_ids array column for cross-vehicle document sharing +-- Issue: #31 +-- Allows a document to be shared with multiple vehicles beyond its primary vehicle_id + +-- Add shared_vehicle_ids column with default empty array +ALTER TABLE documents + ADD COLUMN shared_vehicle_ids UUID[] DEFAULT '{}' NOT NULL; + +-- Add GIN index for efficient array membership queries +-- This allows fast lookups of "which documents are shared with vehicle X" +CREATE INDEX idx_documents_shared_vehicle_ids ON documents USING GIN (shared_vehicle_ids array_ops); + +-- Example usage: +-- 1. Find all documents shared with a specific vehicle: +-- SELECT * FROM documents WHERE 'vehicle-uuid-here' = ANY(shared_vehicle_ids); +-- +-- 2. Find documents by primary OR shared vehicle: +-- SELECT * FROM documents WHERE vehicle_id = 'uuid' OR 'uuid' = ANY(shared_vehicle_ids); From 5dbc17e28dbae11e9de49e53bccdf62aebec3214 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:28:00 -0600 Subject: [PATCH 2/7] feat: add document-vehicle API endpoints and context-aware delete (refs #31) Updates documents backend service and API to support multi-vehicle insurance documents: - Service: createDocument/updateDocument validate and handle sharedVehicleIds for insurance docs - Service: addVehicleToDocument validates ownership and adds vehicles to shared array - Service: removeVehicleFromDocument with context-aware delete logic: - Shared vehicle only: remove from array - Primary with no shared: soft delete document - Primary with shared: promote first shared to primary - Service: getDocumentsByVehicle returns all docs for a vehicle (primary or shared) - Controller: Added handlers for listByVehicle, addVehicle, removeVehicle with proper error handling - Routes: Added POST/DELETE /documents/:id/vehicles/:vehicleId and GET /documents/by-vehicle/:vehicleId - Validation: Added DocumentVehicleParamsSchema for vehicle management routes Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../documents/api/documents.controller.ts | 159 ++++++++++++++++++ .../documents/api/documents.routes.ts | 26 +-- .../documents/api/documents.validation.ts | 5 + .../documents/domain/documents.service.ts | 117 +++++++++++++ 4 files changed, 297 insertions(+), 10 deletions(-) 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]) { From e558fdf8f99bc04554f2a0de0bbbbd2caf960fb1 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:31:03 -0600 Subject: [PATCH 3/7] feat: add frontend document-vehicle API client and hooks (refs #31) - Update DocumentRecord interface to include sharedVehicleIds array - Add optional sharedVehicleIds to Create/UpdateDocumentRequest types - Add documentsApi.listByVehicle() method for fetching by vehicle - Add documentsApi.addSharedVehicle() for linking vehicles - Add documentsApi.removeVehicleFromDocument() for unlinking - Add useDocumentsByVehicle() query hook with vehicle filter - Add useAddSharedVehicle() mutation with optimistic updates - Add useRemoveVehicleFromDocument() mutation with optimistic updates - Ensure query invalidation includes both documents and documents-by-vehicle keys - Update test mocks to include sharedVehicleIds field - Fix optimistic update in useCreateDocument to include new fields Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../features/documents/api/documents.api.ts | 11 ++ .../components/DocumentPreview.test.tsx | 3 + .../features/documents/hooks/useDocuments.ts | 123 ++++++++++++++++++ .../mobile/DocumentsMobileScreen.test.tsx | 2 + .../documents/types/documents.types.ts | 3 + 5 files changed, 142 insertions(+) diff --git a/frontend/src/features/documents/api/documents.api.ts b/frontend/src/features/documents/api/documents.api.ts index a6d7ef8..4913d67 100644 --- a/frontend/src/features/documents/api/documents.api.ts +++ b/frontend/src/features/documents/api/documents.api.ts @@ -47,5 +47,16 @@ export const documentsApi = { // Return a blob for inline preview / download const res = await apiClient.get(`/documents/${id}/download`, { responseType: 'blob' }); return res.data as Blob; + }, + async listByVehicle(vehicleId: string) { + const res = await apiClient.get(`/documents/by-vehicle/${vehicleId}`); + return res.data; + }, + async addSharedVehicle(docId: string, vehicleId: string) { + const res = await apiClient.post(`/documents/${docId}/vehicles/${vehicleId}`); + return res.data; + }, + async removeVehicleFromDocument(docId: string, vehicleId: string) { + await apiClient.delete(`/documents/${docId}/vehicles/${vehicleId}`); } }; diff --git a/frontend/src/features/documents/components/DocumentPreview.test.tsx b/frontend/src/features/documents/components/DocumentPreview.test.tsx index bf0a215..d0ac8d1 100644 --- a/frontend/src/features/documents/components/DocumentPreview.test.tsx +++ b/frontend/src/features/documents/components/DocumentPreview.test.tsx @@ -30,6 +30,7 @@ describe('DocumentPreview', () => { documentType: 'insurance', title: 'Insurance Document', contentType: 'application/pdf', + sharedVehicleIds: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; @@ -41,6 +42,7 @@ describe('DocumentPreview', () => { documentType: 'registration', title: 'Registration Photo', contentType: 'image/jpeg', + sharedVehicleIds: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; @@ -52,6 +54,7 @@ describe('DocumentPreview', () => { documentType: 'insurance', title: 'Text Document', contentType: 'text/plain', + sharedVehicleIds: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; diff --git a/frontend/src/features/documents/hooks/useDocuments.ts b/frontend/src/features/documents/hooks/useDocuments.ts index 17c1ba4..6f12797 100644 --- a/frontend/src/features/documents/hooks/useDocuments.ts +++ b/frontend/src/features/documents/hooks/useDocuments.ts @@ -50,6 +50,9 @@ export function useCreateDocument() { fileHash: null, issuedDate: newDocument.issuedDate || null, expirationDate: newDocument.expirationDate || null, + emailNotifications: newDocument.emailNotifications, + scanForMaintenance: newDocument.scanForMaintenance, + sharedVehicleIds: newDocument.sharedVehicleIds || [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), deletedAt: null, @@ -225,3 +228,123 @@ export function useUploadDocument(id: string) { networkMode: 'offlineFirst', }); } + +export function useDocumentsByVehicle(vehicleId?: string) { + const query = useQuery({ + queryKey: ['documents-by-vehicle', vehicleId], + queryFn: () => documentsApi.listByVehicle(vehicleId!), + enabled: !!vehicleId, + networkMode: 'offlineFirst', + }); + return query; +} + +export function useAddSharedVehicle() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ docId, vehicleId }: { docId: string; vehicleId: string }) => + documentsApi.addSharedVehicle(docId, vehicleId), + onMutate: async ({ docId, vehicleId }) => { + // Cancel outgoing refetches + await qc.cancelQueries({ queryKey: ['document', docId] }); + await qc.cancelQueries({ queryKey: ['documents'] }); + await qc.cancelQueries({ queryKey: ['documents-by-vehicle'] }); + + // Snapshot previous values + const previousDocument = qc.getQueryData(['document', docId]); + const previousDocuments = qc.getQueryData(['documents']); + + // Optimistically update individual document + qc.setQueryData(['document', docId], (old: DocumentRecord | undefined) => { + if (!old) return old; + return { + ...old, + sharedVehicleIds: [...old.sharedVehicleIds, vehicleId], + updatedAt: new Date().toISOString(), + }; + }); + + // Optimistically update documents list + qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => { + if (!old) return old; + return old.map(doc => + doc.id === docId + ? { ...doc, sharedVehicleIds: [...doc.sharedVehicleIds, vehicleId], updatedAt: new Date().toISOString() } + : doc + ); + }); + + return { previousDocument, previousDocuments }; + }, + onError: (_err, { docId }, context) => { + // Rollback on error + if (context?.previousDocument) { + qc.setQueryData(['document', docId], context.previousDocument); + } + if (context?.previousDocuments) { + qc.setQueryData(['documents'], context.previousDocuments); + } + }, + onSettled: () => { + // Refetch to ensure consistency + qc.invalidateQueries({ queryKey: ['documents'] }); + qc.invalidateQueries({ queryKey: ['documents-by-vehicle'] }); + }, + networkMode: 'offlineFirst', + }); +} + +export function useRemoveVehicleFromDocument() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ docId, vehicleId }: { docId: string; vehicleId: string }) => + documentsApi.removeVehicleFromDocument(docId, vehicleId), + onMutate: async ({ docId, vehicleId }) => { + // Cancel outgoing refetches + await qc.cancelQueries({ queryKey: ['document', docId] }); + await qc.cancelQueries({ queryKey: ['documents'] }); + await qc.cancelQueries({ queryKey: ['documents-by-vehicle'] }); + + // Snapshot previous values + const previousDocument = qc.getQueryData(['document', docId]); + const previousDocuments = qc.getQueryData(['documents']); + + // Optimistically update individual document + qc.setQueryData(['document', docId], (old: DocumentRecord | undefined) => { + if (!old) return old; + return { + ...old, + sharedVehicleIds: old.sharedVehicleIds.filter(id => id !== vehicleId), + updatedAt: new Date().toISOString(), + }; + }); + + // Optimistically update documents list + qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => { + if (!old) return old; + return old.map(doc => + doc.id === docId + ? { ...doc, sharedVehicleIds: doc.sharedVehicleIds.filter(id => id !== vehicleId), updatedAt: new Date().toISOString() } + : doc + ); + }); + + return { previousDocument, previousDocuments }; + }, + onError: (_err, { docId }, context) => { + // Rollback on error + if (context?.previousDocument) { + qc.setQueryData(['document', docId], context.previousDocument); + } + if (context?.previousDocuments) { + qc.setQueryData(['documents'], context.previousDocuments); + } + }, + onSettled: () => { + // Refetch to ensure consistency + qc.invalidateQueries({ queryKey: ['documents'] }); + qc.invalidateQueries({ queryKey: ['documents-by-vehicle'] }); + }, + networkMode: 'offlineFirst', + }); +} diff --git a/frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx b/frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx index 0cfd5c8..d2b3766 100644 --- a/frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx +++ b/frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx @@ -31,6 +31,7 @@ describe('DocumentsMobileScreen', () => { vehicleId: 'vehicle-1', documentType: 'insurance', title: 'Car Insurance', + sharedVehicleIds: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }, @@ -40,6 +41,7 @@ describe('DocumentsMobileScreen', () => { vehicleId: 'vehicle-2', documentType: 'registration', title: 'Vehicle Registration', + sharedVehicleIds: [], createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', }, diff --git a/frontend/src/features/documents/types/documents.types.ts b/frontend/src/features/documents/types/documents.types.ts index 71effdf..cefd38a 100644 --- a/frontend/src/features/documents/types/documents.types.ts +++ b/frontend/src/features/documents/types/documents.types.ts @@ -18,6 +18,7 @@ export interface DocumentRecord { expirationDate?: string | null; emailNotifications?: boolean; scanForMaintenance?: boolean; + sharedVehicleIds: string[]; createdAt: string; updatedAt: string; deletedAt?: string | null; @@ -33,6 +34,7 @@ export interface CreateDocumentRequest { expirationDate?: string; emailNotifications?: boolean; scanForMaintenance?: boolean; + sharedVehicleIds?: string[]; } export interface UpdateDocumentRequest { @@ -43,5 +45,6 @@ export interface UpdateDocumentRequest { expirationDate?: string | null; emailNotifications?: boolean; scanForMaintenance?: boolean; + sharedVehicleIds?: string[]; } From 8968cad80536b85d634c51e7b94e83da15bef1ac Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:34:02 -0600 Subject: [PATCH 4/7] feat: display vehicle names instead of UUIDs in document views (refs #31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created shared utility getVehicleLabel() for consistent vehicle display - Updated DocumentsPage to show vehicle names with clickable links - Added "Shared with X vehicles" indicator for multi-vehicle docs - Updated DocumentDetailPage with vehicle name and shared vehicle list - Updated DocumentsMobileScreen with vehicle names and "Shared" indicator - All vehicle names link to vehicle detail pages - Mobile-first with 44px touch targets on all links 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../mobile/DocumentsMobileScreen.tsx | 26 ++++++++-- .../documents/pages/DocumentDetailPage.tsx | 38 ++++++++++++++- .../documents/pages/DocumentsPage.tsx | 48 ++++++++++++++----- .../features/documents/utils/vehicleLabel.ts | 11 +++++ 4 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 frontend/src/features/documents/utils/vehicleLabel.ts diff --git a/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx b/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx index da0aaa7..fc2a42c 100644 --- a/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx +++ b/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useRef, useMemo } from 'react'; import { useAuth0 } from '@auth0/auth0-react'; import { isAxiosError } from 'axios'; import { useNavigate } from 'react-router-dom'; @@ -7,6 +7,8 @@ import { useDocumentsList } from '../hooks/useDocuments'; import { useUploadWithProgress } from '../hooks/useUploadWithProgress'; import { Button } from '../../../shared-minimal/components/Button'; import { AddDocumentDialog } from '../components/AddDocumentDialog'; +import { useVehicles } from '../../vehicles/hooks/useVehicles'; +import { getVehicleLabel } from '../utils/vehicleLabel'; export const DocumentsMobileScreen: React.FC = () => { console.log('[DocumentsMobileScreen] Component initializing'); @@ -17,12 +19,15 @@ export const DocumentsMobileScreen: React.FC = () => { // Data hooks (unconditional per React rules) const { data, isLoading, error } = useDocumentsList(); + const { data: vehicles } = useVehicles(); const inputRef = useRef(null); const [currentId, setCurrentId] = React.useState(null); const upload = useUploadWithProgress(currentId || ''); const navigate = useNavigate(); const [isAddOpen, setIsAddOpen] = React.useState(false); + const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]); + const triggerUpload = (docId: string) => { try { setCurrentId(docId); @@ -170,14 +175,25 @@ export const DocumentsMobileScreen: React.FC = () => { {!isLoading && !hasError && data && data.length > 0 && (
{data.map((doc) => { - const vehicleLabel = doc.vehicleId ? `${doc.vehicleId.slice(0, 8)}...` : '—'; + const vehicle = vehiclesMap.get(doc.vehicleId); + const vehicleLabel = getVehicleLabel(vehicle); + const isShared = doc.sharedVehicleIds.length > 0; return ( -
+
{doc.title}
-
{doc.documentType} • {vehicleLabel}
+
+ {doc.documentType} + {isShared && ' • Shared'} +
+
-
+
{upload.isPending && currentId === doc.id && ( diff --git a/frontend/src/features/documents/pages/DocumentDetailPage.tsx b/frontend/src/features/documents/pages/DocumentDetailPage.tsx index d151e2a..d8da13f 100644 --- a/frontend/src/features/documents/pages/DocumentDetailPage.tsx +++ b/frontend/src/features/documents/pages/DocumentDetailPage.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useRef, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useAuth0 } from '@auth0/auth0-react'; import { isAxiosError } from 'axios'; @@ -8,15 +8,21 @@ import { useDocument } from '../hooks/useDocuments'; import { useUploadWithProgress } from '../hooks/useUploadWithProgress'; import { documentsApi } from '../api/documents.api'; import { DocumentPreview } from '../components/DocumentPreview'; +import { useVehicle, useVehicles } from '../../vehicles/hooks/useVehicles'; +import { getVehicleLabel } from '../utils/vehicleLabel'; export const DocumentDetailPage: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0(); const { data: doc, isLoading, error } = useDocument(id); + const { data: vehicle } = useVehicle(doc?.vehicleId || ''); + const { data: vehicles } = useVehicles(); const inputRef = useRef(null); const upload = useUploadWithProgress(id!); + const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]); + const handleDownload = async () => { if (!id) return; const blob = await documentsApi.download(id); @@ -141,7 +147,35 @@ export const DocumentDetailPage: React.FC = () => {

{doc.title}

Type: {doc.documentType}
-
Vehicle: {doc.vehicleId}
+
+ Vehicle: + +
+ {doc.sharedVehicleIds.length > 0 && ( +
+
Shared with:
+
    + {doc.sharedVehicleIds.map((vehicleId) => { + const sharedVehicle = vehiclesMap.get(vehicleId); + return ( +
  • + +
  • + ); + })} +
+
+ )}
diff --git a/frontend/src/features/documents/pages/DocumentsPage.tsx b/frontend/src/features/documents/pages/DocumentsPage.tsx index 4aed867..8cc7d07 100644 --- a/frontend/src/features/documents/pages/DocumentsPage.tsx +++ b/frontend/src/features/documents/pages/DocumentsPage.tsx @@ -1,18 +1,23 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useAuth0 } from '@auth0/auth0-react'; import { useDocumentsList, useDeleteDocument } from '../hooks/useDocuments'; import { Card } from '../../../shared-minimal/components/Card'; import { Button } from '../../../shared-minimal/components/Button'; import { useNavigate } from 'react-router-dom'; import { AddDocumentDialog } from '../components/AddDocumentDialog'; +import { useVehicles } from '../../vehicles/hooks/useVehicles'; +import { getVehicleLabel } from '../utils/vehicleLabel'; export const DocumentsPage: React.FC = () => { const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0(); const { data, isLoading, error } = useDocumentsList(); + const { data: vehicles } = useVehicles(); const navigate = useNavigate(); const removeDoc = useDeleteDocument(); const [isAddOpen, setIsAddOpen] = React.useState(false); + const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]); + // Show loading while auth is initializing if (authLoading) { return ( @@ -124,19 +129,36 @@ export const DocumentsPage: React.FC = () => { {!isLoading && !error && data && data.length > 0 && (
- {data.map((doc) => ( - -
-
{doc.title}
-
Type: {doc.documentType}
-
Vehicle: {doc.vehicleId}
-
- - + {data.map((doc) => { + const vehicle = vehiclesMap.get(doc.vehicleId); + const vehicleLabel = getVehicleLabel(vehicle); + return ( + +
+
{doc.title}
+
Type: {doc.documentType}
+
+ Vehicle: + +
+ {doc.sharedVehicleIds.length > 0 && ( +
+ Shared with {doc.sharedVehicleIds.length} other vehicle{doc.sharedVehicleIds.length > 1 ? 's' : ''} +
+ )} +
+ + +
-
- - ))} + + ); + })}
)}
diff --git a/frontend/src/features/documents/utils/vehicleLabel.ts b/frontend/src/features/documents/utils/vehicleLabel.ts new file mode 100644 index 0000000..b040fbb --- /dev/null +++ b/frontend/src/features/documents/utils/vehicleLabel.ts @@ -0,0 +1,11 @@ +import type { Vehicle } from '../../vehicles/types/vehicles.types'; + +export const getVehicleLabel = (vehicle: Vehicle | undefined): string => { + if (!vehicle) return 'Unknown Vehicle'; + if (vehicle.nickname?.trim()) return vehicle.nickname.trim(); + const parts = [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean); + const primary = parts.join(' ').trim(); + if (primary.length > 0) return primary; + if (vehicle.vin?.length > 0) return vehicle.vin; + return vehicle.id.slice(0, 8) + '...'; +}; From b71e2cff3c590c55b1c3cf3f68e802b7d11f89e0 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:38:20 -0600 Subject: [PATCH 5/7] feat: add document edit functionality with multi-vehicle support (refs #31) Implemented comprehensive document editing capabilities: 1. Created EditDocumentDialog component: - Responsive MUI Dialog with fullScreen on mobile - Wraps DocumentForm in edit mode - Proper close handlers with refetch 2. Enhanced DocumentForm to support edit mode: - Added mode prop ('create' | 'edit') - Pre-populate all fields from initialValues - Use useUpdateDocument hook when in edit mode - Multi-select for shared vehicles (insurance only) - Vehicle and document type disabled in edit mode - Optional file upload in edit mode - Dynamic button text (Create/Save Changes) 3. Updated DocumentDetailPage: - Added Edit button with proper touch targets - Integrated EditDocumentDialog - Refetch document on successful edit Mobile-first implementation: - All touch targets >= 44px - Dialog goes fullScreen on mobile - Form fields stack on mobile - Shared vehicle checkboxes have min-h-[44px] - Buttons use flex-wrap for mobile overflow Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../documents/components/DocumentForm.tsx | 237 ++++++++++++++---- .../components/EditDocumentDialog.tsx | 75 ++++++ .../documents/pages/DocumentDetailPage.tsx | 19 +- 3 files changed, 281 insertions(+), 50 deletions(-) create mode 100644 frontend/src/features/documents/components/EditDocumentDialog.tsx diff --git a/frontend/src/features/documents/components/DocumentForm.tsx b/frontend/src/features/documents/components/DocumentForm.tsx index 129e3ed..41bfdb8 100644 --- a/frontend/src/features/documents/components/DocumentForm.tsx +++ b/frontend/src/features/documents/components/DocumentForm.tsx @@ -6,41 +6,81 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { DatePicker } from '@mui/x-date-pickers/DatePicker'; import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; import dayjs from 'dayjs'; -import { useCreateDocument } from '../hooks/useDocuments'; +import { useCreateDocument, useUpdateDocument, useAddSharedVehicle, useRemoveVehicleFromDocument } from '../hooks/useDocuments'; import { documentsApi } from '../api/documents.api'; -import type { DocumentType } from '../types/documents.types'; +import type { DocumentType, DocumentRecord } from '../types/documents.types'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; import type { Vehicle } from '../../vehicles/types/vehicles.types'; import { useTierAccess } from '../../../core/hooks/useTierAccess'; interface DocumentFormProps { + mode?: 'create' | 'edit'; + initialValues?: Partial; + documentId?: string; onSuccess?: () => void; onCancel?: () => void; } -export const DocumentForm: React.FC = ({ onSuccess, onCancel }) => { - const [documentType, setDocumentType] = React.useState('insurance'); - const [vehicleID, setVehicleID] = React.useState(''); - const [title, setTitle] = React.useState(''); - const [notes, setNotes] = React.useState(''); +export const DocumentForm: React.FC = ({ + mode = 'create', + initialValues, + documentId, + onSuccess, + onCancel +}) => { + const [documentType, setDocumentType] = React.useState( + initialValues?.documentType || 'insurance' + ); + const [vehicleID, setVehicleID] = React.useState(initialValues?.vehicleId || ''); + const [title, setTitle] = React.useState(initialValues?.title || ''); + const [notes, setNotes] = React.useState(initialValues?.notes || ''); // Insurance fields - const [insuranceCompany, setInsuranceCompany] = React.useState(''); - const [policyNumber, setPolicyNumber] = React.useState(''); - const [effectiveDate, setEffectiveDate] = React.useState(''); - const [expirationDate, setExpirationDate] = React.useState(''); - const [bodilyInjuryPerson, setBodilyInjuryPerson] = React.useState(''); - const [bodilyInjuryIncident, setBodilyInjuryIncident] = React.useState(''); - const [propertyDamage, setPropertyDamage] = React.useState(''); - const [premium, setPremium] = React.useState(''); + const [insuranceCompany, setInsuranceCompany] = React.useState( + initialValues?.details?.insuranceCompany || '' + ); + const [policyNumber, setPolicyNumber] = React.useState( + initialValues?.details?.policyNumber || '' + ); + const [effectiveDate, setEffectiveDate] = React.useState( + initialValues?.issuedDate || '' + ); + const [expirationDate, setExpirationDate] = React.useState( + initialValues?.expirationDate || '' + ); + const [bodilyInjuryPerson, setBodilyInjuryPerson] = React.useState( + initialValues?.details?.bodilyInjuryPerson || '' + ); + const [bodilyInjuryIncident, setBodilyInjuryIncident] = React.useState( + initialValues?.details?.bodilyInjuryIncident || '' + ); + const [propertyDamage, setPropertyDamage] = React.useState( + initialValues?.details?.propertyDamage || '' + ); + const [premium, setPremium] = React.useState( + initialValues?.details?.premium ? String(initialValues.details.premium) : '' + ); // Registration fields - const [licensePlate, setLicensePlate] = React.useState(''); - const [registrationExpirationDate, setRegistrationExpirationDate] = React.useState(''); - const [registrationCost, setRegistrationCost] = React.useState(''); + const [licensePlate, setLicensePlate] = React.useState( + initialValues?.details?.licensePlate || '' + ); + const [registrationExpirationDate, setRegistrationExpirationDate] = React.useState( + initialValues?.expirationDate || '' + ); + const [registrationCost, setRegistrationCost] = React.useState( + initialValues?.details?.cost ? String(initialValues.details.cost) : '' + ); // Manual fields - const [scanForMaintenance, setScanForMaintenance] = React.useState(false); + const [scanForMaintenance, setScanForMaintenance] = React.useState( + initialValues?.scanForMaintenance || false + ); + + // Shared vehicles for edit mode + const [selectedSharedVehicles, setSelectedSharedVehicles] = React.useState( + initialValues?.sharedVehicleIds || [] + ); const [file, setFile] = React.useState(null); const [uploadProgress, setUploadProgress] = React.useState(0); @@ -49,6 +89,9 @@ export const DocumentForm: React.FC = ({ onSuccess, onCancel const { data: vehicles } = useVehicles(); const create = useCreateDocument(); + const update = useUpdateDocument(documentId || ''); + const addSharedVehicle = useAddSharedVehicle(); + const removeSharedVehicle = useRemoveVehicleFromDocument(); const { hasAccess } = useTierAccess(); const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule'); @@ -67,6 +110,7 @@ export const DocumentForm: React.FC = ({ onSuccess, onCancel setRegistrationExpirationDate(''); setRegistrationCost(''); setScanForMaintenance(false); + setSelectedSharedVehicles([]); setFile(null); setUploadProgress(0); setError(null); @@ -106,44 +150,100 @@ export const DocumentForm: React.FC = ({ onSuccess, onCancel } // Manual type: no details or dates, just scanForMaintenance flag - const created = await create.mutateAsync({ - vehicleId: vehicleID, - documentType: documentType, - title: title.trim(), - notes: notes.trim() || undefined, - details: Object.keys(details).length > 0 ? details : undefined, - issuedDate: issued_date, - expirationDate: expiration_date, - scanForMaintenance: documentType === 'manual' ? scanForMaintenance : undefined, - }); + if (mode === 'edit' && documentId) { + // Update existing document + await update.mutateAsync({ + title: title.trim(), + notes: notes.trim() || null, + details: Object.keys(details).length > 0 ? details : undefined, + issuedDate: issued_date || null, + expirationDate: expiration_date || null, + scanForMaintenance: documentType === 'manual' ? scanForMaintenance : undefined, + }); - if (file) { - const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']); - if (!file.type || !allowed.has(file.type)) { - setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'); - return; + // Handle shared vehicles only for insurance documents + if (documentType === 'insurance') { + const currentSharedVehicleIds = initialValues?.sharedVehicleIds || []; + + // Add new shared vehicles + const vehiclesToAdd = selectedSharedVehicles.filter( + id => !currentSharedVehicleIds.includes(id) + ); + for (const vehicleId of vehiclesToAdd) { + await addSharedVehicle.mutateAsync({ docId: documentId, vehicleId }); + } + + // Remove unselected shared vehicles + const vehiclesToRemove = currentSharedVehicleIds.filter( + id => !selectedSharedVehicles.includes(id) + ); + for (const vehicleId of vehiclesToRemove) { + await removeSharedVehicle.mutateAsync({ docId: documentId, vehicleId }); + } } - try { - await documentsApi.uploadWithProgress(created.id, file, (pct) => setUploadProgress(pct)); - } catch (uploadErr: any) { - const status = uploadErr?.response?.status; - if (status === 415) { + + // Handle file upload if a new file was selected + if (file) { + const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']); + if (!file.type || !allowed.has(file.type)) { setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'); return; } - setError(uploadErr?.message || 'Failed to upload file'); - return; + try { + await documentsApi.uploadWithProgress(documentId, file, (pct) => setUploadProgress(pct)); + } catch (uploadErr: any) { + const status = uploadErr?.response?.status; + if (status === 415) { + setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'); + return; + } + setError(uploadErr?.message || 'Failed to upload file'); + return; + } } - } - resetForm(); - onSuccess?.(); + onSuccess?.(); + } else { + // Create new document + const created = await create.mutateAsync({ + vehicleId: vehicleID, + documentType: documentType, + title: title.trim(), + notes: notes.trim() || undefined, + details: Object.keys(details).length > 0 ? details : undefined, + issuedDate: issued_date, + expirationDate: expiration_date, + scanForMaintenance: documentType === 'manual' ? scanForMaintenance : undefined, + }); + + if (file) { + const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']); + if (!file.type || !allowed.has(file.type)) { + setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'); + return; + } + try { + await documentsApi.uploadWithProgress(created.id, file, (pct) => setUploadProgress(pct)); + } catch (uploadErr: any) { + const status = uploadErr?.response?.status; + if (status === 415) { + setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'); + return; + } + setError(uploadErr?.message || 'Failed to upload file'); + return; + } + } + + resetForm(); + onSuccess?.(); + } } catch (err: any) { const status = err?.response?.status; if (status === 415) { setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'); } else { - setError(err?.message || 'Failed to create document'); + setError(err?.message || `Failed to ${mode === 'edit' ? 'update' : 'create'} document`); } } finally { setUploadProgress(0); @@ -159,6 +259,17 @@ export const DocumentForm: React.FC = ({ onSuccess, onCancel return v.id.slice(0, 8) + '...'; }; + // Filter out the primary vehicle from shared vehicle options + const sharedVehicleOptions = (vehicles || []).filter(v => v.id !== vehicleID); + + const handleSharedVehicleToggle = (vehicleId: string) => { + setSelectedSharedVehicles(prev => + prev.includes(vehicleId) + ? prev.filter(id => id !== vehicleId) + : [...prev, vehicleId] + ); + }; + return (
@@ -170,6 +281,7 @@ export const DocumentForm: React.FC = ({ onSuccess, onCancel value={vehicleID} onChange={(e) => setVehicleID(e.target.value)} required + disabled={mode === 'edit'} > {(vehicles || []).map((v: Vehicle) => ( @@ -184,6 +296,7 @@ export const DocumentForm: React.FC = ({ onSuccess, onCancel className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi" value={documentType} onChange={(e) => setDocumentType(e.target.value as DocumentType)} + disabled={mode === 'edit'} > @@ -307,6 +420,32 @@ export const DocumentForm: React.FC = ({ onSuccess, onCancel onChange={(e) => setPremium(e.target.value)} />
+ + {mode === 'edit' && sharedVehicleOptions.length > 0 && ( +
+ +
+ {sharedVehicleOptions.map((v) => ( + + ))} +
+
+ )} )} @@ -393,7 +532,9 @@ export const DocumentForm: React.FC = ({ onSuccess, onCancel
- +
= ({ onSuccess, onCancel )}
- +
diff --git a/frontend/src/features/documents/components/EditDocumentDialog.tsx b/frontend/src/features/documents/components/EditDocumentDialog.tsx new file mode 100644 index 0000000..76d9221 --- /dev/null +++ b/frontend/src/features/documents/components/EditDocumentDialog.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + IconButton, + useMediaQuery, + useTheme, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { DocumentForm } from './DocumentForm'; +import type { DocumentRecord } from '../types/documents.types'; + +interface EditDocumentDialogProps { + open: boolean; + onClose: () => void; + document: DocumentRecord; +} + +export const EditDocumentDialog: React.FC = ({ + open, + onClose, + document, +}) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down('sm')); + + return ( + + {isSmall && ( + theme.palette.grey[500], + zIndex: 1, + }} + > + + + )} + + Edit Document + + + { + onClose(); + }} + onCancel={onClose} + /> + + + ); +}; + +export default EditDocumentDialog; diff --git a/frontend/src/features/documents/pages/DocumentDetailPage.tsx b/frontend/src/features/documents/pages/DocumentDetailPage.tsx index d8da13f..95977b4 100644 --- a/frontend/src/features/documents/pages/DocumentDetailPage.tsx +++ b/frontend/src/features/documents/pages/DocumentDetailPage.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useMemo } from 'react'; +import React, { useRef, useMemo, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useAuth0 } from '@auth0/auth0-react'; import { isAxiosError } from 'axios'; @@ -8,6 +8,7 @@ import { useDocument } from '../hooks/useDocuments'; import { useUploadWithProgress } from '../hooks/useUploadWithProgress'; import { documentsApi } from '../api/documents.api'; import { DocumentPreview } from '../components/DocumentPreview'; +import { EditDocumentDialog } from '../components/EditDocumentDialog'; import { useVehicle, useVehicles } from '../../vehicles/hooks/useVehicles'; import { getVehicleLabel } from '../utils/vehicleLabel'; @@ -15,11 +16,12 @@ export const DocumentDetailPage: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0(); - const { data: doc, isLoading, error } = useDocument(id); + const { data: doc, isLoading, error, refetch } = useDocument(id); const { data: vehicle } = useVehicle(doc?.vehicleId || ''); const { data: vehicles } = useVehicles(); const inputRef = useRef(null); const upload = useUploadWithProgress(id!); + const [isEditOpen, setIsEditOpen] = useState(false); const vehiclesMap = useMemo(() => new Map(vehicles?.map(v => [v.id, v]) || []), [vehicles]); @@ -179,9 +181,10 @@ export const DocumentDetailPage: React.FC = () => {
-
+
+
{upload.isPending && (
Uploading... {upload.progress}%
@@ -195,6 +198,16 @@ export const DocumentDetailPage: React.FC = () => { )}
+ {doc && ( + { + setIsEditOpen(false); + refetch(); + }} + document={doc} + /> + )}
); }; From bdb329f7c3c72cc310b1a2ae7e9456dc989030a2 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:41:52 -0600 Subject: [PATCH 6/7] feat: add context-aware document delete from vehicle screen (refs #31) - Created DeleteDocumentConfirmDialog with context-aware messaging: - Primary vehicle with no shares: Full delete - Shared vehicle: Remove association only - Primary vehicle with shares: Full delete (affects all) - Integrated documents display in VehicleDetailPage records table - Added delete button per document with 44px touch target - Document deletion uses appropriate backend calls based on context - Mobile-friendly dialog with responsive design Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../DeleteDocumentConfirmDialog.tsx | 137 ++++++++++++++++++ .../vehicles/pages/VehicleDetailPage.tsx | 104 ++++++++++++- 2 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 frontend/src/features/documents/components/DeleteDocumentConfirmDialog.tsx diff --git a/frontend/src/features/documents/components/DeleteDocumentConfirmDialog.tsx b/frontend/src/features/documents/components/DeleteDocumentConfirmDialog.tsx new file mode 100644 index 0000000..d7959ca --- /dev/null +++ b/frontend/src/features/documents/components/DeleteDocumentConfirmDialog.tsx @@ -0,0 +1,137 @@ +/** + * @ai-summary Context-aware document delete confirmation dialog + * Shows different messages based on whether document is being removed from vehicle or fully deleted + */ + +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + useMediaQuery, + useTheme, +} from '@mui/material'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import type { DocumentRecord } from '../types/documents.types'; + +export interface DeleteDocumentConfirmDialogProps { + open: boolean; + onClose: () => void; + onConfirm: (fullDelete: boolean) => void; + document: DocumentRecord | null; + vehicleId: string | null; +} + +export const DeleteDocumentConfirmDialog: React.FC = ({ + open, + onClose, + onConfirm, + document, + vehicleId, +}) => { + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + + if (!document || !vehicleId) { + return null; + } + + // Determine delete context + const isPrimaryVehicle = document.vehicleId === vehicleId; + const isSharedVehicle = document.sharedVehicleIds.includes(vehicleId); + const sharedCount = document.sharedVehicleIds.length; + + let title: string; + let message: string; + let fullDelete: boolean; + let actionText: string; + + if (isPrimaryVehicle && sharedCount === 0) { + // Primary vehicle with no shares: Full delete + title = 'Delete Document?'; + message = 'This will permanently delete this document. This action cannot be undone.'; + fullDelete = true; + actionText = 'Delete'; + } else if (isSharedVehicle) { + // Shared vehicle: Remove association only + title = 'Remove Document from Vehicle?'; + message = `This will remove the document from this vehicle. The document will remain shared with ${sharedCount - 1 === 1 ? '1 other vehicle' : `${sharedCount - 1} other vehicles`}.`; + fullDelete = false; + actionText = 'Remove'; + } else if (isPrimaryVehicle && sharedCount > 0) { + // Primary vehicle with shares: Full delete (affects all) + title = 'Delete Document?'; + message = `This document is shared with ${sharedCount === 1 ? '1 other vehicle' : `${sharedCount} other vehicles`}. Deleting it will remove it from all vehicles. This action cannot be undone.`; + fullDelete = true; + actionText = 'Delete'; + } else { + // Fallback case (should not happen) + title = 'Delete Document?'; + message = 'This will delete this document. This action cannot be undone.'; + fullDelete = true; + actionText = 'Delete'; + } + + const handleConfirm = () => { + onConfirm(fullDelete); + }; + + return ( + + + + + + {title} + + + + + + {message} + + + + {document.title} + + + {document.documentType.charAt(0).toUpperCase() + document.documentType.slice(1)} + {document.expirationDate && ` • Expires: ${new Date(document.expirationDate).toLocaleDateString()}`} + + + + + + + + + ); +}; diff --git a/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx b/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx index e5a5894..a2469ba 100644 --- a/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx +++ b/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx @@ -4,12 +4,13 @@ import React, { useMemo, useState, useEffect } from 'react'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; -import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Dialog, DialogTitle, DialogContent, useMediaQuery } from '@mui/material'; +import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Dialog, DialogTitle, DialogContent, useMediaQuery, IconButton } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import EditIcon from '@mui/icons-material/Edit'; import LocalGasStationIcon from '@mui/icons-material/LocalGasStation'; import BuildIcon from '@mui/icons-material/Build'; +import DeleteIcon from '@mui/icons-material/Delete'; import { Vehicle } from '../types/vehicles.types'; import { vehiclesApi } from '../api/vehicles.api'; import { Card } from '../../../shared-minimal/components/Card'; @@ -23,6 +24,9 @@ import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm'; // Unit conversions now handled by backend import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api'; import { OwnershipCostsList } from '../../ownership-costs'; +import { useDocumentsByVehicle, useDeleteDocument, useRemoveVehicleFromDocument } from '../../documents/hooks/useDocuments'; +import { DeleteDocumentConfirmDialog } from '../../documents/components/DeleteDocumentConfirmDialog'; +import type { DocumentRecord } from '../../documents/types/documents.types'; const DetailField: React.FC<{ label: string; @@ -53,9 +57,14 @@ export const VehicleDetailPage: React.FC = () => { const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All'); const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(id); + const { data: documents, isLoading: isDocumentsLoading } = useDocumentsByVehicle(id); + const { mutateAsync: deleteDocument } = useDeleteDocument(); + const { mutateAsync: removeVehicleFromDocument } = useRemoveVehicleFromDocument(); const queryClient = useQueryClient(); const [editingLog, setEditingLog] = useState(null); const [showAddDialog, setShowAddDialog] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [documentToDelete, setDocumentToDelete] = useState(null); const isSmallScreen = useMediaQuery('(max-width:600px)'); // Unit conversions now handled by backend @@ -105,8 +114,24 @@ export const VehicleDetailPage: React.FC = () => { list.push({ id: log.id, type: 'Fuel Logs', date: log.dateTime, summary, amount }); } } + + // Add documents to records + if (documents && Array.isArray(documents)) { + for (const doc of documents) { + const parts: string[] = []; + parts.push(doc.title); + parts.push(doc.documentType.charAt(0).toUpperCase() + doc.documentType.slice(1)); + if (doc.expirationDate) { + parts.push(`Expires: ${new Date(doc.expirationDate).toLocaleDateString()}`); + } + const summary = parts.join(' • '); + const date = doc.issuedDate || doc.createdAt; + list.push({ id: doc.id, type: 'Documents', date, summary, amount: undefined }); + } + } + return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); - }, [fuelLogs]); + }, [fuelLogs, documents]); const filteredRecords = useMemo(() => { if (recordFilter === 'All') return records; @@ -208,6 +233,46 @@ export const VehicleDetailPage: React.FC = () => { const log = (fuelLogs as FuelLogResponse[] | undefined)?.find(l => l.id === recId) || null; setEditingLog(log); } + // Documents are handled via delete button, not row click + }; + + const handleDeleteDocumentClick = (docId: string, event: React.MouseEvent) => { + event.stopPropagation(); // Prevent row click + const doc = documents?.find(d => d.id === docId) || null; + if (doc) { + setDocumentToDelete(doc); + setDeleteDialogOpen(true); + } + }; + + const handleDeleteConfirm = async (fullDelete: boolean) => { + if (!documentToDelete || !id) return; + + try { + if (fullDelete) { + // Full delete + await deleteDocument(documentToDelete.id); + console.log('Document deleted permanently'); + } else { + // Remove vehicle association only + await removeVehicleFromDocument({ docId: documentToDelete.id, vehicleId: id }); + console.log('Document removed from vehicle'); + } + + // Invalidate queries to refresh data + queryClient.invalidateQueries({ queryKey: ['documents-by-vehicle', id] }); + queryClient.invalidateQueries({ queryKey: ['documents'] }); + + setDeleteDialogOpen(false); + setDocumentToDelete(null); + } catch (err) { + console.error('Error deleting/removing document:', err); + } + }; + + const handleDeleteCancel = () => { + setDeleteDialogOpen(false); + setDocumentToDelete(null); }; const handleCloseEdit = () => setEditingLog(null); @@ -398,29 +463,43 @@ export const VehicleDetailPage: React.FC = () => { Type Summary Amount + Actions - {isFuelLoading && ( + {(isFuelLoading || isDocumentsLoading) && ( - + Loading records… )} - {!isFuelLoading && filteredRecords.length === 0 && ( + {!isFuelLoading && !isDocumentsLoading && filteredRecords.length === 0 && ( - + No records found for this filter. )} - {!isFuelLoading && filteredRecords.map((rec) => ( - handleRowClick(rec.id, rec.type)}> + {!isFuelLoading && !isDocumentsLoading && filteredRecords.map((rec) => ( + handleRowClick(rec.id, rec.type)}> {new Date(rec.date).toLocaleDateString()} {rec.type} {rec.summary} {rec.amount || '—'} + + {rec.type === 'Documents' && ( + handleDeleteDocumentClick(rec.id, e)} + sx={{ minWidth: 44, minHeight: 44 }} + aria-label="Delete document" + > + + + )} + ))} @@ -461,6 +540,15 @@ export const VehicleDetailPage: React.FC = () => { + + {/* Delete Document Confirmation Dialog */} + ); From 354ce47fc4da234d4064b91d14318fae1b0e7812 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:45:51 -0600 Subject: [PATCH 7/7] fix: remove debug console.log statements (refs #31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/features/vehicles/pages/VehicleDetailPage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx b/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx index a2469ba..7c895f3 100644 --- a/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx +++ b/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx @@ -252,11 +252,9 @@ export const VehicleDetailPage: React.FC = () => { if (fullDelete) { // Full delete await deleteDocument(documentToDelete.id); - console.log('Document deleted permanently'); } else { // Remove vehicle association only await removeVehicleFromDocument({ docId: documentToDelete.id, vehicleId: id }); - console.log('Document removed from vehicle'); } // Invalidate queries to refresh data