Merge pull request 'feat: Document feature enhancements (#31)' (#32) from issue-31-document-enhancements into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 4m43s
Deploy to Staging / Deploy to Staging (push) Successful in 38s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
All checks were successful
Deploy to Staging / Build Images (push) Successful in 4m43s
Deploy to Staging / Deploy to Staging (push) Successful in 38s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
@@ -421,6 +421,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>;
|
||||
|
||||
|
||||
@@ -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<DocumentRecord> {
|
||||
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<DocumentRecord[]> {
|
||||
@@ -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<Pick<DocumentRecord, 'title'|'notes'|'details'|'issuedDate'|'expirationDate'|'emailNotifications'|'scanForMaintenance'>>): Promise<DocumentRecord | null> {
|
||||
async updateMetadata(id: string, userId: string, patch: Partial<Pick<DocumentRecord, 'title'|'notes'|'details'|'issuedDate'|'expirationDate'|'emailNotifications'|'scanForMaintenance'|'sharedVehicleIds'>>): Promise<DocumentRecord | null> {
|
||||
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<DocumentRecord | null> {
|
||||
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<DocumentRecord | null> {
|
||||
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<DocumentRecord[]> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,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();
|
||||
const doc = await this.repo.insert({
|
||||
id,
|
||||
@@ -24,6 +38,7 @@ export class DocumentsService {
|
||||
expirationDate: body.expirationDate ?? null,
|
||||
emailNotifications: body.emailNotifications ?? false,
|
||||
scanForMaintenance: body.scanForMaintenance ?? false,
|
||||
sharedVehicleIds: body.sharedVehicleIds ?? [],
|
||||
});
|
||||
|
||||
// Auto-create ownership_cost when insurance/registration has cost data
|
||||
@@ -102,6 +117,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') {
|
||||
const updated = await this.repo.updateMetadata(id, userId, patch as any);
|
||||
|
||||
@@ -178,6 +207,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]) {
|
||||
|
||||
@@ -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<typeof CreateDocumentBodySchema>;
|
||||
|
||||
@@ -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<typeof UpdateDocumentBodySchema>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user