Notification updates

This commit is contained in:
Eric Gullickson
2025-12-21 19:56:52 -06:00
parent 144f1d5bb0
commit 719c80ecd8
80 changed files with 7552 additions and 678 deletions

View File

@@ -0,0 +1,272 @@
import { Pool } from 'pg';
import pool from '../../../core/config/database';
import type {
EmailTemplate,
NotificationLog,
NotificationSummary,
DueMaintenanceItem,
ExpiringDocument,
TemplateKey
} from '../domain/notifications.types';
export class NotificationsRepository {
constructor(private readonly db: Pool = pool) {}
// ========================
// Row Mappers
// ========================
private mapEmailTemplate(row: any): EmailTemplate {
return {
id: row.id,
templateKey: row.template_key,
name: row.name,
description: row.description,
subject: row.subject,
body: row.body,
variables: row.variables,
isActive: row.is_active,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
private mapDueMaintenanceItem(row: any): DueMaintenanceItem {
return {
id: row.id,
vehicleId: row.vehicle_id,
vehicleName: row.vehicle_name,
category: row.category,
subtypes: row.subtypes,
nextDueDate: row.next_due_date,
nextDueMileage: row.next_due_mileage,
isDueSoon: row.is_due_soon,
isOverdue: row.is_overdue,
emailNotifications: row.email_notifications
};
}
private mapExpiringDocument(row: any): ExpiringDocument {
return {
id: row.id,
vehicleId: row.vehicle_id,
vehicleName: row.vehicle_name,
documentType: row.document_type,
title: row.title,
expirationDate: row.expiration_date,
isExpiringSoon: row.is_expiring_soon,
isExpired: row.is_expired,
emailNotifications: row.email_notifications
};
}
// ========================
// Email Templates
// ========================
async getEmailTemplates(): Promise<EmailTemplate[]> {
const res = await this.db.query(
`SELECT * FROM email_templates ORDER BY template_key`
);
return res.rows.map(row => this.mapEmailTemplate(row));
}
async getEmailTemplateByKey(key: TemplateKey): Promise<EmailTemplate | null> {
const res = await this.db.query(
`SELECT * FROM email_templates WHERE template_key = $1`,
[key]
);
return res.rows[0] ? this.mapEmailTemplate(res.rows[0]) : null;
}
async updateEmailTemplate(
key: TemplateKey,
updates: { subject?: string; body?: string; isActive?: boolean }
): Promise<EmailTemplate | null> {
const fields: string[] = [];
const params: (string | boolean)[] = [];
let i = 1;
if (updates.subject !== undefined) {
fields.push(`subject = $${i++}`);
params.push(updates.subject);
}
if (updates.body !== undefined) {
fields.push(`body = $${i++}`);
params.push(updates.body);
}
if (updates.isActive !== undefined) {
fields.push(`is_active = $${i++}`);
params.push(updates.isActive);
}
if (fields.length === 0) {
return this.getEmailTemplateByKey(key);
}
fields.push(`updated_at = CURRENT_TIMESTAMP`);
params.push(key);
const sql = `
UPDATE email_templates
SET ${fields.join(', ')}
WHERE template_key = $${i}
RETURNING *
`;
const res = await this.db.query(sql, params);
return res.rows[0] ? this.mapEmailTemplate(res.rows[0]) : null;
}
// ========================
// Notification Logs
// ========================
async insertNotificationLog(log: {
user_id: string;
notification_type: 'email' | 'toast';
template_key: TemplateKey;
recipient_email?: string;
subject?: string;
reference_type?: string;
reference_id?: string;
status?: 'pending' | 'sent' | 'failed';
error_message?: string;
}): Promise<NotificationLog> {
const res = await this.db.query(
`INSERT INTO notification_logs (
user_id, notification_type, template_key, recipient_email, subject,
reference_type, reference_id, status, error_message
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
log.user_id,
log.notification_type,
log.template_key,
log.recipient_email ?? null,
log.subject ?? null,
log.reference_type ?? null,
log.reference_id ?? null,
log.status ?? 'sent',
log.error_message ?? null,
]
);
return res.rows[0] as NotificationLog;
}
// ========================
// Notification Summary
// ========================
async getNotificationSummary(userId: string): Promise<NotificationSummary> {
// Get counts of due soon vs overdue maintenance items
const maintenanceRes = await this.db.query(
`SELECT
COUNT(*) FILTER (WHERE ms.next_due_date <= CURRENT_DATE) as overdue_count,
COUNT(*) FILTER (WHERE ms.next_due_date > CURRENT_DATE AND ms.next_due_date <= CURRENT_DATE + INTERVAL '30 days') as due_soon_count
FROM maintenance_schedules ms
WHERE ms.user_id = $1
AND ms.is_active = true
AND ms.next_due_date IS NOT NULL
AND ms.next_due_date <= CURRENT_DATE + INTERVAL '30 days'`,
[userId]
);
// Get counts of expiring soon vs expired documents
const documentRes = await this.db.query(
`SELECT
COUNT(*) FILTER (WHERE d.expiration_date < CURRENT_DATE) as expired_count,
COUNT(*) FILTER (WHERE d.expiration_date >= CURRENT_DATE AND d.expiration_date <= CURRENT_DATE + INTERVAL '30 days') as expiring_soon_count
FROM documents d
WHERE d.user_id = $1
AND d.deleted_at IS NULL
AND d.expiration_date IS NOT NULL
AND d.expiration_date <= CURRENT_DATE + INTERVAL '30 days'`,
[userId]
);
return {
maintenanceDueSoon: parseInt(maintenanceRes.rows[0]?.due_soon_count || '0', 10),
maintenanceOverdue: parseInt(maintenanceRes.rows[0]?.overdue_count || '0', 10),
documentsExpiringSoon: parseInt(documentRes.rows[0]?.expiring_soon_count || '0', 10),
documentsExpired: parseInt(documentRes.rows[0]?.expired_count || '0', 10),
};
}
// ========================
// Due Maintenance Items
// ========================
async getDueMaintenanceItems(userId: string): Promise<DueMaintenanceItem[]> {
const res = await this.db.query(
`SELECT
ms.id,
ms.vehicle_id,
v.name as vehicle_name,
ms.category,
ms.subtypes,
ms.next_due_date,
ms.next_due_mileage,
ms.email_notifications,
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
ELSE false
END as is_overdue,
CASE
WHEN ms.next_due_date <= CURRENT_DATE + INTERVAL '30 days' AND ms.next_due_date > CURRENT_DATE THEN true
WHEN ms.next_due_mileage IS NOT NULL AND v.odometer >= ms.next_due_mileage - 500 AND v.odometer < ms.next_due_mileage THEN true
ELSE false
END as is_due_soon
FROM maintenance_schedules ms
JOIN vehicles v ON ms.vehicle_id = v.id
WHERE ms.user_id = $1
AND ms.is_active = true
AND (
ms.next_due_date <= CURRENT_DATE + INTERVAL '30 days'
OR (ms.next_due_mileage IS NOT NULL AND v.odometer >= ms.next_due_mileage - 500)
)
ORDER BY
CASE WHEN ms.next_due_date <= CURRENT_DATE THEN 0 ELSE 1 END,
ms.next_due_date ASC NULLS LAST`,
[userId]
);
return res.rows.map(row => this.mapDueMaintenanceItem(row));
}
// ========================
// Expiring Documents
// ========================
async getExpiringDocuments(userId: string): Promise<ExpiringDocument[]> {
const res = await this.db.query(
`SELECT
d.id,
d.vehicle_id,
v.name as vehicle_name,
d.document_type,
d.title,
d.expiration_date,
d.email_notifications,
CASE
WHEN d.expiration_date < CURRENT_DATE THEN true
ELSE false
END as is_expired,
CASE
WHEN d.expiration_date >= CURRENT_DATE AND d.expiration_date <= CURRENT_DATE + INTERVAL '30 days' THEN true
ELSE false
END as is_expiring_soon
FROM documents d
JOIN vehicles v ON d.vehicle_id = v.id
WHERE d.user_id = $1
AND d.deleted_at IS NULL
AND d.expiration_date IS NOT NULL
AND d.expiration_date <= CURRENT_DATE + INTERVAL '30 days'
ORDER BY
CASE WHEN d.expiration_date < CURRENT_DATE THEN 0 ELSE 1 END,
d.expiration_date ASC`,
[userId]
);
return res.rows.map(row => this.mapExpiringDocument(row));
}
}