This commit is contained in:
Eric Gullickson
2025-10-16 19:20:30 -05:00
parent 225520ad30
commit 5638d3960b
68 changed files with 4164 additions and 18995 deletions

View File

@@ -43,7 +43,8 @@ make test
- `npm run build` - Build for production
- `npm start` - Run production build
- `npm test` - Run all tests
- `npm run test:feature -- --feature=vehicles` - Test specific feature
- `npm run test:feature --feature=vehicles` - Test specific feature
- `npm test -- features/vehicles` - Alternative: Test specific feature by path pattern
- `npm run schema:generate` - Generate combined schema
## Core Modules

View File

@@ -19,6 +19,7 @@ import { fuelLogsRoutes } from './features/fuel-logs/api/fuel-logs.routes';
import { stationsRoutes } from './features/stations/api/stations.routes';
import tenantManagementRoutes from './features/tenant-management/index';
import { documentsRoutes } from './features/documents/api/documents.routes';
import { maintenanceRoutes } from './features/maintenance';
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
@@ -113,26 +114,9 @@ async function buildApp(): Promise<FastifyInstance> {
await app.register(documentsRoutes, { prefix: '/api' });
await app.register(fuelLogsRoutes, { prefix: '/api' });
await app.register(stationsRoutes, { prefix: '/api' });
await app.register(maintenanceRoutes, { prefix: '/api' });
await app.register(tenantManagementRoutes);
// Maintenance feature placeholder (not yet implemented)
await app.register(async (fastify) => {
// Maintenance routes - basic placeholder for future implementation
fastify.get('/api/maintenance*', async (_request, reply) => {
return reply.code(501).send({
error: 'Not Implemented',
message: 'Maintenance feature not yet implemented'
});
});
fastify.post('/api/maintenance*', async (_request, reply) => {
return reply.code(501).send({
error: 'Not Implemented',
message: 'Maintenance feature not yet implemented'
});
});
});
// 404 handler
app.setNotFoundHandler(async (_request, reply) => {
return reply.code(404).send({ error: 'Route not found' });

View File

@@ -1,31 +1,89 @@
# Maintenance Feature Capsule
## Status
- WIP: Scaffolded; implementation pending. Track updates in `docs/changes/MULTI-TENANT-REDESIGN.md` and related feature plans.
## Quick Summary
Tracks vehicle maintenance including routine service, repairs, and performance upgrades. Supports multiple subtypes per record, recurring schedules, and upcoming/overdue calculations. User-scoped data with vehicle ownership enforcement.
## API Endpoints
### Maintenance Records
- `POST /api/maintenance/records` - Create a new maintenance record
- `GET /api/maintenance/records` - List all records (optional filters: vehicleId, category)
- `GET /api/maintenance/records/:id` - Get single record by ID
- `GET /api/maintenance/records/vehicle/:vehicleId` - Get all records for a vehicle
- `PUT /api/maintenance/records/:id` - Update existing record
- `DELETE /api/maintenance/records/:id` - Delete record
### Maintenance Schedules
- `POST /api/maintenance/schedules` - Create recurring schedule
- `GET /api/maintenance/schedules/vehicle/:vehicleId` - Get schedules for a vehicle
- `PUT /api/maintenance/schedules/:id` - Update schedule
- `DELETE /api/maintenance/schedules/:id` - Delete schedule
### Utilities
- `GET /api/maintenance/upcoming/:vehicleId` - Get upcoming/overdue maintenance (optional query: currentMileage)
- `GET /api/maintenance/subtypes/:category` - Get valid subtypes for a category
## Structure
- **api/** - HTTP endpoints, routes, validators
- **domain/** - Business logic, types, rules
- **data/** - Repository, database queries
- **migrations/** - Feature-specific schema
- **external/** - External API integrations
- **events/** - Event handlers
- **tests/** - All feature tests
- **docs/** - Detailed documentation
## Categories and Subtypes
### Routine Maintenance (27 subtypes)
Accelerator Pedal, Air Filter Element, Brakes and Traction Control, Cabin Air Filter / Purifier, Coolant, Doors, Drive Belt, Engine Oil, Evaporative Emissions System, Exhaust System, Fluid - A/T, Fluid - Differential, Fluid - M/T, Fluid Filter - A/T, Fluids, Fuel Delivery and Air Induction, Hood Shock / Support, Neutral Safety Switch, Parking Brake System, Restraints and Safety Systems, Shift Interlock A/T, Spark Plug, Steering and Suspension, Tires, Trunk / Liftgate Shock / Support, Washer Fluid, Wiper Blade
### Repair (5 subtypes)
Engine, Transmission, Drivetrain, Exterior, Interior
### Performance Upgrade (5 subtypes)
Engine, Drivetrain, Suspension, Wheels/Tires, Exterior
## Dependencies
- Internal: core/auth, core/cache
- External: (none)
- Database: maintenance table (see `docs/DATABASE-SCHEMA.md`)
## Quick Commands
### Internal
- `core/auth` - Authentication plugin
- `core/logging` - Structured logging
- `core/config` - Database pool
### Database
- Tables: `maintenance_records`, `maintenance_schedules`
- FK: `vehicles(id)` - CASCADE DELETE
## Business Rules
### Validation
1. Category must be: `routine_maintenance`, `repair`, or `performance_upgrade`
2. Subtypes array must be non-empty
3. All subtypes must be valid for the selected category
4. Date required for records
5. Vehicle must belong to user (ownership check)
6. At least one interval (months OR miles OR both) required for schedules
### Next Due Calculation
If interval_months AND interval_miles both set, due when EITHER condition is met (whichever comes first). If only one interval set, calculate based on that single criterion.
### Due Soon / Overdue Logic
**Due Soon**: next_due_date within 30 days OR next_due_mileage within 500 miles
**Overdue**: next_due_date in the past OR next_due_mileage < current odometer
## Security Requirements
1. All queries user-scoped (filter by user_id)
2. Vehicle ownership validated before operations
3. Prepared statements (never concatenate SQL)
4. All routes require JWT authentication
5. Users can only access their own data
## Testing
```bash
# Run feature tests
npm test -- features/maintenance
# Run feature migrations
npm run migrate:feature maintenance
```
## API (planned)
- Endpoints and business rules to be finalized; depends on vehicles. See `docs/DATABASE-SCHEMA.md` for current table shape and indexes.

View File

@@ -0,0 +1,552 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { MaintenanceService } from '../domain/maintenance.service';
import { logger } from '../../../core/logging/logger';
import {
CreateMaintenanceRecordSchema,
UpdateMaintenanceRecordSchema,
CreateScheduleSchema,
UpdateScheduleSchema,
getSubtypesForCategory,
MaintenanceCategory
} from '../domain/maintenance.types';
import { z } from 'zod';
export class MaintenanceController {
private readonly service = new MaintenanceService();
async listRecords(
request: FastifyRequest<{ Querystring: { vehicleId?: string; category?: string } }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
logger.info('Maintenance records list requested', {
operation: 'maintenance.records.list',
user_id: userId,
filters: {
vehicle_id: request.query.vehicleId,
category: request.query.category,
},
});
try {
const filters: { vehicleId?: string; category?: MaintenanceCategory } = {};
if (request.query.vehicleId) {
filters.vehicleId = request.query.vehicleId;
}
if (request.query.category) {
filters.category = request.query.category as MaintenanceCategory;
}
const records = await this.service.getRecords(userId, filters);
logger.info('Maintenance records list retrieved', {
operation: 'maintenance.records.list.success',
user_id: userId,
record_count: 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,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
async getRecord(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const recordId = request.params.id;
logger.info('Maintenance record get requested', {
operation: 'maintenance.records.get',
user_id: userId,
record_id: recordId,
});
try {
const record = await this.service.getRecord(userId, recordId);
if (!record) {
logger.warn('Maintenance record not found', {
operation: 'maintenance.records.get.not_found',
user_id: userId,
record_id: 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,
category: record.category,
});
return reply.code(200).send(record);
} catch (error) {
logger.error('Failed to get maintenance record', {
operation: 'maintenance.records.get.error',
user_id: userId,
record_id: recordId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
async getRecordsByVehicle(
request: FastifyRequest<{ Params: { vehicleId: string } }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const vehicleId = request.params.vehicleId;
logger.info('Maintenance records by vehicle requested', {
operation: 'maintenance.records.by_vehicle',
user_id: userId,
vehicle_id: vehicleId,
});
try {
const records = await this.service.getRecordsByVehicle(userId, vehicleId);
logger.info('Maintenance records by vehicle retrieved', {
operation: 'maintenance.records.by_vehicle.success',
user_id: userId,
vehicle_id: vehicleId,
record_count: 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,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
async createRecord(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
logger.info('Maintenance record create requested', {
operation: 'maintenance.records.create',
user_id: userId,
});
try {
const validated = CreateMaintenanceRecordSchema.parse(request.body);
const record = await this.service.createRecord(userId, validated);
logger.info('Maintenance record created', {
operation: 'maintenance.records.create.success',
user_id: userId,
record_id: record.id,
vehicle_id: record.vehicle_id,
category: record.category,
subtype_count: record.subtypes.length,
});
return reply.code(201).send(record);
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn('Maintenance record validation failed', {
operation: 'maintenance.records.create.validation_error',
user_id: userId,
errors: error.errors,
});
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
}
if (error instanceof Error && 'statusCode' in error) {
const statusCode = (error as any).statusCode;
logger.warn('Maintenance record creation failed', {
operation: 'maintenance.records.create.error',
user_id: userId,
status_code: statusCode,
error: error.message,
});
return reply.code(statusCode).send({ error: error.message });
}
logger.error('Failed to create maintenance record', {
operation: 'maintenance.records.create.error',
user_id: userId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
async updateRecord(
request: FastifyRequest<{ Params: { id: string }; Body: unknown }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const recordId = request.params.id;
logger.info('Maintenance record update requested', {
operation: 'maintenance.records.update',
user_id: userId,
record_id: recordId,
});
try {
const validated = UpdateMaintenanceRecordSchema.parse(request.body);
const record = await this.service.updateRecord(userId, recordId, validated);
if (!record) {
logger.warn('Maintenance record not found for update', {
operation: 'maintenance.records.update.not_found',
user_id: userId,
record_id: 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,
category: record.category,
});
return reply.code(200).send(record);
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn('Maintenance record update validation failed', {
operation: 'maintenance.records.update.validation_error',
user_id: userId,
record_id: recordId,
errors: error.errors,
});
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
}
if (error instanceof Error && 'statusCode' in error) {
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,
error: error.message,
});
return reply.code(statusCode).send({ error: error.message });
}
logger.error('Failed to update maintenance record', {
operation: 'maintenance.records.update.error',
user_id: userId,
record_id: recordId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
async deleteRecord(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const recordId = request.params.id;
logger.info('Maintenance record delete requested', {
operation: 'maintenance.records.delete',
user_id: userId,
record_id: recordId,
});
try {
await this.service.deleteRecord(userId, recordId);
logger.info('Maintenance record deleted', {
operation: 'maintenance.records.delete.success',
user_id: userId,
record_id: 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,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
async getSchedulesByVehicle(
request: FastifyRequest<{ Params: { vehicleId: string } }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const vehicleId = request.params.vehicleId;
logger.info('Maintenance schedules by vehicle requested', {
operation: 'maintenance.schedules.by_vehicle',
user_id: userId,
vehicle_id: vehicleId,
});
try {
const schedules = await this.service.getSchedulesByVehicle(userId, vehicleId);
logger.info('Maintenance schedules by vehicle retrieved', {
operation: 'maintenance.schedules.by_vehicle.success',
user_id: userId,
vehicle_id: vehicleId,
schedule_count: 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,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
async createSchedule(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
logger.info('Maintenance schedule create requested', {
operation: 'maintenance.schedules.create',
user_id: userId,
});
try {
const validated = CreateScheduleSchema.parse(request.body);
const schedule = await this.service.createSchedule(userId, validated);
logger.info('Maintenance schedule created', {
operation: 'maintenance.schedules.create.success',
user_id: userId,
schedule_id: schedule.id,
vehicle_id: schedule.vehicle_id,
category: schedule.category,
subtype_count: schedule.subtypes.length,
});
return reply.code(201).send(schedule);
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn('Maintenance schedule validation failed', {
operation: 'maintenance.schedules.create.validation_error',
user_id: userId,
errors: error.errors,
});
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
}
if (error instanceof Error && 'statusCode' in error) {
const statusCode = (error as any).statusCode;
logger.warn('Maintenance schedule creation failed', {
operation: 'maintenance.schedules.create.error',
user_id: userId,
status_code: statusCode,
error: error.message,
});
return reply.code(statusCode).send({ error: error.message });
}
logger.error('Failed to create maintenance schedule', {
operation: 'maintenance.schedules.create.error',
user_id: userId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
async updateSchedule(
request: FastifyRequest<{ Params: { id: string }; Body: unknown }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const scheduleId = request.params.id;
logger.info('Maintenance schedule update requested', {
operation: 'maintenance.schedules.update',
user_id: userId,
schedule_id: scheduleId,
});
try {
const validated = UpdateScheduleSchema.parse(request.body);
const schedule = await this.service.updateSchedule(userId, scheduleId, validated);
if (!schedule) {
logger.warn('Maintenance schedule not found for update', {
operation: 'maintenance.schedules.update.not_found',
user_id: userId,
schedule_id: 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,
category: schedule.category,
});
return reply.code(200).send(schedule);
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn('Maintenance schedule update validation failed', {
operation: 'maintenance.schedules.update.validation_error',
user_id: userId,
schedule_id: scheduleId,
errors: error.errors,
});
return reply.code(400).send({ error: 'Bad Request', details: error.errors });
}
if (error instanceof Error && 'statusCode' in error) {
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,
error: error.message,
});
return reply.code(statusCode).send({ error: error.message });
}
logger.error('Failed to update maintenance schedule', {
operation: 'maintenance.schedules.update.error',
user_id: userId,
schedule_id: scheduleId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
async deleteSchedule(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const scheduleId = request.params.id;
logger.info('Maintenance schedule delete requested', {
operation: 'maintenance.schedules.delete',
user_id: userId,
schedule_id: scheduleId,
});
try {
await this.service.deleteSchedule(userId, scheduleId);
logger.info('Maintenance schedule deleted', {
operation: 'maintenance.schedules.delete.success',
user_id: userId,
schedule_id: 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,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
async getUpcoming(
request: FastifyRequest<{ Params: { vehicleId: string }; Querystring: { currentMileage?: string } }>,
reply: FastifyReply
) {
const userId = (request as any).user?.sub as string;
const vehicleId = request.params.vehicleId;
const currentMileage = request.query.currentMileage ? parseInt(request.query.currentMileage, 10) : undefined;
logger.info('Upcoming maintenance requested', {
operation: 'maintenance.upcoming',
user_id: userId,
vehicle_id: vehicleId,
current_mileage: currentMileage,
});
try {
const upcoming = await this.service.getUpcomingMaintenance(userId, vehicleId, currentMileage);
logger.info('Upcoming maintenance retrieved', {
operation: 'maintenance.upcoming.success',
user_id: userId,
vehicle_id: vehicleId,
upcoming_count: 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,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
async getSubtypes(request: FastifyRequest<{ Params: { category: string } }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const category = request.params.category;
logger.info('Maintenance subtypes requested', {
operation: 'maintenance.subtypes',
user_id: userId,
category: 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,
});
return reply.code(400).send({ error: 'Bad Request', message: 'Invalid category' });
}
const subtypes = getSubtypesForCategory(category as MaintenanceCategory);
logger.info('Maintenance subtypes retrieved', {
operation: 'maintenance.subtypes.success',
user_id: userId,
category: category,
subtype_count: 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,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
}

View File

@@ -0,0 +1,77 @@
/**
* @ai-summary Fastify routes for maintenance API
*/
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify';
import { tenantMiddleware } from '../../../core/middleware/tenant';
import { MaintenanceController } from './maintenance.controller';
export const maintenanceRoutes: FastifyPluginAsync = async (
fastify: FastifyInstance,
_opts: FastifyPluginOptions
) => {
const ctrl = new MaintenanceController();
const requireAuth = fastify.authenticate.bind(fastify);
// Maintenance Records
fastify.get('/maintenance/records', {
preHandler: [requireAuth, tenantMiddleware as any],
handler: ctrl.listRecords.bind(ctrl)
});
fastify.get<{ Params: any }>('/maintenance/records/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
handler: ctrl.getRecord.bind(ctrl)
});
fastify.get<{ Params: any }>('/maintenance/records/vehicle/:vehicleId', {
preHandler: [requireAuth, tenantMiddleware as any],
handler: ctrl.getRecordsByVehicle.bind(ctrl)
});
fastify.post<{ Body: any }>('/maintenance/records', {
preHandler: [requireAuth, tenantMiddleware as any],
handler: ctrl.createRecord.bind(ctrl)
});
fastify.put<{ Params: any; Body: any }>('/maintenance/records/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
handler: ctrl.updateRecord.bind(ctrl)
});
fastify.delete<{ Params: any }>('/maintenance/records/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
handler: ctrl.deleteRecord.bind(ctrl)
});
// Maintenance Schedules
fastify.get<{ Params: any }>('/maintenance/schedules/vehicle/:vehicleId', {
preHandler: [requireAuth, tenantMiddleware as any],
handler: ctrl.getSchedulesByVehicle.bind(ctrl)
});
fastify.post<{ Body: any }>('/maintenance/schedules', {
preHandler: [requireAuth, tenantMiddleware as any],
handler: ctrl.createSchedule.bind(ctrl)
});
fastify.put<{ Params: any; Body: any }>('/maintenance/schedules/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
handler: ctrl.updateSchedule.bind(ctrl)
});
fastify.delete<{ Params: any }>('/maintenance/schedules/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
handler: ctrl.deleteSchedule.bind(ctrl)
});
// Utility Routes
fastify.get<{ Params: { vehicleId: string }; Querystring: { currentMileage?: string } }>('/maintenance/upcoming/:vehicleId', {
preHandler: [requireAuth, tenantMiddleware as any],
handler: ctrl.getUpcoming.bind(ctrl)
});
fastify.get<{ Params: any }>('/maintenance/subtypes/:category', {
preHandler: [requireAuth, tenantMiddleware as any],
handler: ctrl.getSubtypes.bind(ctrl)
});
};

View File

@@ -0,0 +1,262 @@
import { Pool } from 'pg';
import pool from '../../../core/config/database';
import type { MaintenanceRecord, MaintenanceSchedule, MaintenanceCategory } from '../domain/maintenance.types';
export class MaintenanceRepository {
constructor(private readonly db: Pool = pool) {}
// ========================
// Maintenance Records
// ========================
async insertRecord(record: {
id: string;
user_id: string;
vehicle_id: string;
category: MaintenanceCategory;
subtypes: string[];
date: string;
odometer_reading?: number | null;
cost?: number | null;
shop_name?: string | null;
notes?: string | null;
}): Promise<MaintenanceRecord> {
const res = await this.db.query(
`INSERT INTO maintenance_records (
id, user_id, vehicle_id, category, subtypes, date, odometer_reading, cost, shop_name, notes
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10)
RETURNING *`,
[
record.id,
record.user_id,
record.vehicle_id,
record.category,
record.subtypes,
record.date,
record.odometer_reading ?? null,
record.cost ?? null,
record.shop_name ?? null,
record.notes ?? null,
]
);
return res.rows[0] as MaintenanceRecord;
}
async findRecordById(id: string, userId: string): Promise<MaintenanceRecord | null> {
const res = await this.db.query(
`SELECT * FROM maintenance_records WHERE id = $1 AND user_id = $2`,
[id, userId]
);
return res.rows[0] || null;
}
async findRecordsByUserId(
userId: string,
filters?: { vehicleId?: string; category?: MaintenanceCategory }
): Promise<MaintenanceRecord[]> {
const conds: string[] = ['user_id = $1'];
const params: any[] = [userId];
let i = 2;
if (filters?.vehicleId) {
conds.push(`vehicle_id = $${i++}`);
params.push(filters.vehicleId);
}
if (filters?.category) {
conds.push(`category = $${i++}`);
params.push(filters.category);
}
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[];
}
async findRecordsByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceRecord[]> {
const res = await this.db.query(
`SELECT * FROM maintenance_records WHERE vehicle_id = $1 AND user_id = $2 ORDER BY date DESC`,
[vehicleId, userId]
);
return res.rows as MaintenanceRecord[];
}
async updateRecord(
id: string,
userId: string,
patch: Partial<Pick<MaintenanceRecord, 'category' | 'subtypes' | 'date' | 'odometer_reading' | 'cost' | 'shop_name' | 'notes'>>
): Promise<MaintenanceRecord | null> {
const fields: string[] = [];
const params: any[] = [];
let i = 1;
if (patch.category !== undefined) {
fields.push(`category = $${i++}`);
params.push(patch.category);
}
if (patch.subtypes !== undefined) {
fields.push(`subtypes = $${i++}::text[]`);
params.push(patch.subtypes);
}
if (patch.date !== undefined) {
fields.push(`date = $${i++}`);
params.push(patch.date);
}
if (patch.odometer_reading !== undefined) {
fields.push(`odometer_reading = $${i++}`);
params.push(patch.odometer_reading);
}
if (patch.cost !== undefined) {
fields.push(`cost = $${i++}`);
params.push(patch.cost);
}
if (patch.shop_name !== undefined) {
fields.push(`shop_name = $${i++}`);
params.push(patch.shop_name);
}
if (patch.notes !== undefined) {
fields.push(`notes = $${i++}`);
params.push(patch.notes);
}
if (!fields.length) return this.findRecordById(id, userId);
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;
}
async deleteRecord(id: string, userId: string): Promise<void> {
await this.db.query(
`DELETE FROM maintenance_records WHERE id = $1 AND user_id = $2`,
[id, userId]
);
}
// ========================
// Maintenance Schedules
// ========================
async insertSchedule(schedule: {
id: string;
user_id: string;
vehicle_id: 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;
}): 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)
RETURNING *`,
[
schedule.id,
schedule.user_id,
schedule.vehicle_id,
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,
]
);
return res.rows[0] as MaintenanceSchedule;
}
async findScheduleById(id: string, userId: string): Promise<MaintenanceSchedule | null> {
const res = await this.db.query(
`SELECT * FROM maintenance_schedules WHERE id = $1 AND user_id = $2`,
[id, userId]
);
return res.rows[0] || null;
}
async findSchedulesByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceSchedule[]> {
const res = await this.db.query(
`SELECT * FROM maintenance_schedules WHERE vehicle_id = $1 AND user_id = $2 ORDER BY created_at DESC`,
[vehicleId, userId]
);
return res.rows as MaintenanceSchedule[];
}
async findActiveSchedulesByVehicleId(vehicleId: string, userId: string): Promise<MaintenanceSchedule[]> {
const res = await this.db.query(
`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[];
}
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'>>
): Promise<MaintenanceSchedule | null> {
const fields: string[] = [];
const params: any[] = [];
let i = 1;
if (patch.category !== undefined) {
fields.push(`category = $${i++}`);
params.push(patch.category);
}
if (patch.subtypes !== undefined) {
fields.push(`subtypes = $${i++}::text[]`);
params.push(patch.subtypes);
}
if (patch.interval_months !== undefined) {
fields.push(`interval_months = $${i++}`);
params.push(patch.interval_months);
}
if (patch.interval_miles !== undefined) {
fields.push(`interval_miles = $${i++}`);
params.push(patch.interval_miles);
}
if (patch.last_service_date !== undefined) {
fields.push(`last_service_date = $${i++}`);
params.push(patch.last_service_date);
}
if (patch.last_service_mileage !== undefined) {
fields.push(`last_service_mileage = $${i++}`);
params.push(patch.last_service_mileage);
}
if (patch.next_due_date !== undefined) {
fields.push(`next_due_date = $${i++}`);
params.push(patch.next_due_date);
}
if (patch.next_due_mileage !== undefined) {
fields.push(`next_due_mileage = $${i++}`);
params.push(patch.next_due_mileage);
}
if (patch.is_active !== undefined) {
fields.push(`is_active = $${i++}`);
params.push(patch.is_active);
}
if (!fields.length) return this.findScheduleById(id, userId);
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;
}
async deleteSchedule(id: string, userId: string): Promise<void> {
await this.db.query(
`DELETE FROM maintenance_schedules WHERE id = $1 AND user_id = $2`,
[id, userId]
);
}
}

View File

@@ -0,0 +1,256 @@
import { randomUUID } from 'crypto';
import type {
CreateMaintenanceRecordRequest,
UpdateMaintenanceRecordRequest,
CreateScheduleRequest,
UpdateScheduleRequest,
MaintenanceRecord,
MaintenanceSchedule,
MaintenanceRecordResponse,
MaintenanceScheduleResponse,
MaintenanceCategory
} from './maintenance.types';
import { validateSubtypes } from './maintenance.types';
import { MaintenanceRepository } from '../data/maintenance.repository';
import pool from '../../../core/config/database';
export class MaintenanceService {
private readonly repo = new MaintenanceRepository(pool);
async createRecord(userId: string, body: CreateMaintenanceRecordRequest): Promise<MaintenanceRecord> {
await this.assertVehicleOwnership(userId, body.vehicle_id);
if (!validateSubtypes(body.category, body.subtypes)) {
const err: any = new Error('Invalid subtypes for selected category');
err.statusCode = 400;
throw err;
}
const id = randomUUID();
return this.repo.insertRecord({
id,
user_id: userId,
vehicle_id: body.vehicle_id,
category: body.category,
subtypes: body.subtypes,
date: body.date,
odometer_reading: body.odometer_reading,
cost: body.cost,
shop_name: body.shop_name,
notes: body.notes,
});
}
async getRecord(userId: string, id: string): Promise<MaintenanceRecordResponse | null> {
const record = await this.repo.findRecordById(id, userId);
if (!record) return null;
return this.toRecordResponse(record);
}
async getRecords(userId: string, filters?: { vehicleId?: string; category?: MaintenanceCategory }): Promise<MaintenanceRecordResponse[]> {
const records = await this.repo.findRecordsByUserId(userId, filters);
return records.map(r => this.toRecordResponse(r));
}
async getRecordsByVehicle(userId: string, vehicleId: string): Promise<MaintenanceRecordResponse[]> {
const records = await this.repo.findRecordsByVehicleId(vehicleId, userId);
return records.map(r => this.toRecordResponse(r));
}
async updateRecord(userId: string, id: string, patch: UpdateMaintenanceRecordRequest): Promise<MaintenanceRecordResponse | null> {
const existing = await this.repo.findRecordById(id, userId);
if (!existing) return null;
if (patch.category || patch.subtypes) {
const category = patch.category || existing.category;
const subtypes = patch.subtypes || existing.subtypes;
if (!validateSubtypes(category, subtypes)) {
const err: any = new Error('Invalid subtypes for selected category');
err.statusCode = 400;
throw err;
}
}
// 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'>>;
const updated = await this.repo.updateRecord(id, userId, cleanPatch);
if (!updated) return null;
return this.toRecordResponse(updated);
}
async deleteRecord(userId: string, id: string): Promise<void> {
await this.repo.deleteRecord(id, userId);
}
async createSchedule(userId: string, body: CreateScheduleRequest): Promise<MaintenanceSchedule> {
await this.assertVehicleOwnership(userId, body.vehicle_id);
if (!validateSubtypes(body.category, body.subtypes)) {
const err: any = new Error('Invalid subtypes for selected category');
err.statusCode = 400;
throw err;
}
if (!body.interval_months && !body.interval_miles) {
const err: any = new Error('At least one interval (months or miles) is required');
err.statusCode = 400;
throw err;
}
const id = randomUUID();
return this.repo.insertSchedule({
id,
user_id: userId,
vehicle_id: body.vehicle_id,
category: body.category,
subtypes: body.subtypes,
interval_months: body.interval_months,
interval_miles: body.interval_miles,
is_active: true,
});
}
async getSchedules(userId: string, filters?: { vehicleId?: string }): Promise<MaintenanceScheduleResponse[]> {
let schedules: MaintenanceSchedule[];
if (filters?.vehicleId) {
schedules = await this.repo.findSchedulesByVehicleId(filters.vehicleId, userId);
} else {
schedules = await this.repo.findSchedulesByVehicleId('', userId);
}
return schedules.map(s => this.toScheduleResponse(s));
}
async getSchedulesByVehicle(userId: string, vehicleId: string): Promise<MaintenanceScheduleResponse[]> {
const schedules = await this.repo.findSchedulesByVehicleId(vehicleId, userId);
return schedules.map(s => this.toScheduleResponse(s));
}
async updateSchedule(userId: string, id: string, patch: UpdateScheduleRequest): Promise<MaintenanceScheduleResponse | null> {
const existing = await this.repo.findScheduleById(id, userId);
if (!existing) return null;
if (patch.category || patch.subtypes) {
const category = patch.category || existing.category;
const subtypes = patch.subtypes || existing.subtypes;
if (!validateSubtypes(category, subtypes)) {
const err: any = new Error('Invalid subtypes for selected category');
err.statusCode = 400;
throw err;
}
}
const needsRecalculation =
patch.interval_months !== undefined ||
patch.interval_miles !== undefined;
let 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,
});
patchWithRecalc.next_due_date = nextDue.next_due_date ?? undefined;
patchWithRecalc.next_due_mileage = nextDue.next_due_mileage ?? 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'>>;
const updated = await this.repo.updateSchedule(id, userId, cleanPatch);
if (!updated) return null;
return this.toScheduleResponse(updated);
}
async deleteSchedule(userId: string, id: string): Promise<void> {
await this.repo.deleteSchedule(id, userId);
}
async getUpcomingMaintenance(userId: string, vehicleId: string, currentMileage?: number): Promise<MaintenanceScheduleResponse[]> {
const schedules = await this.repo.findActiveSchedulesByVehicleId(vehicleId, userId);
const today = new Date().toISOString().split('T')[0];
return schedules
.map(s => this.toScheduleResponse(s, today, currentMileage))
.filter(s => s.is_due_soon || s.is_overdue);
}
private async assertVehicleOwnership(userId: string, vehicleId: string) {
const res = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
if (!res.rows[0]) {
const err: any = new Error('Vehicle not found or not owned by user');
err.statusCode = 403;
throw err;
}
}
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;
if (schedule.last_service_date && schedule.interval_months) {
const lastDate = new Date(schedule.last_service_date);
const nextDate = new Date(lastDate);
nextDate.setMonth(nextDate.getMonth() + schedule.interval_months);
next_due_date = 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;
}
return { next_due_date, next_due_mileage };
}
private toRecordResponse(record: MaintenanceRecord): MaintenanceRecordResponse {
return {
...record,
subtype_count: 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;
if (schedule.next_due_date) {
const nextDue = new Date(schedule.next_due_date);
const todayDate = new Date(todayStr);
const daysUntilDue = Math.floor((nextDue.getTime() - todayDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysUntilDue < 0) {
is_overdue = true;
} else if (daysUntilDue <= 30) {
is_due_soon = true;
}
}
if (currentMileage !== undefined && schedule.next_due_mileage !== null && schedule.next_due_mileage !== undefined) {
const milesUntilDue = schedule.next_due_mileage - currentMileage;
if (milesUntilDue < 0) {
is_overdue = true;
} else if (milesUntilDue <= 500) {
is_due_soon = true;
}
}
return {
...schedule,
subtype_count: schedule.subtypes.length,
is_due_soon,
is_overdue,
};
}
}

View File

@@ -0,0 +1,167 @@
/**
* @ai-summary Type definitions for maintenance feature
* @ai-context Supports three categories with specific subtypes, multiple selections allowed
*/
import { z } from 'zod';
// Category types
export type MaintenanceCategory = 'routine_maintenance' | 'repair' | 'performance_upgrade';
// Subtype definitions (constants for validation)
export const ROUTINE_MAINTENANCE_SUBTYPES = [
'Accelerator Pedal',
'Air Filter Element',
'Brakes and Traction Control',
'Cabin Air Filter / Purifier',
'Coolant',
'Doors',
'Drive Belt',
'Engine Oil',
'Evaporative Emissions System',
'Exhaust System',
'Fluid - A/T',
'Fluid - Differential',
'Fluid - M/T',
'Fluid Filter - A/T',
'Fluids',
'Fuel Delivery and Air Induction',
'Hood Shock / Support',
'Neutral Safety Switch',
'Parking Brake System',
'Restraints and Safety Systems',
'Shift Interlock, A/T',
'Spark Plug',
'Steering and Suspension',
'Tires',
'Trunk / Liftgate Shock / Support',
'Washer Fluid',
'Wiper Blade'
] as const;
export const REPAIR_SUBTYPES = [
'Engine',
'Transmission',
'Drivetrain',
'Exterior',
'Interior'
] as const;
export const PERFORMANCE_UPGRADE_SUBTYPES = [
'Engine',
'Drivetrain',
'Suspension',
'Wheels/Tires',
'Exterior'
] as const;
// Database record types
export interface MaintenanceRecord {
id: string;
user_id: string;
vehicle_id: string;
category: MaintenanceCategory;
subtypes: string[];
date: string;
odometer_reading?: number;
cost?: number;
shop_name?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface MaintenanceSchedule {
id: string;
user_id: string;
vehicle_id: 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;
}
// Zod schemas for validation
export const MaintenanceCategorySchema = z.enum(['routine_maintenance', 'repair', 'performance_upgrade']);
export const CreateMaintenanceRecordSchema = z.object({
vehicle_id: z.string().uuid(),
category: MaintenanceCategorySchema,
subtypes: z.array(z.string()).min(1),
date: z.string(),
odometer_reading: z.number().int().positive().optional(),
cost: z.number().positive().optional(),
shop_name: z.string().max(200).optional(),
notes: z.string().max(10000).optional(),
});
export type CreateMaintenanceRecordRequest = z.infer<typeof CreateMaintenanceRecordSchema>;
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(),
cost: z.number().positive().nullable().optional(),
shop_name: 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(),
category: MaintenanceCategorySchema,
subtypes: z.array(z.string()).min(1),
interval_months: z.number().int().positive().optional(),
interval_miles: z.number().int().positive().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(),
});
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;
// Response types
export interface MaintenanceRecordResponse extends MaintenanceRecord {
subtype_count: number;
}
export interface MaintenanceScheduleResponse extends MaintenanceSchedule {
subtype_count: number;
is_due_soon?: boolean;
is_overdue?: boolean;
}
// Validation helpers
export function getSubtypesForCategory(category: MaintenanceCategory): readonly string[] {
switch (category) {
case 'routine_maintenance': return ROUTINE_MAINTENANCE_SUBTYPES;
case 'repair': return REPAIR_SUBTYPES;
case 'performance_upgrade': return PERFORMANCE_UPGRADE_SUBTYPES;
}
}
export function validateSubtypes(category: MaintenanceCategory, subtypes: string[]): boolean {
if (!subtypes || subtypes.length === 0) return false;
const validSubtypes = getSubtypesForCategory(category);
return subtypes.every(st => validSubtypes.includes(st as any));
}
export function getCategoryDisplayName(category: MaintenanceCategory): string {
switch (category) {
case 'routine_maintenance': return 'Routine Maintenance';
case 'repair': return 'Repair';
case 'performance_upgrade': return 'Performance Upgrade';
}
}

View File

@@ -1,14 +1,7 @@
/**
* @ai-summary Public API for maintenance feature capsule
* @ai-note This is the ONLY file other features should import from
* @ai-status Scaffolded - implementation pending
*/
// TODO: Implement maintenance service and types
// Currently scaffolded feature - no exports until implementation is complete
// Placeholder to prevent build errors
export const MaintenanceFeature = {
status: 'scaffolded',
message: 'Maintenance feature not yet implemented'
} as const;
export { maintenanceRoutes } from './api/maintenance.routes';
export * from './domain/maintenance.types';

View File

@@ -0,0 +1,80 @@
-- Drop existing tables (clean slate)
DROP TABLE IF EXISTS maintenance_schedules CASCADE;
DROP TABLE IF EXISTS maintenance_logs CASCADE;
-- Create maintenance_records table
CREATE TABLE maintenance_records (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
vehicle_id UUID NOT NULL,
category VARCHAR(50) NOT NULL,
subtypes TEXT[] NOT NULL,
date DATE NOT NULL,
odometer_reading INTEGER,
cost DECIMAL(10, 2),
shop_name VARCHAR(200),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_maintenance_vehicle
FOREIGN KEY (vehicle_id)
REFERENCES vehicles(id)
ON DELETE CASCADE,
CONSTRAINT check_category
CHECK (category IN ('routine_maintenance', 'repair', 'performance_upgrade')),
CONSTRAINT check_subtypes_not_empty
CHECK (array_length(subtypes, 1) > 0)
);
-- Create maintenance_schedules table
CREATE TABLE maintenance_schedules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
vehicle_id UUID NOT NULL,
category VARCHAR(50) NOT NULL,
subtypes TEXT[] NOT NULL,
interval_months INTEGER,
interval_miles INTEGER,
last_service_date DATE,
last_service_mileage INTEGER,
next_due_date DATE,
next_due_mileage INTEGER,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_schedule_vehicle
FOREIGN KEY (vehicle_id)
REFERENCES vehicles(id)
ON DELETE CASCADE,
CONSTRAINT check_schedule_category
CHECK (category IN ('routine_maintenance', 'repair', 'performance_upgrade'))
);
-- Indexes for performance
CREATE INDEX idx_maintenance_records_user_id ON maintenance_records(user_id);
CREATE INDEX idx_maintenance_records_vehicle_id ON maintenance_records(vehicle_id);
CREATE INDEX idx_maintenance_records_date ON maintenance_records(date DESC);
CREATE INDEX idx_maintenance_records_category ON maintenance_records(category);
CREATE INDEX idx_maintenance_schedules_user_id ON maintenance_schedules(user_id);
CREATE INDEX idx_maintenance_schedules_vehicle_id ON maintenance_schedules(vehicle_id);
CREATE INDEX idx_maintenance_schedules_next_due_date ON maintenance_schedules(next_due_date);
CREATE INDEX idx_maintenance_schedules_active ON maintenance_schedules(is_active) WHERE is_active = true;
-- Triggers for updated_at
DROP TRIGGER IF EXISTS update_maintenance_records_updated_at ON maintenance_records;
CREATE TRIGGER update_maintenance_records_updated_at
BEFORE UPDATE ON maintenance_records
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_maintenance_schedules_updated_at ON maintenance_schedules;
CREATE TRIGGER update_maintenance_schedules_updated_at
BEFORE UPDATE ON maintenance_schedules
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();