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:
Eric Gullickson
2026-01-14 19:24:34 -06:00
parent 5f07123646
commit 57debe4252
3 changed files with 84 additions and 6 deletions

View File

@@ -28,6 +28,7 @@ export class DocumentsRepository {
expirationDate: row.expiration_date, expirationDate: row.expiration_date,
emailNotifications: row.email_notifications, emailNotifications: row.email_notifications,
scanForMaintenance: row.scan_for_maintenance, scanForMaintenance: row.scan_for_maintenance,
sharedVehicleIds: row.shared_vehicle_ids || [],
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
deletedAt: row.deleted_at deletedAt: row.deleted_at
@@ -50,11 +51,12 @@ export class DocumentsRepository {
expirationDate?: string | null; expirationDate?: string | null;
emailNotifications?: boolean; emailNotifications?: boolean;
scanForMaintenance?: boolean; scanForMaintenance?: boolean;
sharedVehicleIds?: string[];
}): Promise<DocumentRecord> { }): Promise<DocumentRecord> {
const res = await this.db.query( const res = await this.db.query(
`INSERT INTO documents ( `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 ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING *`, RETURNING *`,
[ [
doc.id, doc.id,
@@ -68,6 +70,7 @@ export class DocumentsRepository {
doc.expirationDate ?? null, doc.expirationDate ?? null,
doc.emailNotifications ?? false, doc.emailNotifications ?? false,
doc.scanForMaintenance ?? false, doc.scanForMaintenance ?? false,
doc.sharedVehicleIds ?? [],
] ]
); );
return this.mapDocumentRecord(res.rows[0]); return this.mapDocumentRecord(res.rows[0]);
@@ -103,6 +106,7 @@ export class DocumentsRepository {
expirationDate?: string | null; expirationDate?: string | null;
emailNotifications?: boolean; emailNotifications?: boolean;
scanForMaintenance?: boolean; scanForMaintenance?: boolean;
sharedVehicleIds?: string[];
}>, }>,
client?: any client?: any
): Promise<DocumentRecord[]> { ): Promise<DocumentRecord[]> {
@@ -128,17 +132,18 @@ export class DocumentsRepository {
doc.issuedDate ?? null, doc.issuedDate ?? null,
doc.expirationDate ?? null, doc.expirationDate ?? null,
doc.emailNotifications ?? false, 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); placeholders.push(placeholder);
values.push(...docParams); values.push(...docParams);
}); });
const query = ` const query = `
INSERT INTO documents ( 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(', ')} VALUES ${placeholders.join(', ')}
RETURNING * 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]); 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 fields: string[] = [];
const params: any[] = []; const params: any[] = [];
let i = 1; 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.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.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.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); if (!fields.length) return this.findById(id, userId);
params.push(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 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; 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));
}
} }

View File

@@ -22,6 +22,7 @@ export interface DocumentRecord {
expirationDate?: string | null; expirationDate?: string | null;
emailNotifications?: boolean; emailNotifications?: boolean;
scanForMaintenance?: boolean; scanForMaintenance?: boolean;
sharedVehicleIds: string[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt?: string | null; deletedAt?: string | null;
@@ -38,6 +39,7 @@ export const CreateDocumentBodySchema = z.object({
expirationDate: z.string().optional(), expirationDate: z.string().optional(),
emailNotifications: z.boolean().optional(), emailNotifications: z.boolean().optional(),
scanForMaintenance: z.boolean().optional(), scanForMaintenance: z.boolean().optional(),
sharedVehicleIds: z.array(z.string().uuid()).optional(),
}); });
export type CreateDocumentBody = z.infer<typeof CreateDocumentBodySchema>; export type CreateDocumentBody = z.infer<typeof CreateDocumentBodySchema>;
@@ -49,6 +51,7 @@ export const UpdateDocumentBodySchema = z.object({
expirationDate: z.string().nullable().optional(), expirationDate: z.string().nullable().optional(),
emailNotifications: z.boolean().optional(), emailNotifications: z.boolean().optional(),
scanForMaintenance: z.boolean().optional(), scanForMaintenance: z.boolean().optional(),
sharedVehicleIds: z.array(z.string().uuid()).optional(),
}); });
export type UpdateDocumentBody = z.infer<typeof UpdateDocumentBodySchema>; export type UpdateDocumentBody = z.infer<typeof UpdateDocumentBodySchema>;

View File

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