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