diff --git a/backend/package-lock.json b/backend/package-lock.json index 87722f8..59920d7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,6 +22,7 @@ "get-jwks": "^11.0.3", "ioredis": "^5.4.2", "js-yaml": "^4.1.0", + "node-cron": "^3.0.3", "opossum": "^8.0.0", "pg": "^8.13.1", "resend": "^3.0.0", @@ -33,6 +34,7 @@ "@types/jest": "^29.5.10", "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", + "@types/node-cron": "^3.0.11", "@types/opossum": "^8.0.0", "@types/pg": "^8.10.9", "@types/supertest": "^6.0.3", @@ -77,7 +79,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1946,11 +1947,17 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/opossum": { "version": "8.1.9", "resolved": "https://registry.npmjs.org/@types/opossum/-/opossum-8.1.9.tgz", @@ -2072,7 +2079,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -2317,7 +2323,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2763,7 +2768,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3609,7 +3613,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4931,7 +4934,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5852,6 +5854,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -6058,6 +6061,18 @@ "dev": true, "license": "MIT" }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -6430,7 +6445,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -7152,6 +7166,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -7692,7 +7707,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7850,7 +7864,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7938,7 +7951,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8046,6 +8058,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 362c464..36e2d36 100644 --- a/backend/package.json +++ b/backend/package.json @@ -36,12 +36,14 @@ "@fastify/autoload": "^6.0.1", "get-jwks": "^11.0.3", "file-type": "^16.5.4", - "resend": "^3.0.0" + "resend": "^3.0.0", + "node-cron": "^3.0.3" }, "devDependencies": { "@types/node": "^22.0.0", "@types/pg": "^8.10.9", "@types/js-yaml": "^4.0.9", + "@types/node-cron": "^3.0.11", "typescript": "^5.7.2", "ts-node": "^10.9.1", "nodemon": "^3.1.9", diff --git a/backend/src/app.ts b/backend/src/app.ts index 7f35c15..cf2aa91 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -100,9 +100,9 @@ async function buildApp(): Promise { app.get('/auth/verify', { preHandler: [app.authenticate] }, async (request, reply) => { - const user = request.user ?? {}; - const userId = typeof user.sub === 'string' ? user.sub : 'unknown'; - const rolesClaim = user['https://motovaultpro.com/roles']; + const user = request.user; + const userId = user?.sub || 'unknown'; + const rolesClaim = user?.['https://motovaultpro.com/roles']; const roles = Array.isArray(rolesClaim) ? rolesClaim : []; reply diff --git a/backend/src/core/plugins/auth.plugin.ts b/backend/src/core/plugins/auth.plugin.ts index 666e833..ed2a047 100644 --- a/backend/src/core/plugins/auth.plugin.ts +++ b/backend/src/core/plugins/auth.plugin.ts @@ -5,18 +5,39 @@ import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'; import fp from 'fastify-plugin'; import buildGetJwks from 'get-jwks'; +import fastifyJwt from '@fastify/jwt'; import { appConfig } from '../config/config-loader'; import { logger } from '../logging/logger'; import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository'; import { pool } from '../config/database'; +// Define the Auth0 JWT payload type +interface Auth0JwtPayload { + sub: string; + email?: string; + name?: string; + nickname?: string; + 'https://motovaultpro.com/roles'?: string[]; + iss?: string; + aud?: string | string[]; + iat?: number; + exp?: number; +} + +// Extend @fastify/jwt module with our payload type +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: Auth0JwtPayload; + user: Auth0JwtPayload; + } +} + declare module 'fastify' { interface FastifyInstance { authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; } interface FastifyRequest { jwtVerify(): Promise; - user?: any; userContext?: { userId: string; email?: string; @@ -41,7 +62,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { }); // Register @fastify/jwt with Auth0 JWKS validation - await fastify.register(require('@fastify/jwt'), { + await fastify.register(fastifyJwt, { decode: { complete: true }, secret: async (_request: FastifyRequest, token: any) => { try { diff --git a/backend/src/core/scheduler/index.ts b/backend/src/core/scheduler/index.ts new file mode 100644 index 0000000..6757f96 --- /dev/null +++ b/backend/src/core/scheduler/index.ts @@ -0,0 +1,38 @@ +/** + * @ai-summary Centralized cron job scheduler + * @ai-context Uses node-cron for scheduling background tasks + */ + +import cron from 'node-cron'; +import { logger } from '../logging/logger'; +import { processScheduledNotifications } from '../../features/notifications/jobs/notification-processor.job'; + +let schedulerInitialized = false; + +export function initializeScheduler(): void { + if (schedulerInitialized) { + logger.warn('Scheduler already initialized, skipping'); + return; + } + + logger.info('Initializing cron scheduler'); + + // Daily notification processing at 8 AM + cron.schedule('0 8 * * *', async () => { + logger.info('Running scheduled notification job'); + try { + await processScheduledNotifications(); + } catch (error) { + logger.error('Scheduled notification job failed', { + error: error instanceof Error ? error.message : String(error) + }); + } + }); + + schedulerInitialized = true; + logger.info('Cron scheduler initialized - notification job scheduled for 8 AM daily'); +} + +export function isSchedulerInitialized(): boolean { + return schedulerInitialized; +} diff --git a/backend/src/features/admin/tests/integration/admin.integration.test.ts b/backend/src/features/admin/tests/integration/admin.integration.test.ts index 26abf15..377619d 100644 --- a/backend/src/features/admin/tests/integration/admin.integration.test.ts +++ b/backend/src/features/admin/tests/integration/admin.integration.test.ts @@ -8,6 +8,7 @@ import { app } from '../../../../app'; import pool from '../../../../core/config/database'; import { readFileSync } from 'fs'; import { join } from 'path'; +import fastifyPlugin from 'fastify-plugin'; import { setAdminGuardPool } from '../../../../core/plugins/admin-guard.plugin'; const DEFAULT_ADMIN_SUB = 'test-admin-123'; @@ -20,7 +21,6 @@ let currentUser = { // Mock auth plugin to inject test admin user jest.mock('../../../../core/plugins/auth.plugin', () => { - const fastifyPlugin = require('fastify-plugin'); return { default: fastifyPlugin(async function(fastify) { fastify.decorate('authenticate', async function(request, _reply) { diff --git a/backend/src/features/admin/tests/integration/stations.integration.test.ts b/backend/src/features/admin/tests/integration/stations.integration.test.ts index f984feb..6c427df 100644 --- a/backend/src/features/admin/tests/integration/stations.integration.test.ts +++ b/backend/src/features/admin/tests/integration/stations.integration.test.ts @@ -9,11 +9,11 @@ import pool from '../../../../core/config/database'; import { redis } from '../../../../core/config/redis'; import { readFileSync } from 'fs'; import { join } from 'path'; +import fastifyPlugin from 'fastify-plugin'; import { setAdminGuardPool } from '../../../../core/plugins/admin-guard.plugin'; // Mock auth plugin to inject test admin user jest.mock('../../../../core/plugins/auth.plugin', () => { - const fastifyPlugin = require('fastify-plugin'); return { default: fastifyPlugin(async function(fastify) { fastify.decorate('authenticate', async function(request, _reply) { @@ -82,7 +82,6 @@ describe('Admin Station Oversight Integration Tests', () => { it('should reject non-admin user trying to list stations', async () => { jest.isolateModules(() => { jest.mock('../../../../core/plugins/auth.plugin', () => { - const fastifyPlugin = require('fastify-plugin'); return { default: fastifyPlugin(async function(fastify) { fastify.decorate('authenticate', async function(request, _reply) { diff --git a/backend/src/features/maintenance/data/maintenance.repository.ts b/backend/src/features/maintenance/data/maintenance.repository.ts index 97e6adb..26d4f7a 100644 --- a/backend/src/features/maintenance/data/maintenance.repository.ts +++ b/backend/src/features/maintenance/data/maintenance.repository.ts @@ -41,6 +41,11 @@ export class MaintenanceRepository { nextDueMileage: row.next_due_mileage, isActive: row.is_active, emailNotifications: row.email_notifications, + scheduleType: row.schedule_type, + fixedDueDate: row.fixed_due_date, + reminderDays1: row.reminder_days_1, + reminderDays2: row.reminder_days_2, + reminderDays3: row.reminder_days_3, createdAt: row.created_at, updatedAt: row.updated_at }; @@ -192,12 +197,18 @@ export class MaintenanceRepository { nextDueMileage?: number | null; isActive: boolean; emailNotifications?: boolean; + scheduleType?: string; + fixedDueDate?: string | null; + reminderDays1?: number | null; + reminderDays2?: number | null; + reminderDays3?: number | null; }): Promise { const res = await this.db.query( `INSERT INTO maintenance_schedules ( id, user_id, vehicle_id, category, subtypes, interval_months, interval_miles, - last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active, email_notifications - ) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13) + last_service_date, last_service_mileage, next_due_date, next_due_mileage, is_active, email_notifications, + schedule_type, fixed_due_date, reminder_days_1, reminder_days_2, reminder_days_3 + ) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) RETURNING *`, [ schedule.id, @@ -213,6 +224,11 @@ export class MaintenanceRepository { schedule.nextDueMileage ?? null, schedule.isActive, schedule.emailNotifications ?? false, + schedule.scheduleType ?? 'interval', + schedule.fixedDueDate ?? null, + schedule.reminderDays1 ?? null, + schedule.reminderDays2 ?? null, + schedule.reminderDays3 ?? null, ] ); return this.mapMaintenanceSchedule(res.rows[0]); @@ -245,7 +261,7 @@ export class MaintenanceRepository { async updateSchedule( id: string, userId: string, - patch: Partial> + patch: Partial> ): Promise { const fields: string[] = []; const params: any[] = []; @@ -291,6 +307,26 @@ export class MaintenanceRepository { fields.push(`email_notifications = $${i++}`); params.push(patch.emailNotifications); } + if (patch.scheduleType !== undefined) { + fields.push(`schedule_type = $${i++}`); + params.push(patch.scheduleType); + } + if (patch.fixedDueDate !== undefined) { + fields.push(`fixed_due_date = $${i++}`); + params.push(patch.fixedDueDate); + } + if (patch.reminderDays1 !== undefined) { + fields.push(`reminder_days_1 = $${i++}`); + params.push(patch.reminderDays1); + } + if (patch.reminderDays2 !== undefined) { + fields.push(`reminder_days_2 = $${i++}`); + params.push(patch.reminderDays2); + } + if (patch.reminderDays3 !== undefined) { + fields.push(`reminder_days_3 = $${i++}`); + params.push(patch.reminderDays3); + } if (!fields.length) return this.findScheduleById(id, userId); @@ -306,4 +342,46 @@ export class MaintenanceRepository { [id, userId] ); } + + async findMatchingSchedules( + userId: string, + vehicleId: string, + category: MaintenanceCategory, + subtypes: string[] + ): Promise { + const result = await this.db.query( + `SELECT * FROM maintenance_schedules + WHERE user_id = $1 + AND vehicle_id = $2 + AND category = $3 + AND is_active = true + AND schedule_type = 'time_since_last' + AND subtypes && $4::text[] + ORDER BY created_at DESC`, + [userId, vehicleId, category, subtypes] + ); + return result.rows.map(row => this.mapMaintenanceSchedule(row)); + } + + async updateScheduleLastService( + id: string, + userId: string, + lastServiceDate: string, + lastServiceMileage?: number | null, + nextDueDate?: string | null, + nextDueMileage?: number | null + ): Promise { + const result = await this.db.query( + `UPDATE maintenance_schedules SET + last_service_date = $1, + last_service_mileage = $2, + next_due_date = $3, + next_due_mileage = $4, + updated_at = CURRENT_TIMESTAMP + WHERE id = $5 AND user_id = $6 + RETURNING *`, + [lastServiceDate, lastServiceMileage, nextDueDate, nextDueMileage, id, userId] + ); + return result.rows[0] ? this.mapMaintenanceSchedule(result.rows[0]) : null; + } } diff --git a/backend/src/features/maintenance/domain/maintenance.service.ts b/backend/src/features/maintenance/domain/maintenance.service.ts index dffac50..7990ac4 100644 --- a/backend/src/features/maintenance/domain/maintenance.service.ts +++ b/backend/src/features/maintenance/domain/maintenance.service.ts @@ -8,7 +8,8 @@ import type { MaintenanceSchedule, MaintenanceRecordResponse, MaintenanceScheduleResponse, - MaintenanceCategory + MaintenanceCategory, + ScheduleType } from './maintenance.types'; import { validateSubtypes } from './maintenance.types'; import { MaintenanceRepository } from '../data/maintenance.repository'; @@ -27,7 +28,7 @@ export class MaintenanceService { } const id = randomUUID(); - return this.repo.insertRecord({ + const record = await this.repo.insertRecord({ id, userId, vehicleId: body.vehicleId, @@ -39,6 +40,11 @@ export class MaintenanceService { shopName: body.shopName, notes: body.notes, }); + + // Auto-link: Find and update matching 'time_since_last' schedules + await this.autoLinkRecordToSchedules(userId, record); + + return record; } async getRecord(userId: string, id: string): Promise { @@ -191,12 +197,50 @@ export class MaintenanceService { } } + private async autoLinkRecordToSchedules(userId: string, record: MaintenanceRecord): Promise { + // Find active 'time_since_last' schedules with matching category and overlapping subtypes + const matchingSchedules = await this.repo.findMatchingSchedules( + userId, + record.vehicleId, + record.category, + record.subtypes + ); + + for (const schedule of matchingSchedules) { + // Calculate new next due dates based on intervals + const nextDue = this.calculateNextDue({ + lastServiceDate: record.date, + lastServiceMileage: record.odometerReading, + intervalMonths: schedule.intervalMonths, + intervalMiles: schedule.intervalMiles, + }); + + // Update the schedule with new last service info + await this.repo.updateScheduleLastService( + schedule.id, + userId, + record.date, + record.odometerReading, + nextDue.nextDueDate, + nextDue.nextDueMileage + ); + } + } + private calculateNextDue(schedule: { lastServiceDate?: string | null; lastServiceMileage?: number | null; intervalMonths?: number | null; intervalMiles?: number | null; + scheduleType?: ScheduleType; + fixedDueDate?: string | null; }): { nextDueDate: string | null; nextDueMileage: number | null } { + // Handle fixed_date type - just return the fixed date + if (schedule.scheduleType === 'fixed_date' && schedule.fixedDueDate) { + return { nextDueDate: schedule.fixedDueDate, nextDueMileage: null }; + } + + // For interval and time_since_last types, calculate based on last service let nextDueDate: string | null = null; let nextDueMileage: number | null = null; diff --git a/backend/src/features/maintenance/domain/maintenance.types.ts b/backend/src/features/maintenance/domain/maintenance.types.ts index c6c494e..caeeeb5 100644 --- a/backend/src/features/maintenance/domain/maintenance.types.ts +++ b/backend/src/features/maintenance/domain/maintenance.types.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; // Category types export type MaintenanceCategory = 'routine_maintenance' | 'repair' | 'performance_upgrade'; +export type ScheduleType = 'interval' | 'fixed_date' | 'time_since_last'; // Subtype definitions (constants for validation) export const ROUTINE_MAINTENANCE_SUBTYPES = [ @@ -85,12 +86,23 @@ export interface MaintenanceSchedule { nextDueMileage?: number; isActive: boolean; emailNotifications: boolean; + scheduleType: ScheduleType; + fixedDueDate?: string | null; + reminderDays1?: number | null; + reminderDays2?: number | null; + reminderDays3?: number | null; createdAt: string; updatedAt: string; } // Zod schemas for validation (camelCase for API) export const MaintenanceCategorySchema = z.enum(['routine_maintenance', 'repair', 'performance_upgrade']); +export const ScheduleTypeSchema = z.enum(['interval', 'fixed_date', 'time_since_last']); + +const reminderDaysValidator = z.number().int().positive().refine( + (val) => [1, 7, 14, 30, 60].includes(val), + { message: 'Reminder days must be one of: 1, 7, 14, 30, 60' } +); export const CreateMaintenanceRecordSchema = z.object({ vehicleId: z.string().uuid(), @@ -122,6 +134,11 @@ export const CreateScheduleSchema = z.object({ intervalMonths: z.number().int().positive().optional(), intervalMiles: z.number().int().positive().optional(), emailNotifications: z.boolean().optional(), + scheduleType: ScheduleTypeSchema.optional().default('interval'), + fixedDueDate: z.string().optional(), + reminderDays1: reminderDaysValidator.optional(), + reminderDays2: reminderDaysValidator.optional(), + reminderDays3: reminderDaysValidator.optional(), }); export type CreateScheduleRequest = z.infer; @@ -132,6 +149,11 @@ export const UpdateScheduleSchema = z.object({ intervalMiles: z.number().int().positive().nullable().optional(), isActive: z.boolean().optional(), emailNotifications: z.boolean().optional(), + scheduleType: ScheduleTypeSchema.optional(), + fixedDueDate: z.string().nullable().optional(), + reminderDays1: reminderDaysValidator.nullable().optional(), + reminderDays2: reminderDaysValidator.nullable().optional(), + reminderDays3: reminderDaysValidator.nullable().optional(), }); export type UpdateScheduleRequest = z.infer; diff --git a/backend/src/features/maintenance/migrations/003_enhance_maintenance_schedules.sql b/backend/src/features/maintenance/migrations/003_enhance_maintenance_schedules.sql new file mode 100644 index 0000000..f4a248f --- /dev/null +++ b/backend/src/features/maintenance/migrations/003_enhance_maintenance_schedules.sql @@ -0,0 +1,24 @@ +-- Add schedule_type to support different scheduling methods +ALTER TABLE maintenance_schedules + ADD COLUMN schedule_type VARCHAR(20) NOT NULL DEFAULT 'interval' + CHECK (schedule_type IN ('interval', 'fixed_date', 'time_since_last')); + +-- Add fixed_due_date for fixed date schedules +ALTER TABLE maintenance_schedules + ADD COLUMN fixed_due_date DATE; + +-- Add reminder columns for progressive notifications +ALTER TABLE maintenance_schedules + ADD COLUMN reminder_days_1 INTEGER + CHECK (reminder_days_1 IN (1, 7, 14, 30, 60)); + +ALTER TABLE maintenance_schedules + ADD COLUMN reminder_days_2 INTEGER + CHECK (reminder_days_2 IN (1, 7, 14, 30, 60)); + +ALTER TABLE maintenance_schedules + ADD COLUMN reminder_days_3 INTEGER + CHECK (reminder_days_3 IN (1, 7, 14, 30, 60)); + +-- Index for filtering by schedule type +CREATE INDEX idx_maintenance_schedules_schedule_type ON maintenance_schedules(schedule_type); diff --git a/backend/src/features/notifications/api/notifications.controller.ts b/backend/src/features/notifications/api/notifications.controller.ts index 8e47353..e9ef66b 100644 --- a/backend/src/features/notifications/api/notifications.controller.ts +++ b/backend/src/features/notifications/api/notifications.controller.ts @@ -65,6 +65,78 @@ export class NotificationsController { } } + // ======================== + // In-App Notifications + // ======================== + + async getInAppNotifications(request: FastifyRequest, reply: FastifyReply) { + const userId = request.user!.sub!; + const query = request.query as { limit?: string; includeRead?: string }; + const limit = query.limit ? parseInt(query.limit, 10) : 20; + const includeRead = query.includeRead === 'true'; + + try { + const notifications = await this.service.getUserNotifications(userId, limit, includeRead); + return reply.send(notifications); + } catch (error) { + request.log.error({ error }, 'Failed to get in-app notifications'); + return reply.code(500).send({ error: 'Failed to get notifications' }); + } + } + + async getUnreadCount(request: FastifyRequest, reply: FastifyReply) { + const userId = request.user!.sub!; + + try { + const count = await this.service.getUnreadCount(userId); + return reply.send(count); + } catch (error) { + request.log.error({ error }, 'Failed to get unread count'); + return reply.code(500).send({ error: 'Failed to get unread count' }); + } + } + + async markAsRead(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) { + const userId = request.user!.sub!; + const notificationId = request.params.id; + + try { + const notification = await this.service.markAsRead(userId, notificationId); + if (!notification) { + return reply.code(404).send({ error: 'Notification not found' }); + } + return reply.send(notification); + } catch (error) { + request.log.error({ error }, 'Failed to mark notification as read'); + return reply.code(500).send({ error: 'Failed to mark as read' }); + } + } + + async markAllAsRead(request: FastifyRequest, reply: FastifyReply) { + const userId = request.user!.sub!; + + try { + const count = await this.service.markAllAsRead(userId); + return reply.send({ markedAsRead: count }); + } catch (error) { + request.log.error({ error }, 'Failed to mark all notifications as read'); + return reply.code(500).send({ error: 'Failed to mark all as read' }); + } + } + + async deleteNotification(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) { + const userId = request.user!.sub!; + const notificationId = request.params.id; + + try { + await this.service.deleteNotification(notificationId, userId); + return reply.code(204).send(); + } catch (error) { + request.log.error({ error }, 'Failed to delete notification'); + return reply.code(500).send({ error: 'Failed to delete notification' }); + } + } + // ======================== // Admin Endpoints // ======================== diff --git a/backend/src/features/notifications/api/notifications.routes.ts b/backend/src/features/notifications/api/notifications.routes.ts index e211009..2a1cdeb 100644 --- a/backend/src/features/notifications/api/notifications.routes.ts +++ b/backend/src/features/notifications/api/notifications.routes.ts @@ -36,6 +36,40 @@ export const notificationsRoutes: FastifyPluginAsync = async (fastify) => { handler: controller.getExpiringDocuments.bind(controller) }); + // ======================== + // In-App Notifications + // ======================== + + // GET /api/notifications/in-app - Get user's in-app notifications + fastify.get('/notifications/in-app', { + preHandler: [fastify.authenticate], + handler: controller.getInAppNotifications.bind(controller) + }); + + // GET /api/notifications/in-app/count - Get unread notification count + fastify.get('/notifications/in-app/count', { + preHandler: [fastify.authenticate], + handler: controller.getUnreadCount.bind(controller) + }); + + // PUT /api/notifications/in-app/:id/read - Mark single notification as read + fastify.put<{ Params: { id: string } }>('/notifications/in-app/:id/read', { + preHandler: [fastify.authenticate], + handler: controller.markAsRead.bind(controller) + }); + + // PUT /api/notifications/in-app/read-all - Mark all notifications as read + fastify.put('/notifications/in-app/read-all', { + preHandler: [fastify.authenticate], + handler: controller.markAllAsRead.bind(controller) + }); + + // DELETE /api/notifications/in-app/:id - Delete a notification + fastify.delete<{ Params: { id: string } }>('/notifications/in-app/:id', { + preHandler: [fastify.authenticate], + handler: controller.deleteNotification.bind(controller) + }); + // ======================== // Admin Endpoints // ======================== diff --git a/backend/src/features/notifications/data/notifications.repository.ts b/backend/src/features/notifications/data/notifications.repository.ts index 0a9fc34..fd27ace 100644 --- a/backend/src/features/notifications/data/notifications.repository.ts +++ b/backend/src/features/notifications/data/notifications.repository.ts @@ -6,7 +6,10 @@ import type { NotificationSummary, DueMaintenanceItem, ExpiringDocument, - TemplateKey + TemplateKey, + UserNotification, + UnreadNotificationCount, + SentNotificationRecord } from '../domain/notifications.types'; export class NotificationsRepository { @@ -42,7 +45,10 @@ export class NotificationsRepository { nextDueMileage: row.next_due_mileage, isDueSoon: row.is_due_soon, isOverdue: row.is_overdue, - emailNotifications: row.email_notifications + emailNotifications: row.email_notifications, + reminderDays1: row.reminder_1_days, + reminderDays2: row.reminder_2_days, + reminderDays3: row.reminder_3_days }; } @@ -60,6 +66,33 @@ export class NotificationsRepository { }; } + private mapUserNotification(row: any): UserNotification { + return { + id: row.id, + userId: row.user_id, + notificationType: row.notification_type, + title: row.title, + message: row.message, + referenceType: row.reference_type, + referenceId: row.reference_id, + vehicleId: row.vehicle_id, + isRead: row.is_read, + createdAt: row.created_at, + readAt: row.read_at + }; + } + + private mapSentNotificationRecord(row: any): SentNotificationRecord { + return { + id: row.id, + scheduleId: row.schedule_id, + notificationDate: row.notification_date, + reminderLevel: row.reminder_level, + deliveryMethod: row.delivery_method, + createdAt: row.created_at + }; + } + // ======================== // Email Templates // ======================== @@ -208,6 +241,9 @@ export class NotificationsRepository { ms.next_due_date, ms.next_due_mileage, ms.email_notifications, + ms.reminder_1_days, + ms.reminder_2_days, + ms.reminder_3_days, CASE WHEN ms.next_due_date <= CURRENT_DATE THEN true WHEN ms.next_due_mileage IS NOT NULL AND v.odometer >= ms.next_due_mileage THEN true @@ -269,4 +305,175 @@ export class NotificationsRepository { ); return res.rows.map(row => this.mapExpiringDocument(row)); } + + // ======================== + // User Notifications + // ======================== + + async insertUserNotification(notification: { + userId: string; + notificationType: string; + title: string; + message: string; + referenceType?: string; + referenceId?: string; + vehicleId?: string; + }): Promise { + const res = await this.db.query( + `INSERT INTO user_notifications ( + id, user_id, notification_type, title, message, + reference_type, reference_id, vehicle_id + ) VALUES (uuid_generate_v4(), $1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + notification.userId, + notification.notificationType, + notification.title, + notification.message, + notification.referenceType ?? null, + notification.referenceId ?? null, + notification.vehicleId ?? null + ] + ); + return this.mapUserNotification(res.rows[0]); + } + + async findUserNotifications( + userId: string, + limit: number = 20, + includeRead: boolean = false + ): Promise { + const sql = ` + SELECT * + FROM user_notifications + WHERE user_id = $1 + ${includeRead ? '' : 'AND is_read = false'} + ORDER BY created_at DESC + LIMIT $2 + `; + + const res = await this.db.query(sql, [userId, limit]); + return res.rows.map(row => this.mapUserNotification(row)); + } + + async getUnreadCount(userId: string): Promise { + const res = await this.db.query( + `SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE notification_type = 'maintenance') as maintenance, + COUNT(*) FILTER (WHERE notification_type = 'document') as documents + FROM user_notifications + WHERE user_id = $1 + AND is_read = false`, + [userId] + ); + + const row = res.rows[0]; + return { + total: parseInt(row.total || '0', 10), + maintenance: parseInt(row.maintenance || '0', 10), + documents: parseInt(row.documents || '0', 10) + }; + } + + async markAsRead(id: string, userId: string): Promise { + const res = await this.db.query( + `UPDATE user_notifications + SET is_read = true, read_at = CURRENT_TIMESTAMP + WHERE id = $1 AND user_id = $2 + RETURNING *`, + [id, userId] + ); + return res.rows[0] ? this.mapUserNotification(res.rows[0]) : null; + } + + async markAllAsRead(userId: string): Promise { + const res = await this.db.query( + `UPDATE user_notifications + SET is_read = true, read_at = CURRENT_TIMESTAMP + WHERE user_id = $1 AND is_read = false + RETURNING id`, + [userId] + ); + return res.rowCount || 0; + } + + async deleteUserNotification(id: string, userId: string): Promise { + await this.db.query( + `DELETE FROM user_notifications + WHERE id = $1 AND user_id = $2`, + [id, userId] + ); + } + + // ======================== + // Sent Notification Tracker + // ======================== + + async hasNotificationBeenSent( + scheduleId: string, + notificationDate: string, + reminderLevel: 1 | 2 | 3 + ): Promise { + const res = await this.db.query( + `SELECT EXISTS ( + SELECT 1 + FROM sent_notification_tracker + WHERE schedule_id = $1 + AND notification_date = $2 + AND reminder_level = $3 + ) as exists`, + [scheduleId, notificationDate, reminderLevel] + ); + return res.rows[0]?.exists || false; + } + + async recordSentNotification(record: { + scheduleId: string; + notificationDate: string; + reminderLevel: 1 | 2 | 3; + deliveryMethod: 'email' | 'in_app' | 'both'; + }): Promise { + const res = await this.db.query( + `INSERT INTO sent_notification_tracker ( + schedule_id, notification_date, reminder_level, delivery_method + ) VALUES ($1, $2, $3, $4) + RETURNING *`, + [ + record.scheduleId, + record.notificationDate, + record.reminderLevel, + record.deliveryMethod + ] + ); + return this.mapSentNotificationRecord(res.rows[0]); + } + + async getUsersWithActiveSchedules(): Promise> { + const res = await this.db.query( + `SELECT DISTINCT + ms.user_id, + up.email as user_email, + up.name as user_name + FROM maintenance_schedules ms + JOIN user_profiles up ON ms.user_id = up.user_id + WHERE ms.is_active = true + AND ( + ms.reminder_1_days IS NOT NULL + OR ms.reminder_2_days IS NOT NULL + OR ms.reminder_3_days IS NOT NULL + ) + ORDER BY ms.user_id` + ); + + return res.rows.map(row => ({ + userId: row.user_id, + userEmail: row.user_email, + userName: row.user_name + })); + } } diff --git a/backend/src/features/notifications/domain/notifications.service.ts b/backend/src/features/notifications/domain/notifications.service.ts index 6e68b55..5ccfbe9 100644 --- a/backend/src/features/notifications/domain/notifications.service.ts +++ b/backend/src/features/notifications/domain/notifications.service.ts @@ -11,7 +11,9 @@ import type { DueMaintenanceItem, ExpiringDocument, EmailTemplate, - TemplateKey + TemplateKey, + UserNotification, + UnreadNotificationCount } from './notifications.types'; export class NotificationsService { @@ -45,6 +47,30 @@ export class NotificationsService { return this.repository.getExpiringDocuments(userId); } + // ======================== + // In-App Notifications + // ======================== + + async getUserNotifications(userId: string, limit?: number, includeRead?: boolean): Promise { + return this.repository.findUserNotifications(userId, limit, includeRead); + } + + async getUnreadCount(userId: string): Promise { + return this.repository.getUnreadCount(userId); + } + + async markAsRead(userId: string, notificationId: string): Promise { + return this.repository.markAsRead(notificationId, userId); + } + + async markAllAsRead(userId: string): Promise { + return this.repository.markAllAsRead(userId); + } + + async deleteNotification(notificationId: string, userId: string): Promise { + return this.repository.deleteUserNotification(notificationId, userId); + } + // ======================== // Email Templates // ======================== @@ -274,17 +300,133 @@ export class NotificationsService { } } + private async createInAppNotification( + userId: string, + item: DueMaintenanceItem, + _reminderLevel: 1 | 2 | 3 + ): Promise { + const title = item.isOverdue + ? `OVERDUE: ${this.getCategoryDisplayName(item.category)} for ${item.vehicleName}` + : `Due Soon: ${this.getCategoryDisplayName(item.category)} for ${item.vehicleName}`; + + const message = item.isOverdue + ? `Your ${item.subtypes.join(', ')} maintenance was due ${item.nextDueDate || `at ${item.nextDueMileage?.toLocaleString()} miles`}` + : `Your ${item.subtypes.join(', ')} maintenance is due ${item.nextDueDate || `at ${item.nextDueMileage?.toLocaleString()} miles`}`; + + await this.repository.insertUserNotification({ + userId, + notificationType: 'maintenance', + title, + message, + referenceType: 'maintenance_schedule', + referenceId: item.id, + vehicleId: item.vehicleId, + }); + } + + private getCategoryDisplayName(category: string): string { + const names: Record = { + routine_maintenance: 'Routine Maintenance', + repair: 'Repair', + performance_upgrade: 'Performance Upgrade', + }; + return names[category] || category; + } + + /** + * Check if a reminder should be sent based on the due date and reminder days + */ + private shouldSendReminder(item: DueMaintenanceItem, reminderDays: number, today: Date): boolean { + if (!item.nextDueDate) return false; + + const dueDate = new Date(item.nextDueDate); + const diffTime = dueDate.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + // Send notification if today is exactly reminderDays before due date + return diffDays === reminderDays; + } + /** * Process all pending notifications (called by scheduled job) - * This would typically be called by a cron job or scheduler + * Checks all active schedules and sends notifications based on reminder_days settings */ - async processNotifications(): Promise { - // This is a placeholder for batch notification processing - // In a production system, this would: - // 1. Query for users with email_notifications enabled - // 2. Check which items need notifications - // 3. Send batch emails - // 4. Track sent notifications to avoid duplicates - throw new Error('Batch notification processing not yet implemented'); + async processNotifications(): Promise<{ + processed: number; + emailsSent: number; + inAppCreated: number; + errors: string[]; + }> { + const results = { processed: 0, emailsSent: 0, inAppCreated: 0, errors: [] as string[] }; + const today = new Date(); + const todayStr = today.toISOString().split('T')[0]; + + // Get all users with active schedules that have reminders configured + const usersWithSchedules = await this.repository.getUsersWithActiveSchedules(); + + for (const userData of usersWithSchedules) { + const { userId, userEmail, userName } = userData; + + try { + // Get due maintenance items for this user + const dueItems = await this.repository.getDueMaintenanceItems(userId); + + for (const item of dueItems) { + // Check each reminder level (1, 2, 3) for this schedule + const reminderLevels: Array<{ level: 1 | 2 | 3; days: number | null | undefined }> = [ + { level: 1, days: item.reminderDays1 }, + { level: 2, days: item.reminderDays2 }, + { level: 3, days: item.reminderDays3 }, + ]; + + for (const { level, days } of reminderLevels) { + if (!days) continue; // Skip if reminder not configured + + // Calculate if today matches the reminder day + const shouldNotify = this.shouldSendReminder(item, days, today); + if (!shouldNotify && !item.isOverdue) continue; + + // Check if we've already sent this notification today + const alreadySent = await this.repository.hasNotificationBeenSent( + item.id, + todayStr, + level + ); + + if (alreadySent) continue; + + results.processed++; + + try { + // Create in-app notification + await this.createInAppNotification(userId, item, level); + results.inAppCreated++; + + // Send email if enabled + if (item.emailNotifications) { + await this.sendMaintenanceNotification(userId, userEmail, userName, item); + results.emailsSent++; + } + + // Record that notification was sent + await this.repository.recordSentNotification({ + scheduleId: item.id, + notificationDate: todayStr, + reminderLevel: level, + deliveryMethod: item.emailNotifications ? 'both' : 'in_app', + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + results.errors.push(`Failed for schedule ${item.id} (level ${level}): ${errorMsg}`); + } + } + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + results.errors.push(`Failed processing user ${userId}: ${errorMsg}`); + } + } + + return results; } } diff --git a/backend/src/features/notifications/domain/notifications.types.ts b/backend/src/features/notifications/domain/notifications.types.ts index b7bdc46..47c4ac4 100644 --- a/backend/src/features/notifications/domain/notifications.types.ts +++ b/backend/src/features/notifications/domain/notifications.types.ts @@ -61,6 +61,9 @@ export interface DueMaintenanceItem { isDueSoon: boolean; isOverdue: boolean; emailNotifications: boolean; + reminderDays1?: number | null; + reminderDays2?: number | null; + reminderDays3?: number | null; } // Expiring document (camelCase for frontend) @@ -97,3 +100,33 @@ export const PreviewTemplateSchema = z.object({ variables: z.record(z.string()), }); export type PreviewTemplateRequest = z.infer; + +// User notification types (camelCase for frontend) +export interface UserNotification { + id: string; + userId: string; + notificationType: string; + title: string; + message: string; + referenceType?: string | null; + referenceId?: string | null; + vehicleId?: string | null; + isRead: boolean; + createdAt: string; + readAt?: string | null; +} + +export interface UnreadNotificationCount { + total: number; + maintenance: number; + documents: number; +} + +export interface SentNotificationRecord { + id: string; + scheduleId: string; + notificationDate: string; + reminderLevel: 1 | 2 | 3; + deliveryMethod: 'email' | 'in_app' | 'both'; + createdAt: string; +} diff --git a/backend/src/features/notifications/jobs/notification-processor.job.ts b/backend/src/features/notifications/jobs/notification-processor.job.ts new file mode 100644 index 0000000..03f0626 --- /dev/null +++ b/backend/src/features/notifications/jobs/notification-processor.job.ts @@ -0,0 +1,38 @@ +/** + * @ai-summary Daily scheduled job to process maintenance notifications + * @ai-context Runs at 8 AM daily to check schedules and send notifications + */ + +import { logger } from '../../../core/logging/logger'; +import { NotificationsService } from '../domain/notifications.service'; + +export async function processScheduledNotifications(): Promise { + const startTime = Date.now(); + const service = new NotificationsService(); + + try { + logger.info('Starting scheduled notification processing'); + + const results = await service.processNotifications(); + + logger.info('Notification processing completed', { + durationMs: Date.now() - startTime, + processed: results.processed, + emailsSent: results.emailsSent, + inAppCreated: results.inAppCreated, + errorCount: results.errors.length + }); + + if (results.errors.length > 0) { + logger.warn('Some notifications failed', { + errors: results.errors.slice(0, 10) // Log first 10 errors + }); + } + } catch (error) { + logger.error('Notification processing failed', { + error: error instanceof Error ? error.message : String(error), + durationMs: Date.now() - startTime + }); + throw error; + } +} diff --git a/backend/src/features/notifications/migrations/003_create_user_notifications.sql b/backend/src/features/notifications/migrations/003_create_user_notifications.sql new file mode 100644 index 0000000..48584d2 --- /dev/null +++ b/backend/src/features/notifications/migrations/003_create_user_notifications.sql @@ -0,0 +1,19 @@ +-- user_notifications: In-app notification center for users +CREATE TABLE user_notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id VARCHAR(255) NOT NULL, + notification_type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + reference_type VARCHAR(50), + reference_id UUID, + vehicle_id UUID, + is_read BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + read_at TIMESTAMP WITH TIME ZONE +); + +-- Indexes for performance +CREATE INDEX idx_user_notifications_user_id ON user_notifications(user_id); +CREATE INDEX idx_user_notifications_created_at ON user_notifications(created_at DESC); +CREATE INDEX idx_user_notifications_unread ON user_notifications(user_id, created_at DESC) WHERE is_read = false; diff --git a/backend/src/features/notifications/migrations/004_create_sent_notification_tracker.sql b/backend/src/features/notifications/migrations/004_create_sent_notification_tracker.sql new file mode 100644 index 0000000..bbeabb7 --- /dev/null +++ b/backend/src/features/notifications/migrations/004_create_sent_notification_tracker.sql @@ -0,0 +1,21 @@ +-- sent_notification_tracker: Prevent duplicate notifications for schedules +CREATE TABLE sent_notification_tracker ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + schedule_id UUID NOT NULL, + notification_date DATE NOT NULL, + reminder_level INTEGER NOT NULL CHECK (reminder_level IN (1, 2, 3)), + delivery_method VARCHAR(20) NOT NULL CHECK (delivery_method IN ('email', 'in_app', 'both')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_sent_notification_schedule + FOREIGN KEY (schedule_id) + REFERENCES maintenance_schedules(id) + ON DELETE CASCADE, + + CONSTRAINT unique_notification_per_schedule_date_level + UNIQUE (schedule_id, notification_date, reminder_level) +); + +-- Indexes for performance +CREATE INDEX idx_sent_notification_tracker_schedule_id ON sent_notification_tracker(schedule_id); +CREATE INDEX idx_sent_notification_tracker_notification_date ON sent_notification_tracker(notification_date); diff --git a/backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts b/backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts index 2b93d39..b0cf767 100644 --- a/backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts +++ b/backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts @@ -9,10 +9,10 @@ import pool from '../../../../core/config/database'; import { cacheService } from '../../../../core/config/redis'; import { readFileSync } from 'fs'; import { join } from 'path'; +import fastifyPlugin from 'fastify-plugin'; // Mock auth plugin to bypass JWT validation in tests jest.mock('../../../../core/plugins/auth.plugin', () => { - const fastifyPlugin = require('fastify-plugin'); return { default: fastifyPlugin(async function(fastify) { fastify.decorate('authenticate', async function(request, _reply) { diff --git a/backend/src/index.ts b/backend/src/index.ts index f75a3d2..097cf70 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5,24 +5,28 @@ import { buildApp } from './app'; import { appConfig } from './core/config/config-loader'; import { logger } from './core/logging/logger'; +import { initializeScheduler } from './core/scheduler'; const PORT = appConfig.config.server.port; async function start() { try { const app = await buildApp(); - + await app.listen({ port: PORT, host: '0.0.0.0' }); - + logger.info(`MotoVaultPro backend running`, { port: PORT, environment: appConfig.config.server.environment, nodeVersion: process.version, framework: 'Fastify' }); + + // Initialize cron scheduler for background tasks + initializeScheduler(); } catch (error) { logger.error('Failed to start server', { error }); process.exit(1); diff --git a/docs/PROMPTS.md b/docs/PROMPTS.md index 533d9b0..1fc2af8 100644 --- a/docs/PROMPTS.md +++ b/docs/PROMPTS.md @@ -1,11 +1,25 @@ *** CRITICAL ***: Never read this file. Stop and move on. +*** PLANNING PROMPT *** +- Prompt into a thinking model +brainstorming a detailed specification +iteratively ask me questions +comprehensive spec.md - containing requirements, architecture decisions, data models, and even a testing strategy. This spec forms the foundation for development. + +- Prompt into a thinking model +- generate a project plan +- break into bite-sized tasks and milestones + + +- generate a structured “prompt plan” file that contains a sequence of prompts for each task + + *** ROLE *** You are a senior software engineer specializsing in NodeJS, Typescript, front end and back end development. You will be delegating tasks to the platform-agent, feature-agent, first-frontend-agent and quality-agent when appropriate. *** ACTION *** -- You will be implementing the "User Management" feature of this web application. +- You will be enhancing the maintenance record feature. - Make no assumptions. - Ask clarifying questions. - Ultrathink @@ -13,13 +27,11 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en *** CONTEXT *** - This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s. - Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change. -- There currently is no user management system in this application. -- We need to do basic CRUD operations on user accounts -- We need to set the groundwork for a tiered paid system in the future. Start with four types of users. -- 1. Free 2. Pro 3. Enterprise 4. Administrator +- There is a basic maintenance record system implemented. +- We need to implement schedule maintenance records with notifications tied into the existing notification system. *** CHANGES TO IMPLEMENT *** -- Research this code base and look for any gaps in user account management. +- Research this code base and ask iterative questions to compile a complete plan. diff --git a/COMMUNITY-STATIONS-FEATURE.md b/docs/changes/COMMUNITY-STATIONS-FEATURE.md similarity index 100% rename from COMMUNITY-STATIONS-FEATURE.md rename to docs/changes/COMMUNITY-STATIONS-FEATURE.md diff --git a/COMMUNITY-STATIONS-FILES.md b/docs/changes/COMMUNITY-STATIONS-FILES.md similarity index 100% rename from COMMUNITY-STATIONS-FILES.md rename to docs/changes/COMMUNITY-STATIONS-FILES.md diff --git a/COMMUNITY-STATIONS-IMPLEMENTATION.md b/docs/changes/COMMUNITY-STATIONS-IMPLEMENTATION.md similarity index 100% rename from COMMUNITY-STATIONS-IMPLEMENTATION.md rename to docs/changes/COMMUNITY-STATIONS-IMPLEMENTATION.md diff --git a/COMMUNITY-STATIONS-INTEGRATION-GUIDE.md b/docs/changes/COMMUNITY-STATIONS-INTEGRATION-GUIDE.md similarity index 100% rename from COMMUNITY-STATIONS-INTEGRATION-GUIDE.md rename to docs/changes/COMMUNITY-STATIONS-INTEGRATION-GUIDE.md diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f5fbcc3..d7043ec 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -17,6 +17,7 @@ import MenuIcon from '@mui/icons-material/Menu'; import CloseIcon from '@mui/icons-material/Close'; import { useAppStore } from '../core/store'; import { Button } from '../shared-minimal/components/Button'; +import { NotificationBell } from '../features/notifications'; interface LayoutProps { children: React.ReactNode; @@ -67,7 +68,10 @@ export const Layout: React.FC = ({ children, mobileMode = false })
MotoVaultPro
-
v1.0
+
+ +
v1.0
+
{/* Content area */} @@ -231,9 +235,12 @@ export const Layout: React.FC = ({ children, mobileMode = false }) > - - Welcome back, {user?.name || user?.email} - + + + + Welcome back, {user?.name || user?.email} + + diff --git a/frontend/src/features/admin/__tests__/catalogShared.test.ts b/frontend/src/features/admin/__tests__/catalogShared.test.ts index 0520788..81117d1 100644 --- a/frontend/src/features/admin/__tests__/catalogShared.test.ts +++ b/frontend/src/features/admin/__tests__/catalogShared.test.ts @@ -48,7 +48,7 @@ const baseEngine: CatalogEngine = { name: '2.0T', displacement: '2.0L', cylinders: 4, - fuel_type: 'Gasoline', + fuelType: 'Gasoline', createdAt: '2024-01-05T00:00:00Z', updatedAt: '2024-01-05T00:00:00Z', }; @@ -129,6 +129,6 @@ describe('buildDefaultValues', () => { expect(defaults.trimId).toBe(baseTrim.id); expect(defaults.displacement).toBe('2.0L'); expect(defaults.cylinders).toBe(4); - expect(defaults.fuel_type).toBe('Gasoline'); + expect(defaults.fuelType).toBe('Gasoline'); }); }); diff --git a/frontend/src/features/documents/components/DocumentPreview.test.tsx b/frontend/src/features/documents/components/DocumentPreview.test.tsx index bedca1b..bf0a215 100644 --- a/frontend/src/features/documents/components/DocumentPreview.test.tsx +++ b/frontend/src/features/documents/components/DocumentPreview.test.tsx @@ -25,35 +25,35 @@ Object.defineProperty(global.URL, 'revokeObjectURL', { describe('DocumentPreview', () => { const mockPdfDocument: DocumentRecord = { id: 'doc-1', - user_id: 'user-1', - vehicle_id: 'vehicle-1', - document_type: 'insurance', + userId: 'user-1', + vehicleId: 'vehicle-1', + documentType: 'insurance', title: 'Insurance Document', - content_type: 'application/pdf', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', + contentType: 'application/pdf', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', }; const mockImageDocument: DocumentRecord = { id: 'doc-2', - user_id: 'user-1', - vehicle_id: 'vehicle-1', - document_type: 'registration', + userId: 'user-1', + vehicleId: 'vehicle-1', + documentType: 'registration', title: 'Registration Photo', - content_type: 'image/jpeg', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', + contentType: 'image/jpeg', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', }; const mockNonPreviewableDocument: DocumentRecord = { id: 'doc-3', - user_id: 'user-1', - vehicle_id: 'vehicle-1', - document_type: 'insurance', + userId: 'user-1', + vehicleId: 'vehicle-1', + documentType: 'insurance', title: 'Text Document', - content_type: 'text/plain', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', + contentType: 'text/plain', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', }; beforeEach(() => { diff --git a/frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx b/frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx index 9b35d24..0cfd5c8 100644 --- a/frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx +++ b/frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx @@ -27,21 +27,21 @@ describe('DocumentsMobileScreen', () => { const mockDocuments: DocumentRecord[] = [ { id: 'doc-1', - user_id: 'user-1', - vehicle_id: 'vehicle-1', - document_type: 'insurance', + userId: 'user-1', + vehicleId: 'vehicle-1', + documentType: 'insurance', title: 'Car Insurance', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', }, { id: 'doc-2', - user_id: 'user-1', - vehicle_id: 'vehicle-2', - document_type: 'registration', + userId: 'user-1', + vehicleId: 'vehicle-2', + documentType: 'registration', title: 'Vehicle Registration', - created_at: '2024-01-02T00:00:00Z', - updated_at: '2024-01-02T00:00:00Z', + createdAt: '2024-01-02T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', }, ]; diff --git a/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx b/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx index 6925c8d..e9133a0 100644 --- a/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx +++ b/frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx @@ -12,14 +12,7 @@ export const DocumentsMobileScreen: React.FC = () => { console.log('[DocumentsMobileScreen] Component initializing'); // Auth is managed at App level; keep hook to support session-expired UI. - // In test environments without provider, fall back gracefully. - let auth = { isAuthenticated: true, isLoading: false, loginWithRedirect: () => {} } as any; - try { - auth = useAuth0(); - } catch { - // Tests render without Auth0Provider; assume authenticated for unit tests. - } - + const auth = useAuth0(); const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = auth; // Data hooks (unconditional per React rules) diff --git a/frontend/src/features/fuel-logs/components/FuelLogsList.tsx b/frontend/src/features/fuel-logs/components/FuelLogsList.tsx index 1fbe3a9..df87a0a 100644 --- a/frontend/src/features/fuel-logs/components/FuelLogsList.tsx +++ b/frontend/src/features/fuel-logs/components/FuelLogsList.tsx @@ -150,7 +150,9 @@ export const FuelLogsList: React.FC = ({ logs, onEdit, onDele localEffLabel = `${(miles / gallons).toFixed(1)} MPG`; } } - } catch {} + } catch { + // Efficiency calculation failed, leave localEffLabel as null + } return ( void; + onSave: (id: string, data: UpdateScheduleRequest) => Promise; +} + +export const MaintenanceScheduleEditDialog: React.FC = ({ + open, + schedule, + onClose, + onSave, +}) => { + const [formData, setFormData] = useState({}); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const vehiclesQuery = useVehicles(); + const vehicles = vehiclesQuery.data; + const isSmallScreen = useMediaQuery('(max-width:600px)'); + + // Reset form when schedule changes + useEffect(() => { + if (schedule && schedule.id) { + try { + setFormData({ + category: schedule.category, + subtypes: schedule.subtypes, + scheduleType: schedule.scheduleType, + intervalMonths: schedule.intervalMonths || undefined, + intervalMiles: schedule.intervalMiles || undefined, + fixedDueDate: schedule.fixedDueDate || undefined, + isActive: schedule.isActive, + emailNotifications: schedule.emailNotifications || false, + reminderDays1: schedule.reminderDays1 || undefined, + reminderDays2: schedule.reminderDays2 || undefined, + reminderDays3: schedule.reminderDays3 || undefined, + }); + setError(null); + } catch (err) { + console.error('[MaintenanceScheduleEditDialog] Error setting form data:', err); + setError(err as Error); + } + } + }, [schedule]); + + const handleInputChange = (field: keyof UpdateScheduleRequest, value: any) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + }; + + const handleScheduleTypeChange = (newType: ScheduleType) => { + setFormData((prev) => { + const updated: UpdateScheduleRequest = { + ...prev, + scheduleType: newType, + }; + + // Clear fields that don't apply to new schedule type + if (newType === 'interval') { + updated.fixedDueDate = null; + } else if (newType === 'fixed_date') { + updated.intervalMonths = null; + updated.intervalMiles = null; + } else if (newType === 'time_since_last') { + updated.fixedDueDate = null; + } + + return updated; + }); + }; + + const handleSave = async () => { + if (!schedule || !schedule.id) { + console.error('[MaintenanceScheduleEditDialog] No valid schedule to save'); + return; + } + + try { + setIsSaving(true); + + // Filter out unchanged fields + const changedData: UpdateScheduleRequest = {}; + Object.entries(formData).forEach(([key, value]) => { + const typedKey = key as keyof UpdateScheduleRequest; + const scheduleValue = schedule[typedKey as keyof MaintenanceScheduleResponse]; + + // Special handling for arrays + if (Array.isArray(value) && Array.isArray(scheduleValue)) { + if (JSON.stringify(value) !== JSON.stringify(scheduleValue)) { + (changedData as any)[key] = value; + } + } else if (value !== scheduleValue) { + (changedData as any)[key] = value; + } + }); + + // Only send update if there are actual changes + if (Object.keys(changedData).length > 0) { + await onSave(schedule.id, changedData); + } + + onClose(); + } catch (err) { + console.error('[MaintenanceScheduleEditDialog] Failed to save schedule:', err); + setError(err as Error); + } finally { + setIsSaving(false); + } + }; + + const handleCancel = () => { + onClose(); + }; + + // Early bailout if dialog not open or no schedule to edit + if (!open || !schedule) return null; + + // Error state + if (error) { + return ( + + Error Loading Maintenance Schedule + + + Failed to load maintenance schedule data. Please try again. + + + {error.message} + + + + + + + ); + } + + const currentScheduleType = formData.scheduleType || 'interval'; + + return ( + + + Edit Maintenance Schedule + + + + {/* Vehicle (Read-only display) */} + + { + const vehicle = vehicles?.find((v: Vehicle) => v.id === schedule.vehicleId); + if (!vehicle) return 'Unknown Vehicle'; + if (vehicle.nickname?.trim()) return vehicle.nickname.trim(); + const parts = [vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean); + return parts.length > 0 ? parts.join(' ') : 'Vehicle'; + })()} + helperText="Vehicle cannot be changed when editing" + /> + + + {/* Active Status */} + + handleInputChange('isActive', e.target.checked)} + /> + } + label="Schedule is active" + /> + + Inactive schedules will not trigger reminders + + + + {/* Category */} + + + Category + + + + + {/* Subtypes */} + {formData.category && ( + + + Service Types * + + handleInputChange('subtypes', subtypes)} + /> + + )} + + {/* Schedule Type */} + + + Schedule Type + handleScheduleTypeChange(e.target.value as ScheduleType)} + > + } + label="Interval-based (months or miles)" + /> + } + label="Fixed due date" + /> + } + label="Time since last service" + /> + + + + + {/* Interval-based fields */} + {currentScheduleType === 'interval' && ( + <> + + + handleInputChange( + 'intervalMonths', + e.target.value ? parseInt(e.target.value) : undefined + ) + } + helperText="Service every X months" + inputProps={{ min: 1 }} + /> + + + + handleInputChange( + 'intervalMiles', + e.target.value ? parseInt(e.target.value) : undefined + ) + } + helperText="Service every X miles" + inputProps={{ min: 1 }} + /> + + + )} + + {/* Fixed date field */} + {currentScheduleType === 'fixed_date' && ( + + + handleInputChange('fixedDueDate', newValue?.toISOString().split('T')[0] || undefined) + } + format="MM/DD/YYYY" + slotProps={{ + textField: { + fullWidth: true, + helperText: 'One-time service due date', + sx: { + '& .MuiOutlinedInput-root': { + minHeight: '56px', + }, + }, + }, + }} + /> + + )} + + {/* Time since last fields */} + {currentScheduleType === 'time_since_last' && ( + <> + + + handleInputChange( + 'intervalMonths', + e.target.value ? parseInt(e.target.value) : undefined + ) + } + helperText="Months after last service" + inputProps={{ min: 1 }} + /> + + + + handleInputChange( + 'intervalMiles', + e.target.value ? parseInt(e.target.value) : undefined + ) + } + helperText="Miles after last service" + inputProps={{ min: 1 }} + /> + + + )} + + {/* Email Notifications */} + + handleInputChange('emailNotifications', e.target.checked)} + /> + } + label="Email notifications" + /> + + + {/* Reminder Days (only if email notifications enabled) */} + {formData.emailNotifications && ( + <> + + + Reminder Days Before Due Date + + + + + handleInputChange( + 'reminderDays1', + e.target.value ? parseInt(e.target.value) : undefined + ) + } + helperText="Days before" + inputProps={{ min: 1 }} + /> + + + + handleInputChange( + 'reminderDays2', + e.target.value ? parseInt(e.target.value) : undefined + ) + } + helperText="Days before" + inputProps={{ min: 1 }} + /> + + + + handleInputChange( + 'reminderDays3', + e.target.value ? parseInt(e.target.value) : undefined + ) + } + helperText="Days before" + inputProps={{ min: 1 }} + /> + + + )} + + + + + + + + + + ); +}; diff --git a/frontend/src/features/maintenance/components/MaintenanceScheduleForm.tsx b/frontend/src/features/maintenance/components/MaintenanceScheduleForm.tsx new file mode 100644 index 0000000..a1836c7 --- /dev/null +++ b/frontend/src/features/maintenance/components/MaintenanceScheduleForm.tsx @@ -0,0 +1,539 @@ +/** + * @ai-summary Form component for creating maintenance schedules + * @ai-context Mobile-first responsive design with proper validation + */ + +import React, { useState, useEffect } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Card, + CardHeader, + CardContent, + TextField, + Select, + MenuItem, + Button, + Box, + Grid, + FormControl, + InputLabel, + FormHelperText, + CircularProgress, + Typography, + RadioGroup, + FormControlLabel, + Radio, + FormLabel, +} from '@mui/material'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import dayjs from 'dayjs'; +import { useMaintenanceRecords } from '../hooks/useMaintenanceRecords'; +import { useVehicles } from '../../vehicles/hooks/useVehicles'; +import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup'; +import { EmailNotificationToggle } from '../../notifications/components/EmailNotificationToggle'; +import { + MaintenanceCategory, + ScheduleType, + CreateScheduleRequest, + getCategoryDisplayName, +} from '../types/maintenance.types'; +import toast from 'react-hot-toast'; + +const schema = z + .object({ + vehicle_id: z.string().uuid({ message: 'Please select a vehicle' }), + category: z.enum(['routine_maintenance', 'repair', 'performance_upgrade'], { + errorMap: () => ({ message: 'Please select a category' }), + }), + subtypes: z.array(z.string()).min(1, { message: 'Please select at least one subtype' }), + schedule_type: z.enum(['interval', 'fixed_date', 'time_since_last'], { + errorMap: () => ({ message: 'Please select a schedule type' }), + }), + interval_months: z.coerce.number().positive().optional().or(z.literal('')), + interval_miles: z.coerce.number().positive().optional().or(z.literal('')), + fixed_due_date: z.string().optional(), + email_notifications: z.boolean().optional(), + reminder_days_1: z.coerce.number().optional().or(z.literal('')), + reminder_days_2: z.coerce.number().optional().or(z.literal('')), + reminder_days_3: z.coerce.number().optional().or(z.literal('')), + }) + .refine( + (data) => { + if (data.schedule_type === 'fixed_date') { + return !!data.fixed_due_date; + } + return true; + }, + { + message: 'Fixed due date is required for fixed date schedules', + path: ['fixed_due_date'], + } + ) + .refine( + (data) => { + if (data.schedule_type === 'interval' || data.schedule_type === 'time_since_last') { + return !!data.interval_months || !!data.interval_miles; + } + return true; + }, + { + message: 'At least one of interval months or interval miles is required', + path: ['interval_months'], + } + ); + +type FormData = z.infer; + +const REMINDER_OPTIONS = [ + { value: '', label: 'None' }, + { value: '1', label: '1 day' }, + { value: '7', label: '7 days' }, + { value: '14', label: '14 days' }, + { value: '30', label: '30 days' }, + { value: '60', label: '60 days' }, +]; + +export const MaintenanceScheduleForm: React.FC = () => { + const { data: vehicles, isLoading: isLoadingVehicles } = useVehicles(); + const { createSchedule, isScheduleMutating } = useMaintenanceRecords(); + const [selectedCategory, setSelectedCategory] = useState(null); + + const { + control, + handleSubmit, + watch, + setValue, + reset, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(schema), + mode: 'onChange', + defaultValues: { + vehicle_id: '', + category: undefined as any, + subtypes: [], + schedule_type: 'interval' as ScheduleType, + interval_months: '' as any, + interval_miles: '' as any, + fixed_due_date: '', + email_notifications: false, + reminder_days_1: '' as any, + reminder_days_2: '' as any, + reminder_days_3: '' as any, + }, + }); + + // Watch category and schedule type changes + const watchedCategory = watch('category'); + const watchedScheduleType = watch('schedule_type'); + + useEffect(() => { + if (watchedCategory) { + setSelectedCategory(watchedCategory as MaintenanceCategory); + setValue('subtypes', []); + } + }, [watchedCategory, setValue]); + + const onSubmit = async (data: FormData) => { + try { + const payload: CreateScheduleRequest = { + vehicleId: data.vehicle_id, + category: data.category as MaintenanceCategory, + subtypes: data.subtypes, + scheduleType: data.schedule_type as ScheduleType, + intervalMonths: data.interval_months ? Number(data.interval_months) : undefined, + intervalMiles: data.interval_miles ? Number(data.interval_miles) : undefined, + fixedDueDate: data.fixed_due_date || undefined, + emailNotifications: data.email_notifications, + reminderDays1: data.reminder_days_1 ? Number(data.reminder_days_1) : undefined, + reminderDays2: data.reminder_days_2 ? Number(data.reminder_days_2) : undefined, + reminderDays3: data.reminder_days_3 ? Number(data.reminder_days_3) : undefined, + }; + + await createSchedule(payload); + toast.success('Maintenance schedule created successfully'); + + // Reset form + reset({ + vehicle_id: '', + category: undefined as any, + subtypes: [], + schedule_type: 'interval' as ScheduleType, + interval_months: '' as any, + interval_miles: '' as any, + fixed_due_date: '', + email_notifications: false, + reminder_days_1: '' as any, + reminder_days_2: '' as any, + reminder_days_3: '' as any, + }); + setSelectedCategory(null); + } catch (error) { + console.error('Failed to create maintenance schedule:', error); + toast.error('Failed to create maintenance schedule'); + } + }; + + if (isLoadingVehicles) { + return ( + + + + + + + + ); + } + + return ( + + + + +
+ + {/* Vehicle Selection */} + + ( + + Vehicle * + + {errors.vehicle_id && ( + {errors.vehicle_id.message} + )} + + )} + /> + + + {/* Category Selection */} + + ( + + Category * + + {errors.category && ( + {errors.category.message} + )} + + )} + /> + + + {/* Subtypes */} + {selectedCategory && ( + + + Subtypes * + + ( + + + {errors.subtypes && ( + + {errors.subtypes.message} + + )} + + )} + /> + + )} + + {/* Schedule Type */} + + ( + + + Schedule Type * + + + } + label="Interval-based (every X months/miles)" + sx={{ + mb: 1, + '& .MuiFormControlLabel-label': { + fontSize: { xs: 14, sm: 16 }, + }, + }} + /> + } + label="Fixed date" + sx={{ + mb: 1, + '& .MuiFormControlLabel-label': { + fontSize: { xs: 14, sm: 16 }, + }, + }} + /> + } + label="Time since last service" + sx={{ + '& .MuiFormControlLabel-label': { + fontSize: { xs: 14, sm: 16 }, + }, + }} + /> + + {errors.schedule_type && ( + {errors.schedule_type.message} + )} + + )} + /> + + + {/* Conditional fields based on schedule type */} + {(watchedScheduleType === 'interval' || watchedScheduleType === 'time_since_last') && ( + <> + + ( + + )} + /> + + + ( + + )} + /> + + + )} + + {watchedScheduleType === 'fixed_date' && ( + + ( + + field.onChange(newValue?.toISOString().split('T')[0] || '') + } + format="MM/DD/YYYY" + slotProps={{ + textField: { + fullWidth: true, + error: !!errors.fixed_due_date, + helperText: errors.fixed_due_date?.message, + sx: { + '& .MuiOutlinedInput-root': { + minHeight: 56, + }, + }, + }, + }} + /> + )} + /> + + )} + + {/* Reminder Dropdowns */} + + + Reminders + + + + + ( + + Reminder 1 + + + )} + /> + + + + ( + + Reminder 2 + + + )} + /> + + + + ( + + Reminder 3 + + + )} + /> + + + {/* Email Notifications Toggle */} + + ( + + )} + /> + + + {/* Submit Button */} + + + + + + +
+
+
+
+ ); +}; diff --git a/frontend/src/features/maintenance/components/MaintenanceSchedulesList.tsx b/frontend/src/features/maintenance/components/MaintenanceSchedulesList.tsx new file mode 100644 index 0000000..04e2ac3 --- /dev/null +++ b/frontend/src/features/maintenance/components/MaintenanceSchedulesList.tsx @@ -0,0 +1,320 @@ +/** + * @ai-summary List component for displaying maintenance schedules + * @ai-context Shows schedule status with due/overdue indicators + */ + +import React, { useState } from 'react'; +import { + Card, + CardContent, + Typography, + Box, + IconButton, + Stack, + Chip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + useTheme, + useMediaQuery, +} from '@mui/material'; +import { Edit, Delete, Notifications } from '@mui/icons-material'; +import { + MaintenanceScheduleResponse, + getCategoryDisplayName, +} from '../types/maintenance.types'; + +interface MaintenanceSchedulesListProps { + schedules?: MaintenanceScheduleResponse[]; + onEdit?: (schedule: MaintenanceScheduleResponse) => void; + onDelete?: (scheduleId: string) => void; +} + +export const MaintenanceSchedulesList: React.FC = ({ + schedules, + onEdit, + onDelete, +}) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [scheduleToDelete, setScheduleToDelete] = useState(null); + + const handleDeleteClick = (schedule: MaintenanceScheduleResponse) => { + setScheduleToDelete(schedule); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = () => { + if (scheduleToDelete && onDelete) { + onDelete(scheduleToDelete.id); + setDeleteDialogOpen(false); + setScheduleToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setDeleteDialogOpen(false); + setScheduleToDelete(null); + }; + + const getScheduleTypeDisplay = (schedule: MaintenanceScheduleResponse): string => { + if (schedule.scheduleType === 'interval') { + const parts: string[] = []; + if (schedule.intervalMonths) { + parts.push(`Every ${schedule.intervalMonths} month${schedule.intervalMonths > 1 ? 's' : ''}`); + } + if (schedule.intervalMiles) { + parts.push(`${schedule.intervalMiles.toLocaleString()} miles`); + } + return parts.join(' or ') || 'Interval-based'; + } else if (schedule.scheduleType === 'fixed_date') { + return 'Fixed Date'; + } else if (schedule.scheduleType === 'time_since_last') { + return 'Time Since Last'; + } + return 'Interval-based'; + }; + + const getScheduleStatus = (schedule: MaintenanceScheduleResponse): { + label: string; + color: 'default' | 'warning' | 'error' | 'success'; + } => { + if (!schedule.isActive) { + return { label: 'Inactive', color: 'default' }; + } + if (schedule.isOverdue) { + return { label: 'Overdue', color: 'error' }; + } + if (schedule.isDueSoon) { + return { label: 'Due Soon', color: 'warning' }; + } + return { label: 'Active', color: 'success' }; + }; + + const getNextDueDisplay = (schedule: MaintenanceScheduleResponse): string | null => { + const parts: string[] = []; + + if (schedule.nextDueDate) { + const date = new Date(schedule.nextDueDate); + parts.push(date.toLocaleDateString()); + } + + if (schedule.nextDueMileage) { + parts.push(`${schedule.nextDueMileage.toLocaleString()} miles`); + } + + return parts.length > 0 ? parts.join(' or ') : null; + }; + + const getReminderDisplay = (schedule: MaintenanceScheduleResponse): string | null => { + if (!schedule.emailNotifications) { + return null; + } + + const reminderDays = [ + schedule.reminderDays1, + schedule.reminderDays2, + schedule.reminderDays3, + ].filter((day): day is number => day !== null && day !== undefined); + + if (reminderDays.length === 0) { + return 'Email notifications enabled'; + } + + const sortedDays = reminderDays.sort((a, b) => b - a); + return `Reminders: ${sortedDays.join(', ')} days before`; + }; + + if (!schedules || schedules.length === 0) { + return ( + + + + No maintenance schedules yet. + + + + ); + } + + // Sort schedules: overdue first, then due soon, then active, then inactive + const sortedSchedules = [...schedules].sort((a, b) => { + // Inactive schedules go last + if (!a.isActive && b.isActive) return 1; + if (a.isActive && !b.isActive) return -1; + if (!a.isActive && !b.isActive) return 0; + + // Both active: overdue first + if (a.isOverdue && !b.isOverdue) return -1; + if (!a.isOverdue && b.isOverdue) return 1; + + // Both active and same overdue status: due soon next + if (a.isDueSoon && !b.isDueSoon) return -1; + if (!a.isDueSoon && b.isDueSoon) return 1; + + return 0; + }); + + return ( + <> + + {sortedSchedules.map((schedule) => { + const categoryDisplay = getCategoryDisplayName(schedule.category); + const subtypeCount = schedule.subtypeCount || schedule.subtypes?.length || 0; + const scheduleTypeDisplay = getScheduleTypeDisplay(schedule); + const status = getScheduleStatus(schedule); + const nextDueDisplay = getNextDueDisplay(schedule); + const reminderDisplay = getReminderDisplay(schedule); + + return ( + + + + + + + {categoryDisplay} + + + + + + {scheduleTypeDisplay} • {subtypeCount} service type{subtypeCount !== 1 ? 's' : ''} + + + + {schedule.subtypes && schedule.subtypes.length > 0 && ( + + {schedule.subtypes.slice(0, 3).map((subtype) => ( + + ))} + {schedule.subtypes.length > 3 && ( + + )} + + )} + + + {nextDueDisplay && ( + + Next due: {nextDueDisplay} + + )} + + {reminderDisplay && ( + + + + {reminderDisplay} + + + )} + + + + {onEdit && ( + onEdit(schedule)} + sx={{ + color: 'primary.main', + minWidth: 44, + minHeight: 44, + '&:hover': { + backgroundColor: 'primary.main', + color: 'white', + }, + ...(isMobile && { + border: '1px solid', + borderColor: 'primary.main', + borderRadius: 2, + }), + }} + > + + + )} + {onDelete && ( + handleDeleteClick(schedule)} + sx={{ + color: 'error.main', + minWidth: 44, + minHeight: 44, + '&:hover': { + backgroundColor: 'error.main', + color: 'white', + }, + ...(isMobile && { + border: '1px solid', + borderColor: 'error.main', + borderRadius: 2, + }), + }} + > + + + )} + + + + + ); + })} + + + {/* Delete Confirmation Dialog */} + + Delete Maintenance Schedule + + + Are you sure you want to delete this maintenance schedule? This action cannot be undone. + + {scheduleToDelete && ( + + {getCategoryDisplayName(scheduleToDelete.category)} - {getScheduleTypeDisplay(scheduleToDelete)} + + )} + + + + + + + + ); +}; diff --git a/frontend/src/features/maintenance/index.ts b/frontend/src/features/maintenance/index.ts index 1ccee39..de6fa07 100644 --- a/frontend/src/features/maintenance/index.ts +++ b/frontend/src/features/maintenance/index.ts @@ -12,10 +12,16 @@ export * from './api/maintenance.api'; // Hooks export * from './hooks/useMaintenanceRecords'; -// Components +// Components - Records export { SubtypeCheckboxGroup } from './components/SubtypeCheckboxGroup'; export { MaintenanceRecordForm } from './components/MaintenanceRecordForm'; export { MaintenanceRecordsList } from './components/MaintenanceRecordsList'; +export { MaintenanceRecordEditDialog } from './components/MaintenanceRecordEditDialog'; + +// Components - Schedules +export { MaintenanceScheduleForm } from './components/MaintenanceScheduleForm'; +export { MaintenanceSchedulesList } from './components/MaintenanceSchedulesList'; +export { MaintenanceScheduleEditDialog } from './components/MaintenanceScheduleEditDialog'; // Pages export { MaintenancePage } from './pages/MaintenancePage'; diff --git a/frontend/src/features/maintenance/pages/MaintenancePage.tsx b/frontend/src/features/maintenance/pages/MaintenancePage.tsx index d8fde08..5b2ba2b 100644 --- a/frontend/src/features/maintenance/pages/MaintenancePage.tsx +++ b/frontend/src/features/maintenance/pages/MaintenancePage.tsx @@ -4,20 +4,26 @@ */ import React, { useState } from 'react'; -import { Grid, Typography, Box } from '@mui/material'; +import { Grid, Typography, Box, Tabs, Tab } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; import { MaintenanceRecordForm } from '../components/MaintenanceRecordForm'; import { MaintenanceRecordsList } from '../components/MaintenanceRecordsList'; import { MaintenanceRecordEditDialog } from '../components/MaintenanceRecordEditDialog'; +import { MaintenanceScheduleForm } from '../components/MaintenanceScheduleForm'; +import { MaintenanceSchedulesList } from '../components/MaintenanceSchedulesList'; +import { MaintenanceScheduleEditDialog } from '../components/MaintenanceScheduleEditDialog'; import { useMaintenanceRecords } from '../hooks/useMaintenanceRecords'; import { FormSuspense } from '../../../components/SuspenseWrappers'; -import type { MaintenanceRecordResponse, UpdateMaintenanceRecordRequest } from '../types/maintenance.types'; +import type { MaintenanceRecordResponse, UpdateMaintenanceRecordRequest, MaintenanceScheduleResponse, UpdateScheduleRequest } from '../types/maintenance.types'; export const MaintenancePage: React.FC = () => { - const { records, isRecordsLoading, recordsError, updateRecord, deleteRecord } = useMaintenanceRecords(); + const { records, schedules, isRecordsLoading, isSchedulesLoading, recordsError, schedulesError, updateRecord, deleteRecord, updateSchedule, deleteSchedule } = useMaintenanceRecords(); const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState<'records' | 'schedules'>('records'); const [editingRecord, setEditingRecord] = useState(null); const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingSchedule, setEditingSchedule] = useState(null); + const [scheduleEditDialogOpen, setScheduleEditDialogOpen] = useState(false); const handleEdit = (record: MaintenanceRecordResponse) => { setEditingRecord(record); @@ -52,7 +58,43 @@ export const MaintenancePage: React.FC = () => { } }; - if (isRecordsLoading) { + const handleScheduleEdit = (schedule: MaintenanceScheduleResponse) => { + setEditingSchedule(schedule); + setScheduleEditDialogOpen(true); + }; + + const handleScheduleEditSave = async (id: string, data: UpdateScheduleRequest) => { + try { + await updateSchedule({ id, data }); + // Refetch queries after update + queryClient.refetchQueries({ queryKey: ['maintenanceSchedules'] }); + setScheduleEditDialogOpen(false); + setEditingSchedule(null); + } catch (error) { + console.error('Failed to update maintenance schedule:', error); + throw error; // Re-throw to let dialog handle the error + } + }; + + const handleScheduleEditClose = () => { + setScheduleEditDialogOpen(false); + setEditingSchedule(null); + }; + + const handleScheduleDelete = async (scheduleId: string) => { + try { + await deleteSchedule(scheduleId); + // Refetch queries after delete + queryClient.refetchQueries({ queryKey: ['maintenanceSchedules'] }); + } catch (error) { + console.error('Failed to delete maintenance schedule:', error); + } + }; + + const isLoading = activeTab === 'records' ? isRecordsLoading : isSchedulesLoading; + const hasError = activeTab === 'records' ? recordsError : schedulesError; + + if (isLoading) { return ( { height: '50vh', }} > - Loading maintenance records... + + Loading maintenance {activeTab}... + ); } - if (recordsError) { + if (hasError) { return ( { }} > - Failed to load maintenance records. Please try again. + Failed to load maintenance {activeTab}. Please try again. ); @@ -86,32 +130,72 @@ export const MaintenancePage: React.FC = () => { return ( - - {/* Top: Form */} - - - + + setActiveTab(v as 'records' | 'schedules')} + aria-label="Maintenance tabs" + > + + + + - {/* Bottom: Records List */} - - - Recent Maintenance Records - - - - + {activeTab === 'records' && ( + + {/* Top: Form */} + + + - {/* Edit Dialog */} + {/* Bottom: Records List */} + + + Recent Maintenance Records + + + + + )} + + {activeTab === 'schedules' && ( + + {/* Top: Form */} + + + + + {/* Bottom: Schedules List */} + + + Maintenance Schedules + + + + + )} + + {/* Edit Dialogs */} + ); }; diff --git a/frontend/src/features/maintenance/types/maintenance.types.ts b/frontend/src/features/maintenance/types/maintenance.types.ts index 32a657b..26e7d3d 100644 --- a/frontend/src/features/maintenance/types/maintenance.types.ts +++ b/frontend/src/features/maintenance/types/maintenance.types.ts @@ -6,6 +6,9 @@ // Category types export type MaintenanceCategory = 'routine_maintenance' | 'repair' | 'performance_upgrade'; +// Schedule types +export type ScheduleType = 'interval' | 'fixed_date' | 'time_since_last'; + // Subtype definitions (constants for validation) export const ROUTINE_MAINTENANCE_SUBTYPES = [ 'Accelerator Pedal', @@ -75,14 +78,19 @@ export interface MaintenanceSchedule { vehicleId: string; category: MaintenanceCategory; subtypes: string[]; + scheduleType: ScheduleType; intervalMonths?: number; intervalMiles?: number; + fixedDueDate?: string | null; lastServiceDate?: string; lastServiceMileage?: number; nextDueDate?: string; nextDueMileage?: number; isActive: boolean; emailNotifications?: boolean; + reminderDays1?: number | null; + reminderDays2?: number | null; + reminderDays3?: number | null; createdAt: string; updatedAt: string; } @@ -113,18 +121,28 @@ export interface CreateScheduleRequest { vehicleId: string; category: MaintenanceCategory; subtypes: string[]; + scheduleType?: ScheduleType; intervalMonths?: number; intervalMiles?: number; + fixedDueDate?: string; emailNotifications?: boolean; + reminderDays1?: number; + reminderDays2?: number; + reminderDays3?: number; } export interface UpdateScheduleRequest { category?: MaintenanceCategory; subtypes?: string[]; + scheduleType?: ScheduleType; intervalMonths?: number | null; intervalMiles?: number | null; + fixedDueDate?: string | null; isActive?: boolean; emailNotifications?: boolean; + reminderDays1?: number | null; + reminderDays2?: number | null; + reminderDays3?: number | null; } // Response types (camelCase) diff --git a/frontend/src/features/notifications/api/notifications.api.ts b/frontend/src/features/notifications/api/notifications.api.ts index b8ca4e1..f7def78 100644 --- a/frontend/src/features/notifications/api/notifications.api.ts +++ b/frontend/src/features/notifications/api/notifications.api.ts @@ -3,7 +3,13 @@ */ import { apiClient } from '../../../core/api/client'; -import { NotificationSummary, DueMaintenanceItem, ExpiringDocument } from '../types/notifications.types'; +import { + NotificationSummary, + DueMaintenanceItem, + ExpiringDocument, + UserNotification, + UnreadNotificationCount +} from '../types/notifications.types'; export const notificationsApi = { getSummary: async (): Promise => { @@ -20,4 +26,31 @@ export const notificationsApi = { const response = await apiClient.get('/notifications/documents'); return response.data; }, + + // In-App Notifications + getInAppNotifications: async (limit = 20, includeRead = false): Promise => { + const response = await apiClient.get('/notifications/in-app', { + params: { limit, includeRead: includeRead.toString() } + }); + return response.data; + }, + + getUnreadCount: async (): Promise => { + const response = await apiClient.get('/notifications/in-app/count'); + return response.data; + }, + + markAsRead: async (id: string): Promise => { + const response = await apiClient.put(`/notifications/in-app/${id}/read`); + return response.data; + }, + + markAllAsRead: async (): Promise<{ markedAsRead: number }> => { + const response = await apiClient.put('/notifications/in-app/read-all'); + return response.data; + }, + + deleteNotification: async (id: string): Promise => { + await apiClient.delete(`/notifications/in-app/${id}`); + }, }; diff --git a/frontend/src/features/notifications/components/NotificationBell.tsx b/frontend/src/features/notifications/components/NotificationBell.tsx new file mode 100644 index 0000000..ba905c0 --- /dev/null +++ b/frontend/src/features/notifications/components/NotificationBell.tsx @@ -0,0 +1,213 @@ +/** + * @ai-summary Bell icon with dropdown for in-app notifications + * @ai-context Displays in header with unread count badge + */ + +import React, { useState } from 'react'; +import { + IconButton, + Badge, + Popover, + Box, + Typography, + List, + ListItem, + ListItemText, + Button, + Divider, + CircularProgress, +} from '@mui/material'; +import NotificationsIcon from '@mui/icons-material/Notifications'; +import CloseIcon from '@mui/icons-material/Close'; +import CheckIcon from '@mui/icons-material/Check'; +import { useInAppNotifications } from '../hooks/useInAppNotifications'; + +// Helper function for relative time +function formatTimeAgo(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +export const NotificationBell: React.FC = () => { + const [anchorEl, setAnchorEl] = useState(null); + const { + notifications, + unreadCount, + isLoading, + markAsRead, + markAllAsRead, + deleteNotification, + } = useInAppNotifications(); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleMarkAsRead = async (id: string) => { + try { + await markAsRead(id); + } catch (error) { + console.error('Failed to mark as read:', error); + } + }; + + const handleMarkAllAsRead = async () => { + try { + await markAllAsRead(); + } catch (error) { + console.error('Failed to mark all as read:', error); + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteNotification(id); + } catch (error) { + console.error('Failed to delete notification:', error); + } + }; + + const open = Boolean(anchorEl); + + return ( + <> + + + + + + + + + Notifications + {unreadCount > 0 && ( + + )} + + + + {isLoading ? ( + + + + ) : notifications.length === 0 ? ( + + No notifications + + ) : ( + + {notifications.map((notification) => ( + + {!notification.isRead && ( + handleMarkAsRead(notification.id)} + title="Mark as read" + sx={{ minWidth: 36, minHeight: 36 }} + > + + + )} + handleDelete(notification.id)} + title="Delete" + sx={{ minWidth: 36, minHeight: 36 }} + > + + + + } + > + + {notification.title} + + } + secondary={ + <> + + {notification.message} + + + {formatTimeAgo(notification.createdAt)} + + + } + /> + + ))} + + )} + + + ); +}; diff --git a/frontend/src/features/notifications/hooks/useInAppNotifications.ts b/frontend/src/features/notifications/hooks/useInAppNotifications.ts new file mode 100644 index 0000000..8a215b0 --- /dev/null +++ b/frontend/src/features/notifications/hooks/useInAppNotifications.ts @@ -0,0 +1,73 @@ +/** + * @ai-summary Hook for in-app notifications with real-time count + * @ai-context Provides notification list and unread count with polling + */ + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useAuth0 } from '@auth0/auth0-react'; +import { notificationsApi } from '../api/notifications.api'; +import type { UserNotification, UnreadNotificationCount } from '../types/notifications.types'; + +export function useInAppNotifications() { + const { isAuthenticated } = useAuth0(); + const queryClient = useQueryClient(); + + // Unread count - polls every 60 seconds + const countQuery = useQuery({ + queryKey: ['notificationCount'], + queryFn: notificationsApi.getUnreadCount, + enabled: isAuthenticated, + refetchInterval: 60 * 1000, // Poll every minute + staleTime: 30 * 1000, + }); + + // Notification list + const listQuery = useQuery({ + queryKey: ['inAppNotifications'], + queryFn: () => notificationsApi.getInAppNotifications(20, false), + enabled: isAuthenticated, + staleTime: 60 * 1000, + }); + + // Mark as read mutation + const markAsReadMutation = useMutation({ + mutationFn: notificationsApi.markAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notificationCount'] }); + queryClient.invalidateQueries({ queryKey: ['inAppNotifications'] }); + }, + }); + + // Mark all as read mutation + const markAllAsReadMutation = useMutation({ + mutationFn: notificationsApi.markAllAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notificationCount'] }); + queryClient.invalidateQueries({ queryKey: ['inAppNotifications'] }); + }, + }); + + // Delete mutation + const deleteMutation = useMutation({ + mutationFn: notificationsApi.deleteNotification, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notificationCount'] }); + queryClient.invalidateQueries({ queryKey: ['inAppNotifications'] }); + }, + }); + + return { + notifications: listQuery.data ?? [], + unreadCount: countQuery.data?.total ?? 0, + countByType: countQuery.data, + isLoading: listQuery.isLoading || countQuery.isLoading, + isError: listQuery.isError || countQuery.isError, + markAsRead: markAsReadMutation.mutateAsync, + markAllAsRead: markAllAsReadMutation.mutateAsync, + deleteNotification: deleteMutation.mutateAsync, + refetch: () => { + queryClient.invalidateQueries({ queryKey: ['notificationCount'] }); + queryClient.invalidateQueries({ queryKey: ['inAppNotifications'] }); + }, + }; +} diff --git a/frontend/src/features/notifications/index.ts b/frontend/src/features/notifications/index.ts index e874dfd..09d1b66 100644 --- a/frontend/src/features/notifications/index.ts +++ b/frontend/src/features/notifications/index.ts @@ -5,4 +5,6 @@ export * from './api/notifications.api'; export * from './types/notifications.types'; export * from './hooks/useLoginNotifications'; +export * from './hooks/useInAppNotifications'; export * from './components/EmailNotificationToggle'; +export * from './components/NotificationBell'; diff --git a/frontend/src/features/notifications/types/notifications.types.ts b/frontend/src/features/notifications/types/notifications.types.ts index 74b220e..3e79598 100644 --- a/frontend/src/features/notifications/types/notifications.types.ts +++ b/frontend/src/features/notifications/types/notifications.types.ts @@ -34,3 +34,22 @@ export interface ExpiringDocument { isExpired: boolean; emailNotifications: boolean; } + +export interface UserNotification { + id: string; + notificationType: string; + title: string; + message: string; + referenceType?: string | null; + referenceId?: string | null; + vehicleId?: string | null; + isRead: boolean; + createdAt: string; + readAt?: string | null; +} + +export interface UnreadNotificationCount { + total: number; + maintenance: number; + documents: number; +} diff --git a/frontend/src/features/stations/components/StationMap.tsx b/frontend/src/features/stations/components/StationMap.tsx index a72237b..03ab140 100644 --- a/frontend/src/features/stations/components/StationMap.tsx +++ b/frontend/src/features/stations/components/StationMap.tsx @@ -257,7 +257,7 @@ export const StationMap: React.FC = ({ infoWindows.current = []; getGoogleMapsApi(); - let allMarkers: google.maps.marker.AdvancedMarkerElement[] = []; + const allMarkers: google.maps.marker.AdvancedMarkerElement[] = []; // Add station markers stations.forEach((station) => {