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

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