Notification updates

This commit is contained in:
Eric Gullickson
2025-12-21 19:56:52 -06:00
parent 144f1d5bb0
commit 719c80ecd8
80 changed files with 7552 additions and 678 deletions

View File

@@ -17,11 +17,11 @@ export class DocumentsController {
logger.info('Documents list requested', {
operation: 'documents.list',
user_id: userId,
userId,
filters: {
vehicle_id: request.query.vehicleId,
vehicleId: request.query.vehicleId,
type: request.query.type,
expires_before: request.query.expiresBefore,
expiresBefore: request.query.expiresBefore,
},
});
@@ -33,8 +33,8 @@ export class DocumentsController {
logger.info('Documents list retrieved', {
operation: 'documents.list.success',
user_id: userId,
document_count: docs.length,
userId,
documentCount: docs.length,
});
return reply.code(200).send(docs);
@@ -46,26 +46,26 @@ export class DocumentsController {
logger.info('Document get requested', {
operation: 'documents.get',
user_id: userId,
document_id: documentId,
userId,
documentId,
});
const doc = await this.service.getDocument(userId, documentId);
if (!doc) {
logger.warn('Document not found', {
operation: 'documents.get.not_found',
user_id: userId,
document_id: documentId,
userId,
documentId,
});
return reply.code(404).send({ error: 'Not Found' });
}
logger.info('Document retrieved', {
operation: 'documents.get.success',
user_id: userId,
document_id: documentId,
vehicle_id: doc.vehicle_id,
document_type: doc.document_type,
userId,
documentId,
vehicleId: doc.vehicleId,
documentType: doc.documentType,
});
return reply.code(200).send(doc);
@@ -76,9 +76,9 @@ export class DocumentsController {
logger.info('Document create requested', {
operation: 'documents.create',
user_id: userId,
vehicle_id: request.body.vehicle_id,
document_type: request.body.document_type,
userId,
vehicleId: request.body.vehicleId,
documentType: request.body.documentType,
title: request.body.title,
});
@@ -86,10 +86,10 @@ export class DocumentsController {
logger.info('Document created', {
operation: 'documents.create.success',
user_id: userId,
document_id: created.id,
vehicle_id: created.vehicle_id,
document_type: created.document_type,
userId,
documentId: created.id,
vehicleId: created.vehicleId,
documentType: created.documentType,
title: created.title,
});
@@ -102,26 +102,26 @@ export class DocumentsController {
logger.info('Document update requested', {
operation: 'documents.update',
user_id: userId,
document_id: documentId,
update_fields: Object.keys(request.body),
userId,
documentId,
updateFields: Object.keys(request.body),
});
const updated = await this.service.updateDocument(userId, documentId, request.body);
if (!updated) {
logger.warn('Document not found for update', {
operation: 'documents.update.not_found',
user_id: userId,
document_id: documentId,
userId,
documentId,
});
return reply.code(404).send({ error: 'Not Found' });
}
logger.info('Document updated', {
operation: 'documents.update.success',
user_id: userId,
document_id: documentId,
vehicle_id: updated.vehicle_id,
userId,
documentId,
vehicleId: updated.vehicleId,
title: updated.title,
});
@@ -134,28 +134,28 @@ export class DocumentsController {
logger.info('Document delete requested', {
operation: 'documents.delete',
user_id: userId,
document_id: documentId,
userId,
documentId,
});
// If object exists, delete it from storage first
const existing = await this.service.getDocument(userId, documentId);
if (existing && existing.storage_bucket && existing.storage_key) {
if (existing && existing.storageBucket && existing.storageKey) {
const storage = getStorageService();
try {
await storage.deleteObject(existing.storage_bucket, existing.storage_key);
await storage.deleteObject(existing.storageBucket, existing.storageKey);
logger.info('Document file deleted from storage', {
operation: 'documents.delete.storage_cleanup',
user_id: userId,
document_id: documentId,
storage_key: existing.storage_key,
userId,
documentId,
storageKey: existing.storageKey,
});
} catch (e) {
logger.warn('Failed to delete document file from storage', {
operation: 'documents.delete.storage_cleanup_failed',
user_id: userId,
document_id: documentId,
storage_key: existing.storage_key,
userId,
documentId,
storageKey: existing.storageKey,
error: e instanceof Error ? e.message : 'Unknown error',
});
// Non-fatal: proceed with soft delete
@@ -166,10 +166,10 @@ export class DocumentsController {
logger.info('Document deleted', {
operation: 'documents.delete.success',
user_id: userId,
document_id: documentId,
vehicle_id: existing?.vehicle_id,
had_file: !!(existing?.storage_key),
userId,
documentId,
vehicleId: existing?.vehicleId,
hadFile: !!(existing?.storageKey),
});
return reply.code(204).send();
@@ -181,16 +181,16 @@ export class DocumentsController {
logger.info('Document upload requested', {
operation: 'documents.upload',
user_id: userId,
document_id: documentId,
userId,
documentId,
});
const doc = await this.service.getDocument(userId, documentId);
if (!doc) {
logger.warn('Document not found for upload', {
operation: 'documents.upload.not_found',
user_id: userId,
document_id: documentId,
userId,
documentId,
});
return reply.code(404).send({ error: 'Not Found' });
}
@@ -199,8 +199,8 @@ export class DocumentsController {
if (!mp) {
logger.warn('No file provided for upload', {
operation: 'documents.upload.no_file',
user_id: userId,
document_id: documentId,
userId,
documentId,
});
return reply.code(400).send({ error: 'Bad Request', message: 'No file provided' });
}
@@ -216,10 +216,10 @@ export class DocumentsController {
if (!contentType || !allowedTypes.has(contentType)) {
logger.warn('Unsupported file type for upload (header validation)', {
operation: 'documents.upload.unsupported_type',
user_id: userId,
document_id: documentId,
content_type: contentType,
file_name: mp.filename,
userId,
documentId,
contentType,
fileName: mp.filename,
});
return reply.code(415).send({
error: 'Unsupported Media Type',
@@ -248,10 +248,10 @@ export class DocumentsController {
if (!detectedType) {
logger.warn('Unable to detect file type from content', {
operation: 'documents.upload.type_detection_failed',
user_id: userId,
document_id: documentId,
content_type: contentType,
file_name: mp.filename,
userId,
documentId,
contentType,
fileName: mp.filename,
});
return reply.code(415).send({
error: 'Unsupported Media Type',
@@ -264,11 +264,11 @@ export class DocumentsController {
if (!allowedDetectedTypes || !allowedDetectedTypes.has(detectedType.mime)) {
logger.warn('File content does not match Content-Type header', {
operation: 'documents.upload.type_mismatch',
user_id: userId,
document_id: documentId,
claimed_type: contentType,
detected_type: detectedType.mime,
file_name: mp.filename,
userId,
documentId,
claimedType: contentType,
detectedType: detectedType.mime,
fileName: mp.filename,
});
return reply.code(415).send({
error: 'Unsupported Media Type',
@@ -310,29 +310,29 @@ export class DocumentsController {
const bucket = 'documents';
const version = 'v1';
const unique = cryptoRandom();
const key = `documents/${userId}/${doc.vehicle_id}/${doc.id}/${version}/${unique}.${ext}`;
const key = `documents/${userId}/${doc.vehicleId}/${doc.id}/${version}/${unique}.${ext}`;
await storage.putObject(bucket, key, counter, contentType, { 'x-original-filename': originalName });
const updated = await this.service['repo'].updateStorageMeta(doc.id, userId, {
storage_bucket: bucket,
storage_key: key,
file_name: originalName,
content_type: contentType,
file_size: counter.bytes,
file_hash: null,
storageBucket: bucket,
storageKey: key,
fileName: originalName,
contentType: contentType,
fileSize: counter.bytes,
fileHash: null,
});
logger.info('Document upload completed', {
operation: 'documents.upload.success',
user_id: userId,
document_id: documentId,
vehicle_id: doc.vehicle_id,
file_name: originalName,
content_type: contentType,
detected_type: detectedType.mime,
file_size: counter.bytes,
storage_key: key,
userId,
documentId,
vehicleId: doc.vehicleId,
fileName: originalName,
contentType,
detectedType: detectedType.mime,
fileSize: counter.bytes,
storageKey: key,
});
return reply.code(200).send(updated);
@@ -344,18 +344,18 @@ export class DocumentsController {
logger.info('Document download requested', {
operation: 'documents.download',
user_id: userId,
document_id: documentId,
userId,
documentId,
});
const doc = await this.service.getDocument(userId, documentId);
if (!doc || !doc.storage_bucket || !doc.storage_key) {
if (!doc || !doc.storageBucket || !doc.storageKey) {
logger.warn('Document or file not found for download', {
operation: 'documents.download.not_found',
user_id: userId,
document_id: documentId,
has_document: !!doc,
has_storage_info: !!(doc?.storage_bucket && doc?.storage_key),
userId,
documentId,
hasDocument: !!doc,
hasStorageInfo: !!(doc?.storageBucket && doc?.storageKey),
});
return reply.code(404).send({ error: 'Not Found' });
}
@@ -363,10 +363,10 @@ export class DocumentsController {
const storage = getStorageService();
let head: Partial<import('../../../core/storage/storage.service').HeadObjectResult> = {};
try {
head = await storage.headObject(doc.storage_bucket, doc.storage_key);
head = await storage.headObject(doc.storageBucket, doc.storageKey);
} catch { /* ignore */ }
const contentType = head.contentType || doc.content_type || 'application/octet-stream';
const filename = doc.file_name || path.basename(doc.storage_key);
const contentType = head.contentType || doc.contentType || 'application/octet-stream';
const filename = doc.fileName || path.basename(doc.storageKey);
const inlineTypes = new Set(['application/pdf', 'image/jpeg', 'image/png']);
const disposition = inlineTypes.has(contentType) ? 'inline' : 'attachment';
@@ -375,16 +375,16 @@ export class DocumentsController {
logger.info('Document download initiated', {
operation: 'documents.download.success',
user_id: userId,
document_id: documentId,
vehicle_id: doc.vehicle_id,
file_name: filename,
content_type: contentType,
disposition: disposition,
file_size: head.size || doc.file_size,
userId,
documentId,
vehicleId: doc.vehicleId,
fileName: filename,
contentType,
disposition,
fileSize: head.size || doc.fileSize,
});
const stream = await storage.getObjectStream(doc.storage_bucket, doc.storage_key);
const stream = await storage.getObjectStream(doc.storageBucket, doc.storageKey);
return reply.send(stream);
}
}

View File

@@ -5,40 +5,74 @@ 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,
createdAt: row.created_at,
updatedAt: row.updated_at,
deletedAt: row.deleted_at
};
}
// ========================
// CRUD Operations
// ========================
async insert(doc: {
id: string;
user_id: string;
vehicle_id: string;
document_type: DocumentType;
userId: string;
vehicleId: string;
documentType: DocumentType;
title: string;
notes?: string | null;
details?: any;
issued_date?: string | null;
expiration_date?: string | null;
issuedDate?: string | null;
expirationDate?: string | null;
emailNotifications?: boolean;
}): 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
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING *`,
[
doc.id,
doc.user_id,
doc.vehicle_id,
doc.document_type,
doc.userId,
doc.vehicleId,
doc.documentType,
doc.title,
doc.notes ?? null,
doc.details ?? null,
doc.issued_date ?? null,
doc.expiration_date ?? null,
doc.issuedDate ?? null,
doc.expirationDate ?? null,
doc.emailNotifications ?? false,
]
);
return res.rows[0] as DocumentRecord;
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] || null;
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
}
async listByUser(userId: string, filters?: { vehicleId?: string; type?: DocumentType; expiresBefore?: string }): Promise<DocumentRecord[]> {
@@ -50,31 +84,32 @@ export class DocumentsRepository {
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 as DocumentRecord[];
return res.rows.map(row => 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'|'issued_date'|'expiration_date'>>): Promise<DocumentRecord | null> {
async updateMetadata(id: string, userId: string, patch: Partial<Pick<DocumentRecord, 'title'|'notes'|'details'|'issuedDate'|'expirationDate'|'emailNotifications'>>): 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.issued_date !== undefined) { fields.push(`issued_date = $${i++}`); params.push(patch.issued_date); }
if (patch.expiration_date !== undefined) { fields.push(`expiration_date = $${i++}`); params.push(patch.expiration_date); }
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 (!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] || null;
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
}
async updateStorageMeta(id: string, userId: string, meta: {
storage_bucket: string; storage_key: string; file_name: string; content_type: string; file_size: number; file_hash?: string | null;
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
@@ -86,9 +121,9 @@ export class DocumentsRepository {
file_hash = $6
WHERE id = $7 AND user_id = $8 AND deleted_at IS NULL
RETURNING *`,
[meta.storage_bucket, meta.storage_key, meta.file_name, meta.content_type, meta.file_size, meta.file_hash ?? null, id, userId]
[meta.storageBucket, meta.storageKey, meta.fileName, meta.contentType, meta.fileSize, meta.fileHash ?? null, id, userId]
);
return res.rows[0] || null;
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
}
}

View File

@@ -7,18 +7,19 @@ export class DocumentsService {
private readonly repo = new DocumentsRepository(pool);
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
await this.assertVehicleOwnership(userId, body.vehicle_id);
await this.assertVehicleOwnership(userId, body.vehicleId);
const id = randomUUID();
return this.repo.insert({
id,
user_id: userId,
vehicle_id: body.vehicle_id,
document_type: body.document_type as DocumentType,
userId,
vehicleId: body.vehicleId,
documentType: body.documentType as DocumentType,
title: body.title,
notes: body.notes ?? null,
details: body.details ?? null,
issued_date: body.issued_date ?? null,
expiration_date: body.expiration_date ?? null,
issuedDate: body.issuedDate ?? null,
expirationDate: body.expirationDate ?? null,
emailNotifications: body.emailNotifications ?? false,
});
}

View File

@@ -3,35 +3,39 @@ import { z } from 'zod';
export const DocumentTypeSchema = z.enum(['insurance', 'registration']);
export type DocumentType = z.infer<typeof DocumentTypeSchema>;
// API response type (camelCase for frontend)
export interface DocumentRecord {
id: string;
user_id: string;
vehicle_id: string;
document_type: DocumentType;
userId: string;
vehicleId: string;
documentType: DocumentType;
title: string;
notes?: string | null;
details?: Record<string, any> | null;
storage_bucket?: string | null;
storage_key?: string | null;
file_name?: string | null;
content_type?: string | null;
file_size?: number | null;
file_hash?: string | null;
issued_date?: string | null;
expiration_date?: string | null;
created_at: string;
updated_at: string;
deleted_at?: string | null;
storageBucket?: string | null;
storageKey?: string | null;
fileName?: string | null;
contentType?: string | null;
fileSize?: number | null;
fileHash?: string | null;
issuedDate?: string | null;
expirationDate?: string | null;
emailNotifications?: boolean;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
// API request schemas (camelCase for frontend)
export const CreateDocumentBodySchema = z.object({
vehicle_id: z.string().uuid(),
document_type: DocumentTypeSchema,
vehicleId: z.string().uuid(),
documentType: DocumentTypeSchema,
title: z.string().min(1).max(200),
notes: z.string().max(10000).optional(),
details: z.record(z.any()).optional(),
issued_date: z.string().optional(),
expiration_date: z.string().optional(),
issuedDate: z.string().optional(),
expirationDate: z.string().optional(),
emailNotifications: z.boolean().optional(),
});
export type CreateDocumentBody = z.infer<typeof CreateDocumentBodySchema>;
@@ -39,8 +43,9 @@ export const UpdateDocumentBodySchema = z.object({
title: z.string().min(1).max(200).optional(),
notes: z.string().max(10000).nullable().optional(),
details: z.record(z.any()).optional(),
issued_date: z.string().nullable().optional(),
expiration_date: z.string().nullable().optional(),
issuedDate: z.string().nullable().optional(),
expirationDate: z.string().nullable().optional(),
emailNotifications: z.boolean().optional(),
});
export type UpdateDocumentBody = z.infer<typeof UpdateDocumentBodySchema>;