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
@@ -0,0 +1,160 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Bell, Loader2 } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/shared/components/ui/dialog';
import { Button } from '@/shared/components/ui/button';
import { Switch } from '@/shared/components/ui/switch';
import { Label } from '@/shared/components/ui/label';
import { useAuth } from '@/core/auth';
import type { NotificationPreference, NotificationType } from '@/core/notifications/types';
import { NOTIFICATION_TYPES } from '@/core/notifications/types';
export function NotificationPreferences() {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const [pref, setPref] = useState<NotificationPreference | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
// Load preferences when dialog opens
const loadPreferences = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/notifications/preferences');
if (res.ok) {
const data = (await res.json()) as NotificationPreference;
setPref(data);
}
} catch {
// Silently fail — will show defaults
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
void loadPreferences();
}
}, [open, loadPreferences]);
// Auto-save on every change
const savePref = useCallback(async (updated: NotificationPreference) => {
setSaving(true);
try {
await fetch('/api/notifications/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabledTypes: updated.enabledTypes,
globalOptOut: updated.globalOptOut,
}),
});
} catch {
// Silently fail
} finally {
setSaving(false);
}
}, []);
const handleGlobalToggle = (checked: boolean) => {
if (!pref) return;
const updated = { ...pref, globalOptOut: !checked };
setPref(updated);
void savePref(updated);
};
const handleTypeToggle = (type: NotificationType, checked: boolean) => {
if (!pref) return;
const enabledTypes = checked
? [...pref.enabledTypes, type]
: pref.enabledTypes.filter((t) => t !== type);
const updated = { ...pref, enabledTypes };
setPref(updated);
void savePref(updated);
};
return (
<>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setOpen(true)}
>
<Bell className="mr-1.5 h-4 w-4" />
Notificari
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Preferinte notificari</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : pref ? (
<div className="space-y-6 py-2">
{/* Email display */}
<div className="text-sm text-muted-foreground">
Email: <span className="font-medium text-foreground">{user?.email ?? pref.email}</span>
</div>
{/* Global toggle */}
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">Primeste notificari zilnice</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Digest email in fiecare dimineata (L-V)
</p>
</div>
<Switch
checked={!pref.globalOptOut}
onCheckedChange={handleGlobalToggle}
/>
</div>
{/* Per-type toggles */}
{!pref.globalOptOut && (
<div className="space-y-3 pl-1 border-l-2 border-muted ml-2">
{NOTIFICATION_TYPES.map((nt) => (
<div key={nt.type} className="flex items-center justify-between pl-4">
<div>
<Label className="text-sm">{nt.label}</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{nt.description}
</p>
</div>
<Switch
checked={pref.enabledTypes.includes(nt.type)}
onCheckedChange={(checked) => handleTypeToggle(nt.type, checked)}
/>
</div>
))}
</div>
)}
{/* Save indicator */}
{saving && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Se salveaza...
</div>
)}
</div>
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
Nu s-au putut incarca preferintele.
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
@@ -2,6 +2,7 @@
import { useState, useMemo, useCallback } from "react";
import { BookOpen, Plus } from "lucide-react";
import { NotificationPreferences } from "./notification-preferences";
import {
Popover,
PopoverContent,
@@ -421,6 +422,7 @@ export function RegistraturaModule() {
</ScrollArea>
</PopoverContent>
</Popover>
<NotificationPreferences />
<Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>