feat: Scheduled Maintenance feature complete

This commit is contained in:
Eric Gullickson
2025-12-22 14:12:33 -06:00
parent c017b8816f
commit 91b4534e76
44 changed files with 2740 additions and 117 deletions

View File

@@ -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
// ========================

View File

@@ -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
// ========================

View File

@@ -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
}));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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);