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] 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);