Notification updates
This commit is contained in:
121
backend/src/features/notifications/README.md
Normal file
121
backend/src/features/notifications/README.md
Normal 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)
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
54
backend/src/features/notifications/domain/email.service.ts
Normal file
54
backend/src/features/notifications/domain/email.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
6
backend/src/features/notifications/index.ts
Normal file
6
backend/src/features/notifications/index.ts
Normal 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';
|
||||
@@ -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"]');
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user