Files
motovaultpro/backend/src/features/documents/data/documents.repository.ts
Eric Gullickson 57debe4252 feat: add shared_vehicle_ids schema and repository methods (refs #31)
- 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 <noreply@anthropic.com>
2026-01-14 19:24:34 -06:00

249 lines
9.5 KiB
TypeScript

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<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, 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<DocumentRecord | null> {
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<DocumentRecord[]> {
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<DocumentRecord[]> {
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<void> {
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'|'sharedVehicleIds'>>): Promise<DocumentRecord | null> {
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<DocumentRecord | null> {
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<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));
}
}