feat: add email notification system (Brevo SMTP + N8N daily digest)
- Add core notification service: types, email-service (nodemailer/Brevo SMTP), notification-service (digest builder, preference CRUD, HTML renderer) - Add API routes: POST /api/notifications/digest (N8N cron, Bearer auth), GET/PUT /api/notifications/preferences (session auth) - Add NotificationPreferences UI component (Bell button + dialog with per-type toggles) in Registratura toolbar - Add 7 Brevo SMTP env vars to docker-compose.yml - Update CLAUDE.md, ROADMAP.md, DATA-MODEL.md, SYSTEM-ARCHITECTURE.md, CONFIGURATION.md, DOCKER-DEPLOYMENT.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import type { Transporter } from "nodemailer";
|
||||
import type { EmailPayload } from "./types";
|
||||
|
||||
// ── Singleton transport (lazy init, same pattern as prisma) ──
|
||||
|
||||
const globalForEmail = globalThis as unknown as {
|
||||
emailTransport: Transporter | undefined;
|
||||
};
|
||||
|
||||
function getTransport(): Transporter {
|
||||
if (globalForEmail.emailTransport) return globalForEmail.emailTransport;
|
||||
|
||||
const host = process.env.BREVO_SMTP_HOST ?? "smtp-relay.brevo.com";
|
||||
const port = parseInt(process.env.BREVO_SMTP_PORT ?? "587", 10);
|
||||
const user = process.env.BREVO_SMTP_USER ?? "";
|
||||
const pass = process.env.BREVO_SMTP_PASS ?? "";
|
||||
|
||||
if (!user || !pass) {
|
||||
throw new Error(
|
||||
"BREVO_SMTP_USER and BREVO_SMTP_PASS must be set for email notifications",
|
||||
);
|
||||
}
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: false, // STARTTLS on port 587
|
||||
auth: { user, pass },
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForEmail.emailTransport = transport;
|
||||
}
|
||||
|
||||
return transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single email via Brevo SMTP relay.
|
||||
*/
|
||||
export async function sendEmail(payload: EmailPayload): Promise<void> {
|
||||
const fromEmail =
|
||||
process.env.NOTIFICATION_FROM_EMAIL ?? "noreply@beletage.ro";
|
||||
const fromName = process.env.NOTIFICATION_FROM_NAME ?? "ArchiTools";
|
||||
|
||||
const transport = getTransport();
|
||||
|
||||
await transport.sendMail({
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
html: payload.html,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type {
|
||||
NotificationType,
|
||||
NotificationTypeInfo,
|
||||
NotificationPreference,
|
||||
DigestSection,
|
||||
DigestItem,
|
||||
DigestResult,
|
||||
EmailPayload,
|
||||
} from "./types";
|
||||
export { NOTIFICATION_TYPES, defaultPreference } from "./types";
|
||||
export { sendEmail } from "./email-service";
|
||||
export {
|
||||
getPreference,
|
||||
savePreference,
|
||||
getAllPreferences,
|
||||
runDigest,
|
||||
} from "./notification-service";
|
||||
@@ -0,0 +1,387 @@
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { CompanyId } from "@/core/auth/types";
|
||||
import type { RegistryEntry } from "@/modules/registratura/types";
|
||||
import {
|
||||
getDeadlineDisplayStatus,
|
||||
} from "@/modules/registratura/services/deadline-service";
|
||||
import { getDeadlineType } from "@/modules/registratura/services/deadline-catalog";
|
||||
import { sendEmail } from "./email-service";
|
||||
import type {
|
||||
NotificationPreference,
|
||||
NotificationType,
|
||||
DigestSection,
|
||||
DigestItem,
|
||||
DigestResult,
|
||||
} from "./types";
|
||||
import { defaultPreference } from "./types";
|
||||
|
||||
const NAMESPACE = "notifications";
|
||||
|
||||
// ── Preference CRUD (KeyValueStore) ──
|
||||
|
||||
export async function getPreference(
|
||||
userId: string,
|
||||
): Promise<NotificationPreference | null> {
|
||||
const row = await prisma.keyValueStore.findUnique({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: `pref:${userId}` } },
|
||||
});
|
||||
if (!row) return null;
|
||||
return row.value as unknown as NotificationPreference;
|
||||
}
|
||||
|
||||
export async function savePreference(
|
||||
pref: NotificationPreference,
|
||||
): Promise<void> {
|
||||
await prisma.keyValueStore.upsert({
|
||||
where: {
|
||||
namespace_key: { namespace: NAMESPACE, key: `pref:${pref.userId}` },
|
||||
},
|
||||
update: { value: pref as unknown as Prisma.InputJsonValue },
|
||||
create: {
|
||||
namespace: NAMESPACE,
|
||||
key: `pref:${pref.userId}`,
|
||||
value: pref as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllPreferences(): Promise<NotificationPreference[]> {
|
||||
const rows = await prisma.keyValueStore.findMany({
|
||||
where: { namespace: NAMESPACE },
|
||||
});
|
||||
return rows
|
||||
.filter((r) => r.key.startsWith("pref:"))
|
||||
.map((r) => r.value as unknown as NotificationPreference);
|
||||
}
|
||||
|
||||
// ── Load registry entries (direct Prisma — avoids N+1) ──
|
||||
|
||||
async function loadAllRegistryEntries(): Promise<RegistryEntry[]> {
|
||||
const rows = await prisma.keyValueStore.findMany({
|
||||
where: { namespace: "registratura" },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
return rows
|
||||
.filter((r) => r.key.startsWith("entry:"))
|
||||
.map((r) => r.value as unknown as RegistryEntry);
|
||||
}
|
||||
|
||||
// ── Build digest for a company ──
|
||||
|
||||
function buildCompanyDigest(
|
||||
entries: RegistryEntry[],
|
||||
company: CompanyId,
|
||||
): DigestSection[] {
|
||||
const companyEntries = entries.filter((e) => e.company === company);
|
||||
const sections: DigestSection[] = [];
|
||||
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
// ── Deadline sections ──
|
||||
const urgentItems: DigestItem[] = [];
|
||||
const overdueItems: DigestItem[] = [];
|
||||
|
||||
for (const entry of companyEntries) {
|
||||
if (entry.status !== "deschis") continue;
|
||||
|
||||
for (const dl of entry.trackedDeadlines ?? []) {
|
||||
if (dl.resolution !== "pending") continue;
|
||||
|
||||
const status = getDeadlineDisplayStatus(dl);
|
||||
const def = getDeadlineType(dl.typeId);
|
||||
const label = def?.label ?? dl.typeId;
|
||||
|
||||
if (status.variant === "yellow" && status.daysRemaining !== null) {
|
||||
urgentItems.push({
|
||||
entryNumber: entry.number,
|
||||
subject: entry.subject,
|
||||
label,
|
||||
dueDate: dl.dueDate,
|
||||
daysRemaining: status.daysRemaining,
|
||||
color: "yellow",
|
||||
});
|
||||
}
|
||||
|
||||
if (status.variant === "red" && status.daysRemaining !== null) {
|
||||
overdueItems.push({
|
||||
entryNumber: entry.number,
|
||||
subject: entry.subject,
|
||||
label,
|
||||
dueDate: dl.dueDate,
|
||||
daysRemaining: status.daysRemaining,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
|
||||
// Tacit approval (overdue but applicable) — also report
|
||||
if (status.variant === "blue" && status.daysRemaining !== null && status.daysRemaining < 0) {
|
||||
overdueItems.push({
|
||||
entryNumber: entry.number,
|
||||
subject: entry.subject,
|
||||
label: `${label} (aprobat tacit)`,
|
||||
dueDate: dl.dueDate,
|
||||
daysRemaining: status.daysRemaining,
|
||||
color: "blue",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (urgentItems.length > 0) {
|
||||
sections.push({
|
||||
type: "deadline-urgent",
|
||||
title: "Termene urgente (5 zile sau mai putin)",
|
||||
items: urgentItems.sort((a, b) => a.daysRemaining - b.daysRemaining),
|
||||
});
|
||||
}
|
||||
|
||||
if (overdueItems.length > 0) {
|
||||
sections.push({
|
||||
type: "deadline-overdue",
|
||||
title: "Termene depasite",
|
||||
items: overdueItems.sort((a, b) => a.daysRemaining - b.daysRemaining),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Document expiry section ──
|
||||
const expiryItems: DigestItem[] = [];
|
||||
|
||||
for (const entry of companyEntries) {
|
||||
if (entry.status !== "deschis" || !entry.expiryDate) continue;
|
||||
|
||||
const expiry = new Date(entry.expiryDate);
|
||||
expiry.setHours(0, 0, 0, 0);
|
||||
const daysLeft = Math.ceil(
|
||||
(expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
const alertDays = entry.expiryAlertDays ?? 30;
|
||||
|
||||
if (daysLeft <= alertDays) {
|
||||
expiryItems.push({
|
||||
entryNumber: entry.number,
|
||||
subject: entry.subject,
|
||||
label: daysLeft < 0 ? "Expirat" : "Expira curand",
|
||||
dueDate: entry.expiryDate,
|
||||
daysRemaining: daysLeft,
|
||||
color: daysLeft < 0 ? "red" : "yellow",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (expiryItems.length > 0) {
|
||||
sections.push({
|
||||
type: "document-expiry",
|
||||
title: "Documente care expira",
|
||||
items: expiryItems.sort((a, b) => a.daysRemaining - b.daysRemaining),
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// ── Render HTML digest ──
|
||||
|
||||
function formatDateRo(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const year = d.getFullYear();
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function renderDigestHtml(
|
||||
sections: DigestSection[],
|
||||
companyName: string,
|
||||
date: string,
|
||||
): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
red: "#ef4444",
|
||||
yellow: "#f59e0b",
|
||||
blue: "#3b82f6",
|
||||
};
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
red: "#fef2f2",
|
||||
yellow: "#fffbeb",
|
||||
blue: "#eff6ff",
|
||||
};
|
||||
|
||||
let sectionsHtml = "";
|
||||
|
||||
for (const section of sections) {
|
||||
let rowsHtml = "";
|
||||
for (const item of section.items) {
|
||||
const daysText =
|
||||
item.daysRemaining < 0
|
||||
? `${Math.abs(item.daysRemaining)} zile depasit`
|
||||
: item.daysRemaining === 0
|
||||
? "Azi"
|
||||
: `${item.daysRemaining} zile ramase`;
|
||||
|
||||
rowsHtml += `
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 8px 12px; font-size: 13px; color: #374151;">${item.entryNumber}</td>
|
||||
<td style="padding: 8px 12px; font-size: 13px; color: #374151; max-width: 250px; overflow: hidden; text-overflow: ellipsis;">${item.subject}</td>
|
||||
<td style="padding: 8px 12px; font-size: 13px; color: #374151;">${item.label}</td>
|
||||
<td style="padding: 8px 12px; font-size: 13px; color: #374151;">${formatDateRo(item.dueDate)}</td>
|
||||
<td style="padding: 8px 12px; font-size: 13px; font-weight: 600; color: ${colorMap[item.color] ?? "#374151"}; background: ${bgMap[item.color] ?? "transparent"}; border-radius: 4px;">${daysText}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
sectionsHtml += `
|
||||
<div style="margin-bottom: 24px;">
|
||||
<h3 style="margin: 0 0 8px 0; font-size: 15px; font-weight: 600; color: #111827;">${section.title} (${section.items.length})</h3>
|
||||
<table style="width: 100%; border-collapse: collapse; border: 1px solid #e5e7eb; border-radius: 6px; overflow: hidden;">
|
||||
<thead>
|
||||
<tr style="background: #f9fafb;">
|
||||
<th style="padding: 8px 12px; font-size: 12px; font-weight: 600; color: #6b7280; text-align: left;">Nr.</th>
|
||||
<th style="padding: 8px 12px; font-size: 12px; font-weight: 600; color: #6b7280; text-align: left;">Subiect</th>
|
||||
<th style="padding: 8px 12px; font-size: 12px; font-weight: 600; color: #6b7280; text-align: left;">Termen</th>
|
||||
<th style="padding: 8px 12px; font-size: 12px; font-weight: 600; color: #6b7280; text-align: left;">Scadent</th>
|
||||
<th style="padding: 8px 12px; font-size: 12px; font-weight: 600; color: #6b7280; text-align: left;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rowsHtml}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8" /></head>
|
||||
<body style="margin: 0; padding: 0; background: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
<div style="max-width: 700px; margin: 0 auto; padding: 24px;">
|
||||
<div style="background: #ffffff; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden;">
|
||||
<!-- Header -->
|
||||
<div style="background: #111827; padding: 20px 24px;">
|
||||
<h1 style="margin: 0; font-size: 18px; font-weight: 600; color: #ffffff;">ArchiTools — Digest zilnic</h1>
|
||||
<p style="margin: 4px 0 0 0; font-size: 13px; color: #9ca3af;">${companyName} · ${formatDateRo(date)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="padding: 24px;">
|
||||
${sectionsHtml}
|
||||
|
||||
<div style="margin-top: 24px; padding-top: 16px; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; font-size: 12px; color: #9ca3af;">
|
||||
Acest email este generat automat de ArchiTools. Poti dezactiva notificarile din
|
||||
<a href="https://tools.beletage.ro/registratura" style="color: #3b82f6; text-decoration: none;">Registratura</a>
|
||||
→ butonul Notificari.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Company labels ──
|
||||
|
||||
const COMPANY_LABELS: Record<CompanyId, string> = {
|
||||
beletage: "Beletage",
|
||||
"urban-switch": "Urban Switch",
|
||||
"studii-de-teren": "Studii de Teren",
|
||||
group: "Grup",
|
||||
};
|
||||
|
||||
// ── Main orchestrator ──
|
||||
|
||||
export async function runDigest(): Promise<DigestResult> {
|
||||
const result: DigestResult = {
|
||||
success: true,
|
||||
totalEmails: 0,
|
||||
errors: [],
|
||||
companySummary: {},
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. Load all entries + all preferences
|
||||
const [entries, preferences] = await Promise.all([
|
||||
loadAllRegistryEntries(),
|
||||
getAllPreferences(),
|
||||
]);
|
||||
|
||||
if (preferences.length === 0) {
|
||||
return { ...result, errors: ["Nu exista preferinte de notificare configurate"] };
|
||||
}
|
||||
|
||||
// 2. Group subscribers by company
|
||||
const subscribersByCompany = new Map<CompanyId, NotificationPreference[]>();
|
||||
|
||||
for (const pref of preferences) {
|
||||
if (pref.globalOptOut) continue;
|
||||
if (pref.enabledTypes.length === 0) continue;
|
||||
|
||||
const existing = subscribersByCompany.get(pref.company) ?? [];
|
||||
existing.push(pref);
|
||||
subscribersByCompany.set(pref.company, existing);
|
||||
}
|
||||
|
||||
// 3. Build digest per company, send per subscriber
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
for (const [company, subscribers] of subscribersByCompany.entries()) {
|
||||
const allSections = buildCompanyDigest(entries, company);
|
||||
|
||||
if (allSections.length === 0) {
|
||||
result.companySummary[company] = { emails: 0, sections: 0 };
|
||||
continue;
|
||||
}
|
||||
|
||||
let emailsSent = 0;
|
||||
|
||||
for (const subscriber of subscribers) {
|
||||
// Filter sections per subscriber's enabled types
|
||||
const userSections = allSections.filter((s) =>
|
||||
subscriber.enabledTypes.includes(s.type),
|
||||
);
|
||||
|
||||
if (userSections.length === 0) continue;
|
||||
|
||||
const totalItems = userSections.reduce(
|
||||
(acc, s) => acc + s.items.length,
|
||||
0,
|
||||
);
|
||||
|
||||
const html = renderDigestHtml(
|
||||
userSections,
|
||||
COMPANY_LABELS[company] ?? company,
|
||||
today,
|
||||
);
|
||||
|
||||
const subject = `[ArchiTools] ${totalItems} alerte — ${COMPANY_LABELS[company] ?? company} (${formatDateRo(today)})`;
|
||||
|
||||
try {
|
||||
await sendEmail({
|
||||
to: subscriber.email,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
emailsSent++;
|
||||
result.totalEmails++;
|
||||
} catch (err) {
|
||||
const msg = `Eroare trimitere email catre ${subscriber.email}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
result.errors.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
result.companySummary[company] = {
|
||||
emails: emailsSent,
|
||||
sections: allSections.length,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
result.success = false;
|
||||
result.errors.push(
|
||||
`Eroare generala digest: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { CompanyId } from "@/core/auth/types";
|
||||
|
||||
// ── Notification types ──
|
||||
|
||||
export type NotificationType =
|
||||
| "deadline-urgent"
|
||||
| "deadline-overdue"
|
||||
| "document-expiry";
|
||||
|
||||
export interface NotificationTypeInfo {
|
||||
type: NotificationType;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** All notification types with Romanian labels */
|
||||
export const NOTIFICATION_TYPES: NotificationTypeInfo[] = [
|
||||
{
|
||||
type: "deadline-urgent",
|
||||
label: "Termene urgente",
|
||||
description: "Termene legale cu 5 sau mai putine zile ramase",
|
||||
},
|
||||
{
|
||||
type: "deadline-overdue",
|
||||
label: "Termene depasite",
|
||||
description: "Termene legale care au depasit data scadenta",
|
||||
},
|
||||
{
|
||||
type: "document-expiry",
|
||||
label: "Documente care expira",
|
||||
description: "CU/AC si alte documente care expira in fereastra de alerta",
|
||||
},
|
||||
];
|
||||
|
||||
// ── User preferences ──
|
||||
|
||||
export interface NotificationPreference {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
company: CompanyId;
|
||||
enabledTypes: NotificationType[];
|
||||
globalOptOut: boolean;
|
||||
}
|
||||
|
||||
/** Default preference — all types enabled, not opted out */
|
||||
export function defaultPreference(
|
||||
userId: string,
|
||||
email: string,
|
||||
name: string,
|
||||
company: CompanyId,
|
||||
): NotificationPreference {
|
||||
return {
|
||||
userId,
|
||||
email,
|
||||
name,
|
||||
company,
|
||||
enabledTypes: ["deadline-urgent", "deadline-overdue", "document-expiry"],
|
||||
globalOptOut: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Digest result types ──
|
||||
|
||||
export interface DigestItem {
|
||||
/** Entry number (e.g., "BTG-0042/2026") */
|
||||
entryNumber: string;
|
||||
/** Entry subject */
|
||||
subject: string;
|
||||
/** Deadline or expiry label */
|
||||
label: string;
|
||||
/** Due date (YYYY-MM-DD) */
|
||||
dueDate: string;
|
||||
/** Days remaining (negative = overdue) */
|
||||
daysRemaining: number;
|
||||
/** Color for HTML rendering */
|
||||
color: "red" | "yellow" | "blue";
|
||||
}
|
||||
|
||||
export interface DigestSection {
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
items: DigestItem[];
|
||||
}
|
||||
|
||||
export interface DigestResult {
|
||||
success: boolean;
|
||||
totalEmails: number;
|
||||
errors: string[];
|
||||
companySummary: Record<string, { emails: number; sections: number }>;
|
||||
}
|
||||
|
||||
// ── Email payload ──
|
||||
|
||||
export interface EmailPayload {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
Reference in New Issue
Block a user