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

@@ -22,9 +22,9 @@ export class MaintenanceController {
logger.info('Maintenance records list requested', {
operation: 'maintenance.records.list',
user_id: userId,
userId,
filters: {
vehicle_id: request.query.vehicleId,
vehicleId: request.query.vehicleId,
category: request.query.category,
},
});
@@ -42,15 +42,15 @@ export class MaintenanceController {
logger.info('Maintenance records list retrieved', {
operation: 'maintenance.records.list.success',
user_id: userId,
record_count: records.length,
userId,
recordCount: records.length,
});
return reply.code(200).send(records);
} catch (error) {
logger.error('Failed to list maintenance records', {
operation: 'maintenance.records.list.error',
user_id: userId,
userId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
@@ -63,8 +63,8 @@ export class MaintenanceController {
logger.info('Maintenance record get requested', {
operation: 'maintenance.records.get',
user_id: userId,
record_id: recordId,
userId,
recordId,
});
try {
@@ -72,17 +72,17 @@ export class MaintenanceController {
if (!record) {
logger.warn('Maintenance record not found', {
operation: 'maintenance.records.get.not_found',
user_id: userId,
record_id: recordId,
userId,
recordId,
});
return reply.code(404).send({ error: 'Not Found' });
}
logger.info('Maintenance record retrieved', {
operation: 'maintenance.records.get.success',
user_id: userId,
record_id: recordId,
vehicle_id: record.vehicle_id,
userId,
recordId,
vehicleId: record.vehicleId,
category: record.category,
});
@@ -90,8 +90,8 @@ export class MaintenanceController {
} catch (error) {
logger.error('Failed to get maintenance record', {
operation: 'maintenance.records.get.error',
user_id: userId,
record_id: recordId,
userId,
recordId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
@@ -107,8 +107,8 @@ export class MaintenanceController {
logger.info('Maintenance records by vehicle requested', {
operation: 'maintenance.records.by_vehicle',
user_id: userId,
vehicle_id: vehicleId,
userId,
vehicleId,
});
try {
@@ -116,17 +116,17 @@ export class MaintenanceController {
logger.info('Maintenance records by vehicle retrieved', {
operation: 'maintenance.records.by_vehicle.success',
user_id: userId,
vehicle_id: vehicleId,
record_count: records.length,
userId,
vehicleId,
recordCount: records.length,
});
return reply.code(200).send(records);
} catch (error) {
logger.error('Failed to get maintenance records by vehicle', {
operation: 'maintenance.records.by_vehicle.error',
user_id: userId,
vehicle_id: vehicleId,
userId,
vehicleId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
@@ -138,7 +138,7 @@ export class MaintenanceController {
logger.info('Maintenance record create requested', {
operation: 'maintenance.records.create',
user_id: userId,
userId,
});
try {
@@ -148,11 +148,11 @@ export class MaintenanceController {
logger.info('Maintenance record created', {
operation: 'maintenance.records.create.success',
user_id: userId,
record_id: record.id,
vehicle_id: record.vehicle_id,
userId,
recordId: record.id,
vehicleId: record.vehicleId,
category: record.category,
subtype_count: record.subtypes.length,
subtypeCount: record.subtypes.length,
});
return reply.code(201).send(record);
@@ -160,7 +160,7 @@ export class MaintenanceController {
if (error instanceof z.ZodError) {
logger.warn('Maintenance record validation failed', {
operation: 'maintenance.records.create.validation_error',
user_id: userId,
userId,
errors: error.errors,
});
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
@@ -170,8 +170,8 @@ export class MaintenanceController {
const statusCode = (error as any).statusCode;
logger.warn('Maintenance record creation failed', {
operation: 'maintenance.records.create.error',
user_id: userId,
status_code: statusCode,
userId,
statusCode,
error: error.message,
});
return reply.code(statusCode).send({ error: error.message });
@@ -179,7 +179,7 @@ export class MaintenanceController {
logger.error('Failed to create maintenance record', {
operation: 'maintenance.records.create.error',
user_id: userId,
userId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
@@ -195,8 +195,8 @@ export class MaintenanceController {
logger.info('Maintenance record update requested', {
operation: 'maintenance.records.update',
user_id: userId,
record_id: recordId,
userId,
recordId,
});
try {
@@ -206,17 +206,17 @@ export class MaintenanceController {
if (!record) {
logger.warn('Maintenance record not found for update', {
operation: 'maintenance.records.update.not_found',
user_id: userId,
record_id: recordId,
userId,
recordId,
});
return reply.code(404).send({ error: 'Not Found' });
}
logger.info('Maintenance record updated', {
operation: 'maintenance.records.update.success',
user_id: userId,
record_id: recordId,
vehicle_id: record.vehicle_id,
userId,
recordId,
vehicleId: record.vehicleId,
category: record.category,
});
@@ -225,8 +225,8 @@ export class MaintenanceController {
if (error instanceof z.ZodError) {
logger.warn('Maintenance record update validation failed', {
operation: 'maintenance.records.update.validation_error',
user_id: userId,
record_id: recordId,
userId,
recordId,
errors: error.errors,
});
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
@@ -236,9 +236,9 @@ export class MaintenanceController {
const statusCode = (error as any).statusCode;
logger.warn('Maintenance record update failed', {
operation: 'maintenance.records.update.error',
user_id: userId,
record_id: recordId,
status_code: statusCode,
userId,
recordId,
statusCode,
error: error.message,
});
return reply.code(statusCode).send({ error: error.message });
@@ -246,8 +246,8 @@ export class MaintenanceController {
logger.error('Failed to update maintenance record', {
operation: 'maintenance.records.update.error',
user_id: userId,
record_id: recordId,
userId,
recordId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
@@ -260,8 +260,8 @@ export class MaintenanceController {
logger.info('Maintenance record delete requested', {
operation: 'maintenance.records.delete',
user_id: userId,
record_id: recordId,
userId,
recordId,
});
try {
@@ -269,16 +269,16 @@ export class MaintenanceController {
logger.info('Maintenance record deleted', {
operation: 'maintenance.records.delete.success',
user_id: userId,
record_id: recordId,
userId,
recordId,
});
return reply.code(204).send();
} catch (error) {
logger.error('Failed to delete maintenance record', {
operation: 'maintenance.records.delete.error',
user_id: userId,
record_id: recordId,
userId,
recordId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
@@ -294,8 +294,8 @@ export class MaintenanceController {
logger.info('Maintenance schedules by vehicle requested', {
operation: 'maintenance.schedules.by_vehicle',
user_id: userId,
vehicle_id: vehicleId,
userId,
vehicleId,
});
try {
@@ -303,17 +303,17 @@ export class MaintenanceController {
logger.info('Maintenance schedules by vehicle retrieved', {
operation: 'maintenance.schedules.by_vehicle.success',
user_id: userId,
vehicle_id: vehicleId,
schedule_count: schedules.length,
userId,
vehicleId,
scheduleCount: schedules.length,
});
return reply.code(200).send(schedules);
} catch (error) {
logger.error('Failed to get maintenance schedules by vehicle', {
operation: 'maintenance.schedules.by_vehicle.error',
user_id: userId,
vehicle_id: vehicleId,
userId,
vehicleId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
@@ -325,7 +325,7 @@ export class MaintenanceController {
logger.info('Maintenance schedule create requested', {
operation: 'maintenance.schedules.create',
user_id: userId,
userId,
});
try {
@@ -335,11 +335,11 @@ export class MaintenanceController {
logger.info('Maintenance schedule created', {
operation: 'maintenance.schedules.create.success',
user_id: userId,
schedule_id: schedule.id,
vehicle_id: schedule.vehicle_id,
userId,
scheduleId: schedule.id,
vehicleId: schedule.vehicleId,
category: schedule.category,
subtype_count: schedule.subtypes.length,
subtypeCount: schedule.subtypes.length,
});
return reply.code(201).send(schedule);
@@ -347,7 +347,7 @@ export class MaintenanceController {
if (error instanceof z.ZodError) {
logger.warn('Maintenance schedule validation failed', {
operation: 'maintenance.schedules.create.validation_error',
user_id: userId,
userId,
errors: error.errors,
});
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
@@ -357,8 +357,8 @@ export class MaintenanceController {
const statusCode = (error as any).statusCode;
logger.warn('Maintenance schedule creation failed', {
operation: 'maintenance.schedules.create.error',
user_id: userId,
status_code: statusCode,
userId,
statusCode,
error: error.message,
});
return reply.code(statusCode).send({ error: error.message });
@@ -366,7 +366,7 @@ export class MaintenanceController {
logger.error('Failed to create maintenance schedule', {
operation: 'maintenance.schedules.create.error',
user_id: userId,
userId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
@@ -382,8 +382,8 @@ export class MaintenanceController {
logger.info('Maintenance schedule update requested', {
operation: 'maintenance.schedules.update',
user_id: userId,
schedule_id: scheduleId,
userId,
scheduleId,
});
try {
@@ -393,17 +393,17 @@ export class MaintenanceController {
if (!schedule) {
logger.warn('Maintenance schedule not found for update', {
operation: 'maintenance.schedules.update.not_found',
user_id: userId,
schedule_id: scheduleId,
userId,
scheduleId,
});
return reply.code(404).send({ error: 'Not Found' });
}
logger.info('Maintenance schedule updated', {
operation: 'maintenance.schedules.update.success',
user_id: userId,
schedule_id: scheduleId,
vehicle_id: schedule.vehicle_id,
userId,
scheduleId,
vehicleId: schedule.vehicleId,
category: schedule.category,
});
@@ -412,8 +412,8 @@ export class MaintenanceController {
if (error instanceof z.ZodError) {
logger.warn('Maintenance schedule update validation failed', {
operation: 'maintenance.schedules.update.validation_error',
user_id: userId,
schedule_id: scheduleId,
userId,
scheduleId,
errors: error.errors,
});
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
@@ -423,9 +423,9 @@ export class MaintenanceController {
const statusCode = (error as any).statusCode;
logger.warn('Maintenance schedule update failed', {
operation: 'maintenance.schedules.update.error',
user_id: userId,
schedule_id: scheduleId,
status_code: statusCode,
userId,
scheduleId,
statusCode,
error: error.message,
});
return reply.code(statusCode).send({ error: error.message });
@@ -433,8 +433,8 @@ export class MaintenanceController {
logger.error('Failed to update maintenance schedule', {
operation: 'maintenance.schedules.update.error',
user_id: userId,
schedule_id: scheduleId,
userId,
scheduleId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
@@ -447,8 +447,8 @@ export class MaintenanceController {
logger.info('Maintenance schedule delete requested', {
operation: 'maintenance.schedules.delete',
user_id: userId,
schedule_id: scheduleId,
userId,
scheduleId,
});
try {
@@ -456,16 +456,16 @@ export class MaintenanceController {
logger.info('Maintenance schedule deleted', {
operation: 'maintenance.schedules.delete.success',
user_id: userId,
schedule_id: scheduleId,
userId,
scheduleId,
});
return reply.code(204).send();
} catch (error) {
logger.error('Failed to delete maintenance schedule', {
operation: 'maintenance.schedules.delete.error',
user_id: userId,
schedule_id: scheduleId,
userId,
scheduleId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
@@ -482,9 +482,9 @@ export class MaintenanceController {
logger.info('Upcoming maintenance requested', {
operation: 'maintenance.upcoming',
user_id: userId,
vehicle_id: vehicleId,
current_mileage: currentMileage,
userId,
vehicleId,
currentMileage,
});
try {
@@ -492,17 +492,17 @@ export class MaintenanceController {
logger.info('Upcoming maintenance retrieved', {
operation: 'maintenance.upcoming.success',
user_id: userId,
vehicle_id: vehicleId,
upcoming_count: upcoming.length,
userId,
vehicleId,
upcomingCount: upcoming.length,
});
return reply.code(200).send(upcoming);
} catch (error) {
logger.error('Failed to get upcoming maintenance', {
operation: 'maintenance.upcoming.error',
user_id: userId,
vehicle_id: vehicleId,
userId,
vehicleId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
@@ -515,16 +515,16 @@ export class MaintenanceController {
logger.info('Maintenance subtypes requested', {
operation: 'maintenance.subtypes',
user_id: userId,
category: category,
userId,
category,
});
try {
if (!['routine_maintenance', 'repair', 'performance_upgrade'].includes(category)) {
logger.warn('Invalid maintenance category', {
operation: 'maintenance.subtypes.invalid_category',
user_id: userId,
category: category,
userId,
category,
});
return reply.code(400).send({ error: 'Bad Request', message: 'Invalid category' });
}
@@ -533,17 +533,17 @@ export class MaintenanceController {
logger.info('Maintenance subtypes retrieved', {
operation: 'maintenance.subtypes.success',
user_id: userId,
category: category,
subtype_count: subtypes.length,
userId,
category,
subtypeCount: subtypes.length,
});
return reply.code(200).send({ category, subtypes: Array.from(subtypes) });
} catch (error) {
logger.error('Failed to get maintenance subtypes', {
operation: 'maintenance.subtypes.error',
user_id: userId,
category: category,
userId,
category,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;

View File

@@ -5,20 +5,61 @@ import type { MaintenanceRecord, MaintenanceSchedule, MaintenanceCategory } from
export class MaintenanceRepository {
constructor(private readonly db: Pool = pool) {}
// ========================
// Row Mappers
// ========================
private mapMaintenanceRecord(row: any): MaintenanceRecord {
return {
id: row.id,
userId: row.user_id,
vehicleId: row.vehicle_id,
category: row.category,
subtypes: row.subtypes,
date: row.date,
odometerReading: row.odometer_reading,
cost: row.cost,
shopName: row.shop_name,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
private mapMaintenanceSchedule(row: any): MaintenanceSchedule {
return {
id: row.id,
userId: row.user_id,
vehicleId: row.vehicle_id,
category: row.category,
subtypes: row.subtypes,
intervalMonths: row.interval_months,
intervalMiles: row.interval_miles,
lastServiceDate: row.last_service_date,
lastServiceMileage: row.last_service_mileage,
nextDueDate: row.next_due_date,
nextDueMileage: row.next_due_mileage,
isActive: row.is_active,
emailNotifications: row.email_notifications,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
// ========================
// Maintenance Records
// ========================
async insertRecord(record: {
id: string;
user_id: string;
vehicle_id: string;
userId: string;
vehicleId: string;
category: MaintenanceCategory;
subtypes: string[];
date: string;
odometer_reading?: number | null;
odometerReading?: number | null;
cost?: number | null;
shop_name?: string | null;
shopName?: string | null;
notes?: string | null;
}): Promise<MaintenanceRecord> {
const res = await this.db.query(
@@ -28,18 +69,18 @@ export class MaintenanceRepository {
RETURNING *`,
[
record.id,
record.user_id,
record.vehicle_id,
record.userId,
record.vehicleId,
record.category,
record.subtypes,
record.date,
record.odometer_reading ?? null,
record.odometerReading ?? null,
record.cost ?? null,
record.shop_name ?? null,
record.shopName ?? null,
record.notes ?? null,
]
);
return res.rows[0] as MaintenanceRecord;
return this.mapMaintenanceRecord(res.rows[0]);
}
async findRecordById(id: string, userId: string): Promise<MaintenanceRecord | null> {
@@ -47,7 +88,7 @@ export class MaintenanceRepository {
`SELECT * FROM maintenance_records WHERE id = $1 AND user_id = $2`,
[id, userId]
);
return res.rows[0] || null;
return res.rows[0] ? this.mapMaintenanceRecord(res.rows[0]) : null;
}
async findRecordsByUserId(
@@ -69,7 +110,7 @@ export class MaintenanceRepository {
const sql = `SELECT * FROM maintenance_records WHERE ${conds.join(' AND ')} ORDER BY date DESC`;
const res = await this.db.query(sql, params);
return res.rows as MaintenanceRecord[];
return res.rows.map(row => this.mapMaintenanceRecord(row));
}
async findRecordsByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceRecord[]> {
@@ -77,13 +118,13 @@ export class MaintenanceRepository {
`SELECT * FROM maintenance_records WHERE vehicle_id = $1 AND user_id = $2 ORDER BY date DESC`,
[vehicleId, userId]
);
return res.rows as MaintenanceRecord[];
return res.rows.map(row => this.mapMaintenanceRecord(row));
}
async updateRecord(
id: string,
userId: string,
patch: Partial<Pick<MaintenanceRecord, 'category' | 'subtypes' | 'date' | 'odometer_reading' | 'cost' | 'shop_name' | 'notes'>>
patch: Partial<Pick<MaintenanceRecord, 'category' | 'subtypes' | 'date' | 'odometerReading' | 'cost' | 'shopName' | 'notes'>>
): Promise<MaintenanceRecord | null> {
const fields: string[] = [];
const params: any[] = [];
@@ -101,17 +142,17 @@ export class MaintenanceRepository {
fields.push(`date = $${i++}`);
params.push(patch.date);
}
if (patch.odometer_reading !== undefined) {
if (patch.odometerReading !== undefined) {
fields.push(`odometer_reading = $${i++}`);
params.push(patch.odometer_reading);
params.push(patch.odometerReading);
}
if (patch.cost !== undefined) {
fields.push(`cost = $${i++}`);
params.push(patch.cost);
}
if (patch.shop_name !== undefined) {
if (patch.shopName !== undefined) {
fields.push(`shop_name = $${i++}`);
params.push(patch.shop_name);
params.push(patch.shopName);
}
if (patch.notes !== undefined) {
fields.push(`notes = $${i++}`);
@@ -123,7 +164,7 @@ export class MaintenanceRepository {
params.push(id, userId);
const sql = `UPDATE maintenance_records SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} RETURNING *`;
const res = await this.db.query(sql, params);
return res.rows[0] || null;
return res.rows[0] ? this.mapMaintenanceRecord(res.rows[0]) : null;
}
async deleteRecord(id: string, userId: string): Promise<void> {
@@ -139,40 +180,42 @@ export class MaintenanceRepository {
async insertSchedule(schedule: {
id: string;
user_id: string;
vehicle_id: string;
userId: string;
vehicleId: string;
category: MaintenanceCategory;
subtypes: string[];
interval_months?: number | null;
interval_miles?: number | null;
last_service_date?: string | null;
last_service_mileage?: number | null;
next_due_date?: string | null;
next_due_mileage?: number | null;
is_active: boolean;
intervalMonths?: number | null;
intervalMiles?: number | null;
lastServiceDate?: string | null;
lastServiceMileage?: number | null;
nextDueDate?: string | null;
nextDueMileage?: number | null;
isActive: boolean;
emailNotifications?: boolean;
}): Promise<MaintenanceSchedule> {
const res = await this.db.query(
`INSERT INTO maintenance_schedules (
id, user_id, vehicle_id, category, subtypes, interval_months, interval_miles,
last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12)
last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active, email_notifications
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
schedule.id,
schedule.user_id,
schedule.vehicle_id,
schedule.userId,
schedule.vehicleId,
schedule.category,
schedule.subtypes,
schedule.interval_months ?? null,
schedule.interval_miles ?? null,
schedule.last_service_date ?? null,
schedule.last_service_mileage ?? null,
schedule.next_due_date ?? null,
schedule.next_due_mileage ?? null,
schedule.is_active,
schedule.intervalMonths ?? null,
schedule.intervalMiles ?? null,
schedule.lastServiceDate ?? null,
schedule.lastServiceMileage ?? null,
schedule.nextDueDate ?? null,
schedule.nextDueMileage ?? null,
schedule.isActive,
schedule.emailNotifications ?? false,
]
);
return res.rows[0] as MaintenanceSchedule;
return this.mapMaintenanceSchedule(res.rows[0]);
}
async findScheduleById(id: string, userId: string): Promise<MaintenanceSchedule | null> {
@@ -180,7 +223,7 @@ export class MaintenanceRepository {
`SELECT * FROM maintenance_schedules WHERE id = $1 AND user_id = $2`,
[id, userId]
);
return res.rows[0] || null;
return res.rows[0] ? this.mapMaintenanceSchedule(res.rows[0]) : null;
}
async findSchedulesByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceSchedule[]> {
@@ -188,7 +231,7 @@ export class MaintenanceRepository {
`SELECT * FROM maintenance_schedules WHERE vehicle_id = $1 AND user_id = $2 ORDER BY created_at DESC`,
[vehicleId, userId]
);
return res.rows as MaintenanceSchedule[];
return res.rows.map(row => this.mapMaintenanceSchedule(row));
}
async findActiveSchedulesByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceSchedule[]> {
@@ -196,13 +239,13 @@ export class MaintenanceRepository {
`SELECT * FROM maintenance_schedules WHERE vehicle_id = $1 AND user_id = $2 AND is_active = true ORDER BY created_at DESC`,
[vehicleId, userId]
);
return res.rows as MaintenanceSchedule[];
return res.rows.map(row => this.mapMaintenanceSchedule(row));
}
async updateSchedule(
id: string,
userId: string,
patch: Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'interval_months' | 'interval_miles' | 'last_service_date' | 'last_service_mileage' | 'next_due_date' | 'next_due_mileage' | 'is_active'>>
patch: Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'intervalMonths' | 'intervalMiles' | 'lastServiceDate' | 'lastServiceMileage' | 'nextDueDate' | 'nextDueMileage' | 'isActive' | 'emailNotifications'>>
): Promise<MaintenanceSchedule | null> {
const fields: string[] = [];
const params: any[] = [];
@@ -216,33 +259,37 @@ export class MaintenanceRepository {
fields.push(`subtypes = $${i++}::text[]`);
params.push(patch.subtypes);
}
if (patch.interval_months !== undefined) {
if (patch.intervalMonths !== undefined) {
fields.push(`interval_months = $${i++}`);
params.push(patch.interval_months);
params.push(patch.intervalMonths);
}
if (patch.interval_miles !== undefined) {
if (patch.intervalMiles !== undefined) {
fields.push(`interval_miles = $${i++}`);
params.push(patch.interval_miles);
params.push(patch.intervalMiles);
}
if (patch.last_service_date !== undefined) {
if (patch.lastServiceDate !== undefined) {
fields.push(`last_service_date = $${i++}`);
params.push(patch.last_service_date);
params.push(patch.lastServiceDate);
}
if (patch.last_service_mileage !== undefined) {
if (patch.lastServiceMileage !== undefined) {
fields.push(`last_service_mileage = $${i++}`);
params.push(patch.last_service_mileage);
params.push(patch.lastServiceMileage);
}
if (patch.next_due_date !== undefined) {
if (patch.nextDueDate !== undefined) {
fields.push(`next_due_date = $${i++}`);
params.push(patch.next_due_date);
params.push(patch.nextDueDate);
}
if (patch.next_due_mileage !== undefined) {
if (patch.nextDueMileage !== undefined) {
fields.push(`next_due_mileage = $${i++}`);
params.push(patch.next_due_mileage);
params.push(patch.nextDueMileage);
}
if (patch.is_active !== undefined) {
if (patch.isActive !== undefined) {
fields.push(`is_active = $${i++}`);
params.push(patch.is_active);
params.push(patch.isActive);
}
if (patch.emailNotifications !== undefined) {
fields.push(`email_notifications = $${i++}`);
params.push(patch.emailNotifications);
}
if (!fields.length) return this.findScheduleById(id, userId);
@@ -250,7 +297,7 @@ export class MaintenanceRepository {
params.push(id, userId);
const sql = `UPDATE maintenance_schedules SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} RETURNING *`;
const res = await this.db.query(sql, params);
return res.rows[0] || null;
return res.rows[0] ? this.mapMaintenanceSchedule(res.rows[0]) : null;
}
async deleteSchedule(id: string, userId: string): Promise<void> {

View File

@@ -18,7 +18,7 @@ export class MaintenanceService {
private readonly repo = new MaintenanceRepository(pool);
async createRecord(userId: string, body: CreateMaintenanceRecordRequest): Promise<MaintenanceRecord> {
await this.assertVehicleOwnership(userId, body.vehicle_id);
await this.assertVehicleOwnership(userId, body.vehicleId);
if (!validateSubtypes(body.category, body.subtypes)) {
const err: any = new Error('Invalid subtypes for selected category');
@@ -29,14 +29,14 @@ export class MaintenanceService {
const id = randomUUID();
return this.repo.insertRecord({
id,
user_id: userId,
vehicle_id: body.vehicle_id,
userId,
vehicleId: body.vehicleId,
category: body.category,
subtypes: body.subtypes,
date: body.date,
odometer_reading: body.odometer_reading,
odometerReading: body.odometerReading,
cost: body.cost,
shop_name: body.shop_name,
shopName: body.shopName,
notes: body.notes,
});
}
@@ -74,7 +74,7 @@ export class MaintenanceService {
// Convert nulls to undefined for repository compatibility
const cleanPatch = Object.fromEntries(
Object.entries(patch).map(([k, v]) => [k, v === null ? undefined : v])
) as Partial<Pick<MaintenanceRecord, 'date' | 'notes' | 'category' | 'subtypes' | 'odometer_reading' | 'cost' | 'shop_name'>>;
) as Partial<Pick<MaintenanceRecord, 'date' | 'notes' | 'category' | 'subtypes' | 'odometerReading' | 'cost' | 'shopName'>>;
const updated = await this.repo.updateRecord(id, userId, cleanPatch);
if (!updated) return null;
@@ -86,7 +86,7 @@ export class MaintenanceService {
}
async createSchedule(userId: string, body: CreateScheduleRequest): Promise<MaintenanceSchedule> {
await this.assertVehicleOwnership(userId, body.vehicle_id);
await this.assertVehicleOwnership(userId, body.vehicleId);
if (!validateSubtypes(body.category, body.subtypes)) {
const err: any = new Error('Invalid subtypes for selected category');
@@ -94,7 +94,7 @@ export class MaintenanceService {
throw err;
}
if (!body.interval_months && !body.interval_miles) {
if (!body.intervalMonths && !body.intervalMiles) {
const err: any = new Error('At least one interval (months or miles) is required');
err.statusCode = 400;
throw err;
@@ -103,13 +103,14 @@ export class MaintenanceService {
const id = randomUUID();
return this.repo.insertSchedule({
id,
user_id: userId,
vehicle_id: body.vehicle_id,
userId,
vehicleId: body.vehicleId,
category: body.category,
subtypes: body.subtypes,
interval_months: body.interval_months,
interval_miles: body.interval_miles,
is_active: true,
intervalMonths: body.intervalMonths,
intervalMiles: body.intervalMiles,
isActive: true,
emailNotifications: body.emailNotifications ?? false,
});
}
@@ -143,25 +144,25 @@ export class MaintenanceService {
}
const needsRecalculation =
patch.interval_months !== undefined ||
patch.interval_miles !== undefined;
patch.intervalMonths !== undefined ||
patch.intervalMiles !== undefined;
let patchWithRecalc: any = { ...patch };
const patchWithRecalc: any = { ...patch };
if (needsRecalculation) {
const nextDue = this.calculateNextDue({
last_service_date: existing.last_service_date,
last_service_mileage: existing.last_service_mileage,
interval_months: patch.interval_months ?? existing.interval_months,
interval_miles: patch.interval_miles ?? existing.interval_miles,
lastServiceDate: existing.lastServiceDate,
lastServiceMileage: existing.lastServiceMileage,
intervalMonths: patch.intervalMonths ?? existing.intervalMonths,
intervalMiles: patch.intervalMiles ?? existing.intervalMiles,
});
patchWithRecalc.next_due_date = nextDue.next_due_date ?? undefined;
patchWithRecalc.next_due_mileage = nextDue.next_due_mileage ?? undefined;
patchWithRecalc.nextDueDate = nextDue.nextDueDate ?? undefined;
patchWithRecalc.nextDueMileage = nextDue.nextDueMileage ?? undefined;
}
// Convert nulls to undefined for repository compatibility
const cleanPatch = Object.fromEntries(
Object.entries(patchWithRecalc).map(([k, v]) => [k, v === null ? undefined : v])
) as Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'interval_months' | 'interval_miles' | 'is_active' | 'last_service_date' | 'last_service_mileage' | 'next_due_date' | 'next_due_mileage'>>;
) as Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'intervalMonths' | 'intervalMiles' | 'isActive' | 'emailNotifications' | 'lastServiceDate' | 'lastServiceMileage' | 'nextDueDate' | 'nextDueMileage'>>;
const updated = await this.repo.updateSchedule(id, userId, cleanPatch);
if (!updated) return null;
@@ -178,7 +179,7 @@ export class MaintenanceService {
return schedules
.map(s => this.toScheduleResponse(s, today, currentMileage))
.filter(s => s.is_due_soon || s.is_overdue);
.filter(s => s.isDueSoon || s.isOverdue);
}
private async assertVehicleOwnership(userId: string, vehicleId: string) {
@@ -191,66 +192,66 @@ export class MaintenanceService {
}
private calculateNextDue(schedule: {
last_service_date?: string | null;
last_service_mileage?: number | null;
interval_months?: number | null;
interval_miles?: number | null;
}): { next_due_date: string | null; next_due_mileage: number | null } {
let next_due_date: string | null = null;
let next_due_mileage: number | null = null;
lastServiceDate?: string | null;
lastServiceMileage?: number | null;
intervalMonths?: number | null;
intervalMiles?: number | null;
}): { nextDueDate: string | null; nextDueMileage: number | null } {
let nextDueDate: string | null = null;
let nextDueMileage: number | null = null;
if (schedule.last_service_date && schedule.interval_months) {
const lastDate = new Date(schedule.last_service_date);
if (schedule.lastServiceDate && schedule.intervalMonths) {
const lastDate = new Date(schedule.lastServiceDate);
const nextDate = new Date(lastDate);
nextDate.setMonth(nextDate.getMonth() + schedule.interval_months);
next_due_date = nextDate.toISOString().split('T')[0];
nextDate.setMonth(nextDate.getMonth() + schedule.intervalMonths);
nextDueDate = nextDate.toISOString().split('T')[0];
}
if (schedule.last_service_mileage !== null && schedule.last_service_mileage !== undefined && schedule.interval_miles) {
next_due_mileage = schedule.last_service_mileage + schedule.interval_miles;
if (schedule.lastServiceMileage !== null && schedule.lastServiceMileage !== undefined && schedule.intervalMiles) {
nextDueMileage = schedule.lastServiceMileage + schedule.intervalMiles;
}
return { next_due_date, next_due_mileage };
return { nextDueDate, nextDueMileage };
}
private toRecordResponse(record: MaintenanceRecord): MaintenanceRecordResponse {
return {
...record,
subtype_count: record.subtypes.length,
subtypeCount: record.subtypes.length,
};
}
private toScheduleResponse(schedule: MaintenanceSchedule, today?: string, currentMileage?: number): MaintenanceScheduleResponse {
const todayStr = today || new Date().toISOString().split('T')[0];
let is_due_soon = false;
let is_overdue = false;
let isDueSoon = false;
let isOverdue = false;
if (schedule.next_due_date) {
const nextDue = new Date(schedule.next_due_date);
if (schedule.nextDueDate) {
const nextDue = new Date(schedule.nextDueDate);
const todayDate = new Date(todayStr);
const daysUntilDue = Math.floor((nextDue.getTime() - todayDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysUntilDue < 0) {
is_overdue = true;
isOverdue = true;
} else if (daysUntilDue <= 30) {
is_due_soon = true;
isDueSoon = true;
}
}
if (currentMileage !== undefined && schedule.next_due_mileage !== null && schedule.next_due_mileage !== undefined) {
const milesUntilDue = schedule.next_due_mileage - currentMileage;
if (currentMileage !== undefined && schedule.nextDueMileage !== null && schedule.nextDueMileage !== undefined) {
const milesUntilDue = schedule.nextDueMileage - currentMileage;
if (milesUntilDue < 0) {
is_overdue = true;
isOverdue = true;
} else if (milesUntilDue <= 500) {
is_due_soon = true;
isDueSoon = true;
}
}
return {
...schedule,
subtype_count: schedule.subtypes.length,
is_due_soon,
is_overdue,
subtypeCount: schedule.subtypes.length,
isDueSoon,
isOverdue,
};
}
}

View File

@@ -55,50 +55,51 @@ export const PERFORMANCE_UPGRADE_SUBTYPES = [
'Exterior'
] as const;
// Database record types
// Database record types (camelCase for TypeScript)
export interface MaintenanceRecord {
id: string;
user_id: string;
vehicle_id: string;
userId: string;
vehicleId: string;
category: MaintenanceCategory;
subtypes: string[];
date: string;
odometer_reading?: number;
odometerReading?: number;
cost?: number;
shop_name?: string;
shopName?: string;
notes?: string;
created_at: string;
updated_at: string;
createdAt: string;
updatedAt: string;
}
export interface MaintenanceSchedule {
id: string;
user_id: string;
vehicle_id: string;
userId: string;
vehicleId: string;
category: MaintenanceCategory;
subtypes: string[];
interval_months?: number;
interval_miles?: number;
last_service_date?: string;
last_service_mileage?: number;
next_due_date?: string;
next_due_mileage?: number;
is_active: boolean;
created_at: string;
updated_at: string;
intervalMonths?: number;
intervalMiles?: number;
lastServiceDate?: string;
lastServiceMileage?: number;
nextDueDate?: string;
nextDueMileage?: number;
isActive: boolean;
emailNotifications: boolean;
createdAt: string;
updatedAt: string;
}
// Zod schemas for validation
// Zod schemas for validation (camelCase for API)
export const MaintenanceCategorySchema = z.enum(['routine_maintenance', 'repair', 'performance_upgrade']);
export const CreateMaintenanceRecordSchema = z.object({
vehicle_id: z.string().uuid(),
vehicleId: z.string().uuid(),
category: MaintenanceCategorySchema,
subtypes: z.array(z.string()).min(1),
date: z.string(),
odometer_reading: z.number().int().positive().optional(),
odometerReading: z.number().int().positive().optional(),
cost: z.number().positive().optional(),
shop_name: z.string().max(200).optional(),
shopName: z.string().max(200).optional(),
notes: z.string().max(10000).optional(),
});
export type CreateMaintenanceRecordRequest = z.infer<typeof CreateMaintenanceRecordSchema>;
@@ -107,40 +108,42 @@ export const UpdateMaintenanceRecordSchema = z.object({
category: MaintenanceCategorySchema.optional(),
subtypes: z.array(z.string()).min(1).optional(),
date: z.string().optional(),
odometer_reading: z.number().int().positive().nullable().optional(),
odometerReading: z.number().int().positive().nullable().optional(),
cost: z.number().positive().nullable().optional(),
shop_name: z.string().max(200).nullable().optional(),
shopName: z.string().max(200).nullable().optional(),
notes: z.string().max(10000).nullable().optional(),
});
export type UpdateMaintenanceRecordRequest = z.infer<typeof UpdateMaintenanceRecordSchema>;
export const CreateScheduleSchema = z.object({
vehicle_id: z.string().uuid(),
vehicleId: z.string().uuid(),
category: MaintenanceCategorySchema,
subtypes: z.array(z.string()).min(1),
interval_months: z.number().int().positive().optional(),
interval_miles: z.number().int().positive().optional(),
intervalMonths: z.number().int().positive().optional(),
intervalMiles: z.number().int().positive().optional(),
emailNotifications: z.boolean().optional(),
});
export type CreateScheduleRequest = z.infer<typeof CreateScheduleSchema>;
export const UpdateScheduleSchema = z.object({
category: MaintenanceCategorySchema.optional(),
subtypes: z.array(z.string()).min(1).optional(),
interval_months: z.number().int().positive().nullable().optional(),
interval_miles: z.number().int().positive().nullable().optional(),
is_active: z.boolean().optional(),
intervalMonths: z.number().int().positive().nullable().optional(),
intervalMiles: z.number().int().positive().nullable().optional(),
isActive: z.boolean().optional(),
emailNotifications: z.boolean().optional(),
});
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;
// Response types
export interface MaintenanceRecordResponse extends MaintenanceRecord {
subtype_count: number;
subtypeCount: number;
}
export interface MaintenanceScheduleResponse extends MaintenanceSchedule {
subtype_count: number;
is_due_soon?: boolean;
is_overdue?: boolean;
subtypeCount: number;
isDueSoon?: boolean;
isOverdue?: boolean;
}
// Validation helpers