import { Pool } from 'pg'; import pool from '../../../core/config/database'; import type { DocumentRecord, DocumentType } from '../domain/documents.types'; export class DocumentsRepository { constructor(private readonly db: Pool = pool) {} // ======================== // Row Mapper // ======================== private mapDocumentRecord(row: any): DocumentRecord { return { id: row.id, userId: row.user_id, vehicleId: row.vehicle_id, documentType: row.document_type, title: row.title, notes: row.notes, details: row.details, storageBucket: row.storage_bucket, storageKey: row.storage_key, fileName: row.file_name, contentType: row.content_type, fileSize: row.file_size, fileHash: row.file_hash, issuedDate: row.issued_date, 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 }; } // ======================== // CRUD Operations // ======================== async insert(doc: { id: string; userId: string; vehicleId: string; documentType: DocumentType; title: string; notes?: string | null; details?: any; issuedDate?: string | null; 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, shared_vehicle_ids ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING *`, [ doc.id, doc.userId, doc.vehicleId, doc.documentType, doc.title, doc.notes ?? null, doc.details ?? null, doc.issuedDate ?? null, doc.expirationDate ?? null, doc.emailNotifications ?? false, doc.scanForMaintenance ?? false, doc.sharedVehicleIds ?? [], ] ); return this.mapDocumentRecord(res.rows[0]); } async findById(id: string, userId: string): Promise { const res = await this.db.query(`SELECT * FROM documents WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`, [id, userId]); return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null; } async listByUser(userId: string, filters?: { vehicleId?: string; type?: DocumentType; expiresBefore?: string }): Promise { const conds: string[] = ['user_id = $1', 'deleted_at IS NULL']; const params: any[] = [userId]; let i = 2; if (filters?.vehicleId) { conds.push(`vehicle_id = $${i++}`); params.push(filters.vehicleId); } if (filters?.type) { conds.push(`document_type = $${i++}`); params.push(filters.type); } if (filters?.expiresBefore) { conds.push(`expiration_date <= $${i++}`); params.push(filters.expiresBefore); } const sql = `SELECT * FROM documents WHERE ${conds.join(' AND ')} ORDER BY created_at DESC`; const res = await this.db.query(sql, params); return res.rows.map(row => this.mapDocumentRecord(row)); } async batchInsert( documents: Array<{ id: string; userId: string; vehicleId: string; documentType: DocumentType; title: string; notes?: string | null; details?: any; issuedDate?: string | null; expirationDate?: string | null; emailNotifications?: boolean; scanForMaintenance?: boolean; sharedVehicleIds?: string[]; }>, client?: any ): Promise { if (documents.length === 0) { return []; } // Multi-value INSERT for performance (avoids N round-trips) const queryClient = client || this.db; const placeholders: string[] = []; const values: any[] = []; let paramCount = 1; documents.forEach((doc) => { const docParams = [ doc.id, doc.userId, doc.vehicleId, doc.documentType, doc.title, doc.notes ?? null, doc.details ?? null, doc.issuedDate ?? null, doc.expirationDate ?? null, doc.emailNotifications ?? false, doc.scanForMaintenance ?? false, doc.sharedVehicleIds ?? [] ]; 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, shared_vehicle_ids ) VALUES ${placeholders.join(', ')} RETURNING * `; const result = await queryClient.query(query, values); return result.rows.map((row: any) => this.mapDocumentRecord(row)); } async softDelete(id: string, userId: string): Promise { 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 { const fields: string[] = []; const params: any[] = []; let i = 1; if (patch.title !== undefined) { fields.push(`title = $${i++}`); params.push(patch.title); } if (patch.notes !== undefined) { fields.push(`notes = $${i++}`); params.push(patch.notes); } if (patch.details !== undefined) { fields.push(`details = $${i++}`); params.push(patch.details); } if (patch.issuedDate !== undefined) { fields.push(`issued_date = $${i++}`); params.push(patch.issuedDate); } 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 *`; const res = await this.db.query(sql, params); return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null; } async updateStorageMeta(id: string, userId: string, meta: { storageBucket: string; storageKey: string; fileName: string; contentType: string; fileSize: number; fileHash?: string | null; }): Promise { const res = await this.db.query( `UPDATE documents SET storage_bucket = $1, storage_key = $2, file_name = $3, content_type = $4, file_size = $5, file_hash = $6 WHERE id = $7 AND user_id = $8 AND deleted_at IS NULL RETURNING *`, [meta.storageBucket, meta.storageKey, meta.fileName, meta.contentType, meta.fileSize, meta.fileHash ?? null, id, userId] ); 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)); } }