Update
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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.
|
||||
|
||||
552
backend/src/features/maintenance/api/maintenance.controller.ts
Normal file
552
backend/src/features/maintenance/api/maintenance.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
77
backend/src/features/maintenance/api/maintenance.routes.ts
Normal file
77
backend/src/features/maintenance/api/maintenance.routes.ts
Normal 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)
|
||||
});
|
||||
};
|
||||
262
backend/src/features/maintenance/data/maintenance.repository.ts
Normal file
262
backend/src/features/maintenance/data/maintenance.repository.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
256
backend/src/features/maintenance/domain/maintenance.service.ts
Normal file
256
backend/src/features/maintenance/domain/maintenance.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
167
backend/src/features/maintenance/domain/maintenance.types.ts
Normal file
167
backend/src/features/maintenance/domain/maintenance.types.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user