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>
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user