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 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-14 19:28:00 -06:00
parent 57debe4252
commit 5dbc17e28d
4 changed files with 297 additions and 10 deletions

View File

@@ -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 {

View File

@@ -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)
});
};

View File

@@ -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<typeof ListQuerySchema>;
export type IdParams = z.infer<typeof IdParamsSchema>;
export type VehicleParams = z.infer<typeof VehicleParamsSchema>;
export type DocumentVehicleParams = z.infer<typeof DocumentVehicleParamsSchema>;
export type CreateBody = z.infer<typeof CreateBodySchema>;
export type UpdateBody = z.infer<typeof UpdateBodySchema>;

View File

@@ -8,6 +8,20 @@ export class DocumentsService {
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
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<DocumentRecord | null> {
// 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<DocumentRecord | null> {
// 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<DocumentRecord[]> {
// 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]) {