feat: Document feature enhancements (#31) #32
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
Reference in New Issue
Block a user