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,121 @@
# Notifications Feature Capsule
## Quick Summary
Email and toast notification system for maintenance due/overdue items and expiring documents. Uses Resend for email delivery and provides admin-editable email templates. User-scoped data with per-entry email notification toggles.
## API Endpoints
### User Endpoints
- `GET /api/notifications/summary` - Get notification summary (counts for login toast)
- `GET /api/notifications/maintenance` - Get due/overdue maintenance items
- `GET /api/notifications/documents` - Get expiring/expired documents
### Admin Endpoints
- `GET /api/admin/email-templates` - List all email templates
- `GET /api/admin/email-templates/:key` - Get single email template
- `PUT /api/admin/email-templates/:key` - Update email template
- `POST /api/admin/email-templates/:key/preview` - Preview template with sample variables
## Structure
- **api/** - HTTP endpoints, routes, validators
- **domain/** - Business logic, services, types
- **data/** - Repository, database queries
- **migrations/** - Feature-specific schema
- **tests/** - All feature tests
## Email Templates
### Predefined Templates (4 total)
1. **maintenance_due_soon** - Sent when maintenance is due within 30 days or 500 miles
2. **maintenance_overdue** - Sent when maintenance is past due
3. **document_expiring** - Sent when document expires within 30 days
4. **document_expired** - Sent when document has expired
### Template Variables
Templates use `{{variableName}}` syntax for variable substitution.
**Maintenance templates:**
- userName, vehicleName, category, subtypes, dueDate, dueMileage
**Document templates:**
- userName, vehicleName, documentType, documentTitle, expirationDate
## Dependencies
### Internal
- `core/auth` - Authentication plugin
- `core/logging` - Structured logging
- `core/config` - Database pool and secrets
### External
- `resend` - Email delivery service
### Database
- Tables: `email_templates`, `notification_logs`
- FK: `maintenance_schedules(email_notifications)`, `documents(email_notifications)`
## Business Rules
### Notification Triggers
**Maintenance Due Soon:**
- Next due date within 30 days OR
- Next due mileage within 500 miles of current odometer
**Maintenance Overdue:**
- Next due date in the past OR
- Current odometer exceeds next due mileage
**Document Expiring Soon:**
- Expiration date within 30 days
**Document Expired:**
- Expiration date in the past
### Email Notification Toggle
- Per-entry toggle on `maintenance_schedules.email_notifications`
- Per-entry toggle on `documents.email_notifications`
- Default: `false` (opt-in)
### Login Toast Summary
- Shows count of maintenance items requiring attention
- Shows count of documents requiring attention
- Displayed once per session on successful login
## Security Requirements
1. All queries user-scoped (filter by user_id)
2. Prepared statements (never concatenate SQL)
3. User endpoints require JWT authentication
4. Admin endpoints require admin role
5. Template editing restricted to admins
6. Email logs track all sent notifications
## Email Service Configuration
### Environment Variables
- `RESEND_API_KEY` - Resend API key (required, stored in secrets)
- `FROM_EMAIL` - Sender email address (default: noreply@motovaultpro.com)
### Email Delivery
- Uses Resend API for transactional emails
- Converts plain text templates to HTML with line breaks
- Tracks all sent emails in `notification_logs` table
- Logs failures with error messages for debugging
## Testing
```bash
# Run feature tests
npm test -- features/notifications
```
## Future Enhancements
- Batch notification processing (scheduled job)
- Notification frequency controls (daily digest, etc.)
- User preference for notification types
- SMS notifications (via Twilio or similar)
- Push notifications (via FCM or similar)

View File

@@ -0,0 +1,197 @@
/**
* @ai-summary Controller for notifications API endpoints
* @ai-context Handles requests for notification summary, templates, and sending
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { NotificationsService } from '../domain/notifications.service';
import type { TemplateKey } from '../domain/notifications.types';
import type {
TemplateKeyParam,
UpdateEmailTemplateRequest,
PreviewTemplateRequest
} from './notifications.validation';
export class NotificationsController {
private service: NotificationsService;
constructor(service?: NotificationsService) {
this.service = service || new NotificationsService();
}
// ========================
// User Endpoints
// ========================
async getSummary(request: FastifyRequest, reply: FastifyReply) {
const userId = request.user.sub;
try {
const summary = await this.service.getNotificationSummary(userId);
return reply.code(200).send(summary);
} catch (error) {
request.log.error({ error }, 'Failed to get notification summary');
return reply.code(500).send({
error: 'Failed to get notification summary'
});
}
}
async getDueMaintenanceItems(request: FastifyRequest, reply: FastifyReply) {
const userId = request.user.sub;
try {
const items = await this.service.getDueMaintenanceItems(userId);
return reply.code(200).send(items);
} catch (error) {
request.log.error({ error }, 'Failed to get due maintenance items');
return reply.code(500).send({
error: 'Failed to get due maintenance items'
});
}
}
async getExpiringDocuments(request: FastifyRequest, reply: FastifyReply) {
const userId = request.user.sub;
try {
const documents = await this.service.getExpiringDocuments(userId);
return reply.code(200).send(documents);
} catch (error) {
request.log.error({ error }, 'Failed to get expiring documents');
return reply.code(500).send({
error: 'Failed to get expiring documents'
});
}
}
// ========================
// Admin Endpoints
// ========================
async listTemplates(request: FastifyRequest, reply: FastifyReply) {
try {
const templates = await this.service.listTemplates();
return reply.code(200).send(templates);
} catch (error) {
request.log.error({ error }, 'Failed to list email templates');
return reply.code(500).send({
error: 'Failed to list email templates'
});
}
}
async getTemplate(
request: FastifyRequest<{ Params: TemplateKeyParam }>,
reply: FastifyReply
) {
const { key } = request.params;
try {
const template = await this.service.getTemplate(key as TemplateKey);
if (!template) {
return reply.code(404).send({
error: 'Template not found'
});
}
return reply.code(200).send(template);
} catch (error) {
request.log.error({ error }, 'Failed to get email template');
return reply.code(500).send({
error: 'Failed to get email template'
});
}
}
async updateTemplate(
request: FastifyRequest<{
Params: TemplateKeyParam;
Body: UpdateEmailTemplateRequest;
}>,
reply: FastifyReply
) {
const { key } = request.params;
const updates = request.body;
try {
const template = await this.service.updateTemplate(key as TemplateKey, updates);
if (!template) {
return reply.code(404).send({
error: 'Template not found'
});
}
return reply.code(200).send(template);
} catch (error) {
request.log.error({ error }, 'Failed to update email template');
return reply.code(500).send({
error: 'Failed to update email template'
});
}
}
async previewTemplate(
request: FastifyRequest<{
Params: TemplateKeyParam;
Body: PreviewTemplateRequest;
}>,
reply: FastifyReply
) {
const { subject, body, variables } = request.body;
try {
const preview = await this.service.previewTemplate(subject, body, variables);
return reply.code(200).send(preview);
} catch (error) {
request.log.error({ error }, 'Failed to preview template');
return reply.code(500).send({
error: 'Failed to preview template'
});
}
}
async sendTestEmail(
request: FastifyRequest<{ Params: TemplateKeyParam }>,
reply: FastifyReply
) {
const { key } = request.params;
const userEmail = request.userContext?.email;
const userName = request.userContext?.displayName || 'Test User';
if (!userEmail) {
return reply.code(400).send({
error: 'No email address available. Please set your email in Settings.'
});
}
try {
const result = await this.service.sendTestEmail(
key as any,
userEmail,
userName
);
if (!result.success) {
return reply.code(500).send({
error: result.error || 'Failed to send test email',
subject: result.subject,
body: result.body
});
}
return reply.code(200).send({
message: `Test email sent to ${userEmail}`,
subject: result.subject,
body: result.body
});
} catch (error) {
request.log.error({ error }, 'Failed to send test email');
return reply.code(500).send({
error: 'Failed to send test email'
});
}
}
}

View File

@@ -0,0 +1,78 @@
/**
* @ai-summary Notifications feature routes
* @ai-context Registers notification API endpoints with proper guards
*/
import { FastifyPluginAsync } from 'fastify';
import { NotificationsController } from './notifications.controller';
import type {
TemplateKeyParam,
UpdateEmailTemplateRequest,
PreviewTemplateRequest
} from './notifications.validation';
export const notificationsRoutes: FastifyPluginAsync = async (fastify) => {
const controller = new NotificationsController();
// ========================
// User Endpoints
// ========================
// GET /api/notifications/summary - Get notification summary for login toast
fastify.get('/notifications/summary', {
preHandler: [fastify.authenticate],
handler: controller.getSummary.bind(controller)
});
// GET /api/notifications/maintenance - Get due maintenance items
fastify.get('/notifications/maintenance', {
preHandler: [fastify.authenticate],
handler: controller.getDueMaintenanceItems.bind(controller)
});
// GET /api/notifications/documents - Get expiring documents
fastify.get('/notifications/documents', {
preHandler: [fastify.authenticate],
handler: controller.getExpiringDocuments.bind(controller)
});
// ========================
// Admin Endpoints
// ========================
// GET /api/admin/email-templates - List all email templates
fastify.get('/admin/email-templates', {
preHandler: [fastify.requireAdmin],
handler: controller.listTemplates.bind(controller)
});
// GET /api/admin/email-templates/:key - Get single email template
fastify.get<{ Params: TemplateKeyParam }>('/admin/email-templates/:key', {
preHandler: [fastify.requireAdmin],
handler: controller.getTemplate.bind(controller)
});
// PUT /api/admin/email-templates/:key - Update email template
fastify.put<{
Params: TemplateKeyParam;
Body: UpdateEmailTemplateRequest;
}>('/admin/email-templates/:key', {
preHandler: [fastify.requireAdmin],
handler: controller.updateTemplate.bind(controller)
});
// POST /api/admin/email-templates/:key/preview - Preview template with variables
fastify.post<{
Params: TemplateKeyParam;
Body: PreviewTemplateRequest;
}>('/admin/email-templates/:key/preview', {
preHandler: [fastify.requireAdmin],
handler: controller.previewTemplate.bind(controller)
});
// POST /api/admin/email-templates/:key/test - Send test email to admin
fastify.post<{ Params: TemplateKeyParam }>('/admin/email-templates/:key/test', {
preHandler: [fastify.requireAdmin],
handler: controller.sendTestEmail.bind(controller)
});
};

View File

@@ -0,0 +1,33 @@
/**
* @ai-summary Validation schemas for notifications API
* @ai-context Zod schemas for request validation
*/
import { z } from 'zod';
// Template key parameter validation
export const TemplateKeyParamSchema = z.object({
key: z.enum([
'maintenance_due_soon',
'maintenance_overdue',
'document_expiring',
'document_expired'
])
});
export type TemplateKeyParam = z.infer<typeof TemplateKeyParamSchema>;
// Update email template request
export const UpdateEmailTemplateSchema = z.object({
subject: z.string().min(1).max(255).optional(),
body: z.string().min(1).optional(),
isActive: z.boolean().optional(),
});
export type UpdateEmailTemplateRequest = z.infer<typeof UpdateEmailTemplateSchema>;
// Preview template request
export const PreviewTemplateSchema = z.object({
subject: z.string().min(1).max(255),
body: z.string().min(1),
variables: z.record(z.string()),
});
export type PreviewTemplateRequest = z.infer<typeof PreviewTemplateSchema>;

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

View File

@@ -0,0 +1,54 @@
/**
* @ai-summary Email service using Resend
* @ai-context Sends transactional emails with error handling
*/
import { Resend } from 'resend';
export class EmailService {
private resend: Resend;
private fromEmail: string;
constructor() {
const apiKey = process.env['RESEND_API_KEY'];
if (!apiKey) {
throw new Error('RESEND_API_KEY is not configured');
}
this.resend = new Resend(apiKey);
this.fromEmail = process.env['FROM_EMAIL'] || 'noreply@motovaultpro.com';
}
/**
* Send an email using Resend
* @param to Recipient email address
* @param subject Email subject line
* @param html Email body (HTML format)
* @returns Promise that resolves when email is sent
*/
async send(to: string, subject: string, html: string): Promise<void> {
try {
await this.resend.emails.send({
from: this.fromEmail,
to,
subject,
html,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to send email: ${errorMessage}`);
}
}
/**
* Send an email with plain text body (converted to HTML)
* @param to Recipient email address
* @param subject Email subject line
* @param text Email body (plain text)
*/
async sendText(to: string, subject: string, text: string): Promise<void> {
// Convert plain text to HTML with proper line breaks
const html = text.split('\n').map(line => `<p>${line}</p>`).join('');
await this.send(to, subject, html);
}
}

View File

@@ -0,0 +1,290 @@
/**
* @ai-summary Notifications service with core business logic
* @ai-context Manages notification summary, due items, and email sending
*/
import { NotificationsRepository } from '../data/notifications.repository';
import { TemplateService } from './template.service';
import { EmailService } from './email.service';
import type {
NotificationSummary,
DueMaintenanceItem,
ExpiringDocument,
EmailTemplate,
TemplateKey
} from './notifications.types';
export class NotificationsService {
private repository: NotificationsRepository;
private templateService: TemplateService;
private emailService: EmailService;
constructor(
repository?: NotificationsRepository,
templateService?: TemplateService,
emailService?: EmailService
) {
this.repository = repository || new NotificationsRepository();
this.templateService = templateService || new TemplateService();
this.emailService = emailService || new EmailService();
}
// ========================
// Summary and Queries
// ========================
async getNotificationSummary(userId: string): Promise<NotificationSummary> {
return this.repository.getNotificationSummary(userId);
}
async getDueMaintenanceItems(userId: string): Promise<DueMaintenanceItem[]> {
return this.repository.getDueMaintenanceItems(userId);
}
async getExpiringDocuments(userId: string): Promise<ExpiringDocument[]> {
return this.repository.getExpiringDocuments(userId);
}
// ========================
// Email Templates
// ========================
async listTemplates(): Promise<EmailTemplate[]> {
return this.repository.getEmailTemplates();
}
async getTemplate(key: TemplateKey): Promise<EmailTemplate | null> {
return this.repository.getEmailTemplateByKey(key);
}
async updateTemplate(
key: TemplateKey,
updates: { subject?: string; body?: string; isActive?: boolean }
): Promise<EmailTemplate | null> {
return this.repository.updateEmailTemplate(key, updates);
}
async previewTemplate(
subject: string,
body: string,
variables: Record<string, string | number | boolean | null | undefined>
): Promise<{ subject: string; body: string }> {
return {
subject: this.templateService.render(subject, variables),
body: this.templateService.render(body, variables),
};
}
// ========================
// Email Sending
// ========================
async sendMaintenanceNotification(
userId: string,
userEmail: string,
userName: string,
item: DueMaintenanceItem
): Promise<void> {
const templateKey: TemplateKey = item.isOverdue
? 'maintenance_overdue'
: 'maintenance_due_soon';
const template = await this.repository.getEmailTemplateByKey(templateKey);
if (!template || !template.isActive) {
throw new Error(`Template ${templateKey} not found or inactive`);
}
const variables = {
userName,
vehicleName: item.vehicleName,
category: item.category,
subtypes: item.subtypes.join(', '),
dueDate: item.nextDueDate || 'N/A',
dueMileage: item.nextDueMileage?.toString() || 'N/A',
};
const subject = this.templateService.render(template.subject, variables);
const body = this.templateService.render(template.body, variables);
try {
await this.emailService.sendText(userEmail, subject, body);
await this.repository.insertNotificationLog({
user_id: userId,
notification_type: 'email',
template_key: templateKey,
recipient_email: userEmail,
subject,
reference_type: 'maintenance_schedule',
reference_id: item.id,
status: 'sent',
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await this.repository.insertNotificationLog({
user_id: userId,
notification_type: 'email',
template_key: templateKey,
recipient_email: userEmail,
subject,
reference_type: 'maintenance_schedule',
reference_id: item.id,
status: 'failed',
error_message: errorMessage,
});
throw error;
}
}
async sendDocumentNotification(
userId: string,
userEmail: string,
userName: string,
document: ExpiringDocument
): Promise<void> {
const templateKey: TemplateKey = document.isExpired
? 'document_expired'
: 'document_expiring';
const template = await this.repository.getEmailTemplateByKey(templateKey);
if (!template || !template.isActive) {
throw new Error(`Template ${templateKey} not found or inactive`);
}
const variables = {
userName,
vehicleName: document.vehicleName,
documentType: document.documentType,
documentTitle: document.title,
expirationDate: document.expirationDate || 'N/A',
};
const subject = this.templateService.render(template.subject, variables);
const body = this.templateService.render(template.body, variables);
try {
await this.emailService.sendText(userEmail, subject, body);
await this.repository.insertNotificationLog({
user_id: userId,
notification_type: 'email',
template_key: templateKey,
recipient_email: userEmail,
subject,
reference_type: 'document',
reference_id: document.id,
status: 'sent',
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await this.repository.insertNotificationLog({
user_id: userId,
notification_type: 'email',
template_key: templateKey,
recipient_email: userEmail,
subject,
reference_type: 'document',
reference_id: document.id,
status: 'failed',
error_message: errorMessage,
});
throw error;
}
}
/**
* Send a test email for a template to verify email configuration
* @param key Template key to test
* @param recipientEmail Email address to send test to
* @param recipientName Name to use in template
* @returns Rendered subject and body that was sent
*/
async sendTestEmail(
key: TemplateKey,
recipientEmail: string,
recipientName: string
): Promise<{ subject: string; body: string; success: boolean; error?: string }> {
const template = await this.repository.getEmailTemplateByKey(key);
if (!template) {
return {
subject: '',
body: '',
success: false,
error: `Template '${key}' not found`
};
}
// Sample variables based on template type
const sampleVariables = this.getSampleVariables(key, recipientName);
const subject = this.templateService.render(template.subject, sampleVariables);
const body = this.templateService.render(template.body, sampleVariables);
try {
await this.emailService.sendText(recipientEmail, `[TEST] ${subject}`, body);
return {
subject,
body,
success: true
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return {
subject,
body,
success: false,
error: errorMessage
};
}
}
/**
* Get sample variables for a template based on its type
*/
private getSampleVariables(key: TemplateKey, userName: string): Record<string, string> {
const baseVariables = { userName };
switch (key) {
case 'maintenance_due_soon':
case 'maintenance_overdue':
return {
...baseVariables,
vehicleName: '2024 Toyota Camry',
category: 'Routine Maintenance',
subtypes: 'Oil Change, Air Filter',
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString(),
dueMileage: '50,000',
};
case 'document_expiring':
case 'document_expired':
return {
...baseVariables,
vehicleName: '2024 Toyota Camry',
documentType: 'Insurance',
documentTitle: 'State Farm Auto Policy',
expirationDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString(),
};
default:
return baseVariables;
}
}
/**
* Process all pending notifications (called by scheduled job)
* This would typically be called by a cron job or scheduler
*/
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');
}
}

View File

@@ -0,0 +1,99 @@
/**
* @ai-summary Type definitions for notifications feature
* @ai-context Email and toast notifications for maintenance and documents
*/
import { z } from 'zod';
// Template key union type
export type TemplateKey =
| 'maintenance_due_soon'
| 'maintenance_overdue'
| 'document_expiring'
| 'document_expired';
// Email template API response type (camelCase for frontend)
export interface EmailTemplate {
id: string;
templateKey: TemplateKey;
name: string;
description?: string;
subject: string;
body: string;
variables: string[];
isActive: boolean;
createdAt: string;
updatedAt: string;
}
// Notification log database type
export interface NotificationLog {
id: string;
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;
sent_at: string;
}
// Summary for login toast (camelCase for frontend compatibility)
export interface NotificationSummary {
maintenanceDueSoon: number;
maintenanceOverdue: number;
documentsExpiringSoon: number;
documentsExpired: number;
}
// Due maintenance item (camelCase for frontend)
export interface DueMaintenanceItem {
id: string;
vehicleId: string;
vehicleName: string;
category: string;
subtypes: string[];
nextDueDate?: string;
nextDueMileage?: number;
isDueSoon: boolean;
isOverdue: boolean;
emailNotifications: boolean;
}
// Expiring document (camelCase for frontend)
export interface ExpiringDocument {
id: string;
vehicleId: string;
vehicleName: string;
documentType: string;
title: string;
expirationDate?: string;
isExpiringSoon: boolean;
isExpired: boolean;
emailNotifications: boolean;
}
// Zod schemas for validation
export const TemplateKeySchema = z.enum([
'maintenance_due_soon',
'maintenance_overdue',
'document_expiring',
'document_expired'
]);
export const UpdateEmailTemplateSchema = z.object({
subject: z.string().min(1).max(255).optional(),
body: z.string().min(1).optional(),
isActive: z.boolean().optional(),
});
export type UpdateEmailTemplateRequest = z.infer<typeof UpdateEmailTemplateSchema>;
export const PreviewTemplateSchema = z.object({
subject: z.string().min(1).max(255),
body: z.string().min(1),
variables: z.record(z.string()),
});
export type PreviewTemplateRequest = z.infer<typeof PreviewTemplateSchema>;

View File

@@ -0,0 +1,43 @@
/**
* @ai-summary Simple template variable substitution service
* @ai-context Replaces {{variableName}} with values
*/
export class TemplateService {
/**
* Render a template string by replacing {{variableName}} with values
* @param template Template string with {{variable}} placeholders
* @param variables Object mapping variable names to values
* @returns Rendered string with variables replaced
*/
render(template: string, variables: Record<string, string | number | boolean | null | undefined>): string {
let result = template;
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
const replacement = value !== null && value !== undefined ? String(value) : '';
result = result.split(placeholder).join(replacement);
}
return result;
}
/**
* Extract variable names from a template string
* @param template Template string with {{variable}} placeholders
* @returns Array of variable names found in template
*/
extractVariables(template: string): string[] {
const regex = /\{\{(\w+)\}\}/g;
const variables: string[] = [];
let match;
while ((match = regex.exec(template)) !== null) {
if (!variables.includes(match[1])) {
variables.push(match[1]);
}
}
return variables;
}
}

View File

@@ -0,0 +1,6 @@
/**
* @ai-summary Notifications feature module export
* @ai-context Exports routes for registration in app.ts
*/
export { notificationsRoutes } from './api/notifications.routes';

View File

@@ -0,0 +1,93 @@
-- email_templates: Admin-editable predefined templates
CREATE TABLE email_templates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
template_key VARCHAR(50) NOT NULL UNIQUE CHECK (template_key IN (
'maintenance_due_soon', 'maintenance_overdue',
'document_expiring', 'document_expired'
)),
name VARCHAR(100) NOT NULL,
description TEXT,
subject VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
variables JSONB DEFAULT '[]'::jsonb,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- notification_logs: Track sent notifications
CREATE TABLE notification_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
notification_type VARCHAR(20) NOT NULL CHECK (notification_type IN ('email', 'toast')),
template_key VARCHAR(50) NOT NULL,
recipient_email VARCHAR(255),
subject VARCHAR(255),
reference_type VARCHAR(50), -- 'maintenance_schedule' or 'document'
reference_id UUID,
status VARCHAR(20) DEFAULT 'sent' CHECK (status IN ('pending', 'sent', 'failed')),
error_message TEXT,
sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX idx_notification_logs_user_id ON notification_logs(user_id);
CREATE INDEX idx_notification_logs_reference ON notification_logs(reference_type, reference_id);
CREATE INDEX idx_notification_logs_sent_at ON notification_logs(sent_at DESC);
-- Seed 4 default templates
INSERT INTO email_templates (template_key, name, description, subject, body, variables) VALUES
('maintenance_due_soon', 'Maintenance Due Soon', 'Sent when maintenance is due within 30 days or 500 miles',
'MotoVaultPro: Maintenance Due Soon for {{vehicleName}}',
'Hi {{userName}},
Your {{category}} maintenance for {{vehicleName}} is due soon.
Due Date: {{dueDate}}
Due Mileage: {{dueMileage}} miles
Items: {{subtypes}}
Best regards,
MotoVaultPro',
'["userName", "vehicleName", "category", "subtypes", "dueDate", "dueMileage"]'),
('maintenance_overdue', 'Maintenance Overdue', 'Sent when maintenance is past due',
'MotoVaultPro: OVERDUE Maintenance for {{vehicleName}}',
'Hi {{userName}},
Your {{category}} maintenance for {{vehicleName}} is OVERDUE.
Was Due: {{dueDate}}
Was Due At: {{dueMileage}} miles
Items: {{subtypes}}
Please schedule service as soon as possible.
Best regards,
MotoVaultPro',
'["userName", "vehicleName", "category", "subtypes", "dueDate", "dueMileage"]'),
('document_expiring', 'Document Expiring Soon', 'Sent when document expires within 30 days',
'MotoVaultPro: {{documentTitle}} Expiring Soon',
'Hi {{userName}},
Your {{documentType}} document "{{documentTitle}}" for {{vehicleName}} is expiring soon.
Expiration Date: {{expirationDate}}
Please renew before expiration.
Best regards,
MotoVaultPro',
'["userName", "vehicleName", "documentType", "documentTitle", "expirationDate"]'),
('document_expired', 'Document Expired', 'Sent when document has expired',
'MotoVaultPro: {{documentTitle}} Has EXPIRED',
'Hi {{userName}},
Your {{documentType}} document "{{documentTitle}}" for {{vehicleName}} has EXPIRED.
Expired On: {{expirationDate}}
Please renew immediately.
Best regards,
MotoVaultPro',
'["userName", "vehicleName", "documentType", "documentTitle", "expirationDate"]');

View File

@@ -0,0 +1,7 @@
ALTER TABLE maintenance_schedules ADD COLUMN email_notifications BOOLEAN DEFAULT false;
ALTER TABLE documents ADD COLUMN email_notifications BOOLEAN DEFAULT false;
CREATE INDEX idx_maintenance_schedules_email_notifications
ON maintenance_schedules(email_notifications) WHERE email_notifications = true AND is_active = true;
CREATE INDEX idx_documents_email_notifications
ON documents(email_notifications) WHERE email_notifications = true AND deleted_at IS NULL;