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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user