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:
@@ -432,6 +432,165 @@ export class DocumentsController {
|
|||||||
const stream = await storage.getObjectStream(doc.storageBucket, doc.storageKey);
|
const stream = await storage.getObjectStream(doc.storageBucket, doc.storageKey);
|
||||||
return reply.send(stream);
|
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 {
|
function cryptoRandom(): string {
|
||||||
|
|||||||
@@ -22,16 +22,6 @@ export const documentsRoutes: FastifyPluginAsync = async (
|
|||||||
handler: ctrl.get.bind(ctrl)
|
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', {
|
fastify.post<{ Body: any }>('/documents', {
|
||||||
preHandler: [requireAuth],
|
preHandler: [requireAuth],
|
||||||
handler: ctrl.create.bind(ctrl)
|
handler: ctrl.create.bind(ctrl)
|
||||||
@@ -56,4 +46,20 @@ export const documentsRoutes: FastifyPluginAsync = async (
|
|||||||
preHandler: [requireAuth],
|
preHandler: [requireAuth],
|
||||||
handler: ctrl.download.bind(ctrl)
|
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)
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const ListQuerySchema = z.object({
|
|||||||
|
|
||||||
export const IdParamsSchema = z.object({ id: z.string().uuid() });
|
export const IdParamsSchema = z.object({ id: z.string().uuid() });
|
||||||
export const VehicleParamsSchema = z.object({ vehicleId: 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 CreateBodySchema = CreateDocumentBodySchema;
|
||||||
export const UpdateBodySchema = UpdateDocumentBodySchema;
|
export const UpdateBodySchema = UpdateDocumentBodySchema;
|
||||||
@@ -16,6 +20,7 @@ export const UpdateBodySchema = UpdateDocumentBodySchema;
|
|||||||
export type ListQuery = z.infer<typeof ListQuerySchema>;
|
export type ListQuery = z.infer<typeof ListQuerySchema>;
|
||||||
export type IdParams = z.infer<typeof IdParamsSchema>;
|
export type IdParams = z.infer<typeof IdParamsSchema>;
|
||||||
export type VehicleParams = z.infer<typeof VehicleParamsSchema>;
|
export type VehicleParams = z.infer<typeof VehicleParamsSchema>;
|
||||||
|
export type DocumentVehicleParams = z.infer<typeof DocumentVehicleParamsSchema>;
|
||||||
export type CreateBody = z.infer<typeof CreateBodySchema>;
|
export type CreateBody = z.infer<typeof CreateBodySchema>;
|
||||||
export type UpdateBody = z.infer<typeof UpdateBodySchema>;
|
export type UpdateBody = z.infer<typeof UpdateBodySchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ export class DocumentsService {
|
|||||||
|
|
||||||
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
|
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
|
||||||
await this.assertVehicleOwnership(userId, body.vehicleId);
|
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();
|
const id = randomUUID();
|
||||||
return this.repo.insert({
|
return this.repo.insert({
|
||||||
id,
|
id,
|
||||||
@@ -21,6 +35,7 @@ export class DocumentsService {
|
|||||||
expirationDate: body.expirationDate ?? null,
|
expirationDate: body.expirationDate ?? null,
|
||||||
emailNotifications: body.emailNotifications ?? false,
|
emailNotifications: body.emailNotifications ?? false,
|
||||||
scanForMaintenance: body.scanForMaintenance ?? false,
|
scanForMaintenance: body.scanForMaintenance ?? false,
|
||||||
|
sharedVehicleIds: body.sharedVehicleIds ?? [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +50,20 @@ export class DocumentsService {
|
|||||||
async updateDocument(userId: string, id: string, patch: UpdateDocumentBody) {
|
async updateDocument(userId: string, id: string, patch: UpdateDocumentBody) {
|
||||||
const existing = await this.repo.findById(id, userId);
|
const existing = await this.repo.findById(id, userId);
|
||||||
if (!existing) return null;
|
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') {
|
if (patch && typeof patch === 'object') {
|
||||||
return this.repo.updateMetadata(id, userId, patch as any);
|
return this.repo.updateMetadata(id, userId, patch as any);
|
||||||
}
|
}
|
||||||
@@ -45,6 +74,94 @@ export class DocumentsService {
|
|||||||
await this.repo.softDelete(id, userId);
|
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) {
|
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]);
|
const res = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
|
||||||
if (!res.rows[0]) {
|
if (!res.rows[0]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user