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:
AI Assistant
2026-03-11 01:12:36 +02:00
parent 6941074106
commit 974d06fff8
17 changed files with 998 additions and 14 deletions
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { runDigest } from "@/core/notifications";
/**
* POST /api/notifications/digest
*
* Server-to-server endpoint called by N8N cron.
* Auth via Authorization: Bearer <NOTIFICATION_CRON_SECRET>
*/
export async function POST(request: Request) {
const secret = process.env.NOTIFICATION_CRON_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "NOTIFICATION_CRON_SECRET not configured" },
{ status: 500 },
);
}
const authHeader = request.headers.get("Authorization");
const token = authHeader?.replace("Bearer ", "");
if (token !== secret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const result = await runDigest();
return NextResponse.json(result, {
status: result.success ? 200 : 500,
});
}
@@ -0,0 +1,109 @@
import { NextResponse } from "next/server";
import { getAuthSession } from "@/core/auth";
import type { CompanyId } from "@/core/auth/types";
import {
getPreference,
savePreference,
defaultPreference,
} from "@/core/notifications";
import type { NotificationType, NotificationPreference } from "@/core/notifications";
const VALID_TYPES: NotificationType[] = [
"deadline-urgent",
"deadline-overdue",
"document-expiry",
];
type SessionUser = {
id?: string;
name?: string | null;
email?: string | null;
company?: string;
};
/**
* GET /api/notifications/preferences
*
* Returns the current user's notification preferences.
* Creates defaults (all enabled) if none exist.
*/
export async function GET() {
const session = await getAuthSession();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const u = session.user as SessionUser;
const id = u.id ?? "unknown";
const email = u.email ?? "";
const name = u.name ?? "";
const company = (u.company ?? "beletage") as CompanyId;
let pref = await getPreference(id);
if (!pref) {
pref = defaultPreference(id, email, name, company);
await savePreference(pref);
}
return NextResponse.json(pref);
}
/**
* PUT /api/notifications/preferences
*
* Update the current user's notification preferences.
* Body: { enabledTypes?: NotificationType[], globalOptOut?: boolean }
*/
export async function PUT(request: Request) {
const session = await getAuthSession();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const u = session.user as SessionUser;
const id = u.id ?? "unknown";
const email = u.email ?? "";
const name = u.name ?? "";
const company = (u.company ?? "beletage") as CompanyId;
const body = (await request.json()) as Partial<
Pick<NotificationPreference, "enabledTypes" | "globalOptOut">
>;
// Validate types
if (body.enabledTypes) {
const invalid = body.enabledTypes.filter(
(t) => !VALID_TYPES.includes(t),
);
if (invalid.length > 0) {
return NextResponse.json(
{ error: `Tipuri invalide: ${invalid.join(", ")}` },
{ status: 400 },
);
}
}
// Load existing or create default
let pref = await getPreference(id);
if (!pref) {
pref = defaultPreference(id, email, name, company);
}
// Update fields
if (body.enabledTypes !== undefined) {
pref.enabledTypes = body.enabledTypes;
}
if (body.globalOptOut !== undefined) {
pref.globalOptOut = body.globalOptOut;
}
// Always refresh identity from session
pref.email = email;
pref.name = name;
pref.company = company;
await savePreference(pref);
return NextResponse.json(pref);
}