feat: Scheduled Maintenance feature complete
This commit is contained in:
@@ -41,6 +41,11 @@ export class MaintenanceRepository {
|
||||
nextDueMileage: row.next_due_mileage,
|
||||
isActive: row.is_active,
|
||||
emailNotifications: row.email_notifications,
|
||||
scheduleType: row.schedule_type,
|
||||
fixedDueDate: row.fixed_due_date,
|
||||
reminderDays1: row.reminder_days_1,
|
||||
reminderDays2: row.reminder_days_2,
|
||||
reminderDays3: row.reminder_days_3,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
@@ -192,12 +197,18 @@ export class MaintenanceRepository {
|
||||
nextDueMileage?: number | null;
|
||||
isActive: boolean;
|
||||
emailNotifications?: boolean;
|
||||
scheduleType?: string;
|
||||
fixedDueDate?: string | null;
|
||||
reminderDays1?: number | null;
|
||||
reminderDays2?: number | null;
|
||||
reminderDays3?: number | null;
|
||||
}): 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, email_notifications
|
||||
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active, email_notifications,
|
||||
schedule_type, fixed_due_date, reminder_days_1, reminder_days_2, reminder_days_3
|
||||
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
RETURNING *`,
|
||||
[
|
||||
schedule.id,
|
||||
@@ -213,6 +224,11 @@ export class MaintenanceRepository {
|
||||
schedule.nextDueMileage ?? null,
|
||||
schedule.isActive,
|
||||
schedule.emailNotifications ?? false,
|
||||
schedule.scheduleType ?? 'interval',
|
||||
schedule.fixedDueDate ?? null,
|
||||
schedule.reminderDays1 ?? null,
|
||||
schedule.reminderDays2 ?? null,
|
||||
schedule.reminderDays3 ?? null,
|
||||
]
|
||||
);
|
||||
return this.mapMaintenanceSchedule(res.rows[0]);
|
||||
@@ -245,7 +261,7 @@ export class MaintenanceRepository {
|
||||
async updateSchedule(
|
||||
id: string,
|
||||
userId: string,
|
||||
patch: Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'intervalMonths' | 'intervalMiles' | 'lastServiceDate' | 'lastServiceMileage' | 'nextDueDate' | 'nextDueMileage' | 'isActive' | 'emailNotifications'>>
|
||||
patch: Partial<Pick<MaintenanceSchedule, 'category' | 'subtypes' | 'intervalMonths' | 'intervalMiles' | 'lastServiceDate' | 'lastServiceMileage' | 'nextDueDate' | 'nextDueMileage' | 'isActive' | 'emailNotifications' | 'scheduleType' | 'fixedDueDate' | 'reminderDays1' | 'reminderDays2' | 'reminderDays3'>>
|
||||
): Promise<MaintenanceSchedule | null> {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
@@ -291,6 +307,26 @@ export class MaintenanceRepository {
|
||||
fields.push(`email_notifications = $${i++}`);
|
||||
params.push(patch.emailNotifications);
|
||||
}
|
||||
if (patch.scheduleType !== undefined) {
|
||||
fields.push(`schedule_type = $${i++}`);
|
||||
params.push(patch.scheduleType);
|
||||
}
|
||||
if (patch.fixedDueDate !== undefined) {
|
||||
fields.push(`fixed_due_date = $${i++}`);
|
||||
params.push(patch.fixedDueDate);
|
||||
}
|
||||
if (patch.reminderDays1 !== undefined) {
|
||||
fields.push(`reminder_days_1 = $${i++}`);
|
||||
params.push(patch.reminderDays1);
|
||||
}
|
||||
if (patch.reminderDays2 !== undefined) {
|
||||
fields.push(`reminder_days_2 = $${i++}`);
|
||||
params.push(patch.reminderDays2);
|
||||
}
|
||||
if (patch.reminderDays3 !== undefined) {
|
||||
fields.push(`reminder_days_3 = $${i++}`);
|
||||
params.push(patch.reminderDays3);
|
||||
}
|
||||
|
||||
if (!fields.length) return this.findScheduleById(id, userId);
|
||||
|
||||
@@ -306,4 +342,46 @@ export class MaintenanceRepository {
|
||||
[id, userId]
|
||||
);
|
||||
}
|
||||
|
||||
async findMatchingSchedules(
|
||||
userId: string,
|
||||
vehicleId: string,
|
||||
category: MaintenanceCategory,
|
||||
subtypes: string[]
|
||||
): Promise<MaintenanceSchedule[]> {
|
||||
const result = await this.db.query(
|
||||
`SELECT * FROM maintenance_schedules
|
||||
WHERE user_id = $1
|
||||
AND vehicle_id = $2
|
||||
AND category = $3
|
||||
AND is_active = true
|
||||
AND schedule_type = 'time_since_last'
|
||||
AND subtypes && $4::text[]
|
||||
ORDER BY created_at DESC`,
|
||||
[userId, vehicleId, category, subtypes]
|
||||
);
|
||||
return result.rows.map(row => this.mapMaintenanceSchedule(row));
|
||||
}
|
||||
|
||||
async updateScheduleLastService(
|
||||
id: string,
|
||||
userId: string,
|
||||
lastServiceDate: string,
|
||||
lastServiceMileage?: number | null,
|
||||
nextDueDate?: string | null,
|
||||
nextDueMileage?: number | null
|
||||
): Promise<MaintenanceSchedule | null> {
|
||||
const result = await this.db.query(
|
||||
`UPDATE maintenance_schedules SET
|
||||
last_service_date = $1,
|
||||
last_service_mileage = $2,
|
||||
next_due_date = $3,
|
||||
next_due_mileage = $4,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5 AND user_id = $6
|
||||
RETURNING *`,
|
||||
[lastServiceDate, lastServiceMileage, nextDueDate, nextDueMileage, id, userId]
|
||||
);
|
||||
return result.rows[0] ? this.mapMaintenanceSchedule(result.rows[0]) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
MaintenanceSchedule,
|
||||
MaintenanceRecordResponse,
|
||||
MaintenanceScheduleResponse,
|
||||
MaintenanceCategory
|
||||
MaintenanceCategory,
|
||||
ScheduleType
|
||||
} from './maintenance.types';
|
||||
import { validateSubtypes } from './maintenance.types';
|
||||
import { MaintenanceRepository } from '../data/maintenance.repository';
|
||||
@@ -27,7 +28,7 @@ export class MaintenanceService {
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
return this.repo.insertRecord({
|
||||
const record = await this.repo.insertRecord({
|
||||
id,
|
||||
userId,
|
||||
vehicleId: body.vehicleId,
|
||||
@@ -39,6 +40,11 @@ export class MaintenanceService {
|
||||
shopName: body.shopName,
|
||||
notes: body.notes,
|
||||
});
|
||||
|
||||
// Auto-link: Find and update matching 'time_since_last' schedules
|
||||
await this.autoLinkRecordToSchedules(userId, record);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async getRecord(userId: string, id: string): Promise<MaintenanceRecordResponse | null> {
|
||||
@@ -191,12 +197,50 @@ export class MaintenanceService {
|
||||
}
|
||||
}
|
||||
|
||||
private async autoLinkRecordToSchedules(userId: string, record: MaintenanceRecord): Promise<void> {
|
||||
// Find active 'time_since_last' schedules with matching category and overlapping subtypes
|
||||
const matchingSchedules = await this.repo.findMatchingSchedules(
|
||||
userId,
|
||||
record.vehicleId,
|
||||
record.category,
|
||||
record.subtypes
|
||||
);
|
||||
|
||||
for (const schedule of matchingSchedules) {
|
||||
// Calculate new next due dates based on intervals
|
||||
const nextDue = this.calculateNextDue({
|
||||
lastServiceDate: record.date,
|
||||
lastServiceMileage: record.odometerReading,
|
||||
intervalMonths: schedule.intervalMonths,
|
||||
intervalMiles: schedule.intervalMiles,
|
||||
});
|
||||
|
||||
// Update the schedule with new last service info
|
||||
await this.repo.updateScheduleLastService(
|
||||
schedule.id,
|
||||
userId,
|
||||
record.date,
|
||||
record.odometerReading,
|
||||
nextDue.nextDueDate,
|
||||
nextDue.nextDueMileage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateNextDue(schedule: {
|
||||
lastServiceDate?: string | null;
|
||||
lastServiceMileage?: number | null;
|
||||
intervalMonths?: number | null;
|
||||
intervalMiles?: number | null;
|
||||
scheduleType?: ScheduleType;
|
||||
fixedDueDate?: string | null;
|
||||
}): { nextDueDate: string | null; nextDueMileage: number | null } {
|
||||
// Handle fixed_date type - just return the fixed date
|
||||
if (schedule.scheduleType === 'fixed_date' && schedule.fixedDueDate) {
|
||||
return { nextDueDate: schedule.fixedDueDate, nextDueMileage: null };
|
||||
}
|
||||
|
||||
// For interval and time_since_last types, calculate based on last service
|
||||
let nextDueDate: string | null = null;
|
||||
let nextDueMileage: number | null = null;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
||||
|
||||
// Category types
|
||||
export type MaintenanceCategory = 'routine_maintenance' | 'repair' | 'performance_upgrade';
|
||||
export type ScheduleType = 'interval' | 'fixed_date' | 'time_since_last';
|
||||
|
||||
// Subtype definitions (constants for validation)
|
||||
export const ROUTINE_MAINTENANCE_SUBTYPES = [
|
||||
@@ -85,12 +86,23 @@ export interface MaintenanceSchedule {
|
||||
nextDueMileage?: number;
|
||||
isActive: boolean;
|
||||
emailNotifications: boolean;
|
||||
scheduleType: ScheduleType;
|
||||
fixedDueDate?: string | null;
|
||||
reminderDays1?: number | null;
|
||||
reminderDays2?: number | null;
|
||||
reminderDays3?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Zod schemas for validation (camelCase for API)
|
||||
export const MaintenanceCategorySchema = z.enum(['routine_maintenance', 'repair', 'performance_upgrade']);
|
||||
export const ScheduleTypeSchema = z.enum(['interval', 'fixed_date', 'time_since_last']);
|
||||
|
||||
const reminderDaysValidator = z.number().int().positive().refine(
|
||||
(val) => [1, 7, 14, 30, 60].includes(val),
|
||||
{ message: 'Reminder days must be one of: 1, 7, 14, 30, 60' }
|
||||
);
|
||||
|
||||
export const CreateMaintenanceRecordSchema = z.object({
|
||||
vehicleId: z.string().uuid(),
|
||||
@@ -122,6 +134,11 @@ export const CreateScheduleSchema = z.object({
|
||||
intervalMonths: z.number().int().positive().optional(),
|
||||
intervalMiles: z.number().int().positive().optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
scheduleType: ScheduleTypeSchema.optional().default('interval'),
|
||||
fixedDueDate: z.string().optional(),
|
||||
reminderDays1: reminderDaysValidator.optional(),
|
||||
reminderDays2: reminderDaysValidator.optional(),
|
||||
reminderDays3: reminderDaysValidator.optional(),
|
||||
});
|
||||
export type CreateScheduleRequest = z.infer<typeof CreateScheduleSchema>;
|
||||
|
||||
@@ -132,6 +149,11 @@ export const UpdateScheduleSchema = z.object({
|
||||
intervalMiles: z.number().int().positive().nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
emailNotifications: z.boolean().optional(),
|
||||
scheduleType: ScheduleTypeSchema.optional(),
|
||||
fixedDueDate: z.string().nullable().optional(),
|
||||
reminderDays1: reminderDaysValidator.nullable().optional(),
|
||||
reminderDays2: reminderDaysValidator.nullable().optional(),
|
||||
reminderDays3: reminderDaysValidator.nullable().optional(),
|
||||
});
|
||||
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Add schedule_type to support different scheduling methods
|
||||
ALTER TABLE maintenance_schedules
|
||||
ADD COLUMN schedule_type VARCHAR(20) NOT NULL DEFAULT 'interval'
|
||||
CHECK (schedule_type IN ('interval', 'fixed_date', 'time_since_last'));
|
||||
|
||||
-- Add fixed_due_date for fixed date schedules
|
||||
ALTER TABLE maintenance_schedules
|
||||
ADD COLUMN fixed_due_date DATE;
|
||||
|
||||
-- Add reminder columns for progressive notifications
|
||||
ALTER TABLE maintenance_schedules
|
||||
ADD COLUMN reminder_days_1 INTEGER
|
||||
CHECK (reminder_days_1 IN (1, 7, 14, 30, 60));
|
||||
|
||||
ALTER TABLE maintenance_schedules
|
||||
ADD COLUMN reminder_days_2 INTEGER
|
||||
CHECK (reminder_days_2 IN (1, 7, 14, 30, 60));
|
||||
|
||||
ALTER TABLE maintenance_schedules
|
||||
ADD COLUMN reminder_days_3 INTEGER
|
||||
CHECK (reminder_days_3 IN (1, 7, 14, 30, 60));
|
||||
|
||||
-- Index for filtering by schedule type
|
||||
CREATE INDEX idx_maintenance_schedules_schedule_type ON maintenance_schedules(schedule_type);
|
||||
Reference in New Issue
Block a user