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
+27 -7
View File
@@ -101,7 +101,7 @@ legacy/ # Original HTML tools for reference
| 1 | **Dashboard** | `/` | 0.1.0 | KPI cards (6), activity feed (last 20), module grid, external tools | | 1 | **Dashboard** | `/` | 0.1.0 | KPI cards (6), activity feed (last 20), module grid, external tools |
| 2 | **Email Signature** | `/email-signature` | 0.1.0 | Multi-company branding, address toggle (BTG/US/SDT), live preview, zoom/copy/download | | 2 | **Email Signature** | `/email-signature` | 0.1.0 | Multi-company branding, address toggle (BTG/US/SDT), live preview, zoom/copy/download |
| 3 | **Word XML Generator** | `/word-xml` | 0.1.0 | Category-based XML gen, simple/advanced mode, ZIP export | | 3 | **Word XML Generator** | `/word-xml` | 0.1.0 | Category-based XML gen, simple/advanced mode, ZIP export |
| 4 | **Registratura** | `/registratura` | 0.4.0 | CRUD registry, dynamic doc types, bidirectional Address Book, threads, backdating, **legal deadline tracking**, recipient registration, document expiry, **NAS network path attachments** (A/O/P/T drives, copy-to-clipboard), **detail sheet side panel**, **configurable column visibility**, **QuickLook attachment preview** (images: zoom/pan, PDFs: native viewer, multi-file navigation) | | 4 | **Registratura** | `/registratura` | 0.5.0 | CRUD registry, dynamic doc types, bidirectional Address Book, threads, backdating, **legal deadline tracking** (6 categories, 18 types), recipient registration, document expiry, **NAS network path attachments** (A/O/P/T drives, copy-to-clipboard), **detail sheet side panel**, **configurable column visibility**, **QuickLook attachment preview** (images: zoom/pan, PDFs: native viewer, multi-file navigation), **email notifications** (Brevo SMTP daily digest, per-user preferences) |
| 5 | **Tag Manager** | `/tag-manager` | 0.2.0 | CRUD tags, category/scope/color, US/SDT seeds, mandatory categories, **ManicTime bidirectional sync** | | 5 | **Tag Manager** | `/tag-manager` | 0.2.0 | CRUD tags, category/scope/color, US/SDT seeds, mandatory categories, **ManicTime bidirectional sync** |
| 6 | **IT Inventory** | `/it-inventory` | 0.2.0 | Dynamic equipment types, rented status (purple pulse), **42U rack visualization**, type/status/company filters | | 6 | **IT Inventory** | `/it-inventory` | 0.2.0 | Dynamic equipment types, rented status (purple pulse), **42U rack visualization**, type/status/company filters |
| 7 | **Address Book** | `/address-book` | 0.1.1 | CRUD contacts, card grid, vCard export, Registratura reverse lookup, **dynamic types (creatable)**, **alphabetically sorted type dropdown** | | 7 | **Address Book** | `/address-book` | 0.1.1 | CRUD contacts, card grid, vCard export, Registratura reverse lookup, **dynamic types (creatable)**, **alphabetically sorted type dropdown** |
@@ -119,21 +119,40 @@ legacy/ # Original HTML tools for reference
The Registratura module includes a full legal deadline tracking engine for Romanian construction permitting: The Registratura module includes a full legal deadline tracking engine for Romanian construction permitting:
- **16 deadline types** across 5 categories (Avize, Completări, Analiză, Autorizare, Publicitate) - **18 deadline types** across 6 categories (Certificat, Avize, Completari, Urbanism, Autorizare, Litigii)
- **Working days vs calendar days** with Romanian public holiday support (including Orthodox Easter via Meeus algorithm) - **Working days vs calendar days** with Romanian public holiday support (including Orthodox Easter via Meeus algorithm)
- **Backward deadlines** (e.g., AC extension: 45 working days BEFORE expiry) - **Chain deadlines** (resolving one prompts adding the next — e.g., CU analiza → emitere, PUZ/PUD analiza → post-CTATU → emitere)
- **Chain deadlines** (resolving one prompts adding the next)
- **Tacit approval** (auto-detected when overdue + applicable type) - **Tacit approval** (auto-detected when overdue + applicable type)
- **Tabbed UI**: "Registru" tab (existing registry) + "Termene legale" tab (deadline dashboard) - **Tabbed UI**: "Registru" tab (existing registry) + "Termene legale" tab (deadline dashboard)
- **Email notifications**: daily digest via Brevo SMTP, per-user opt-in/opt-out preferences, N8N cron trigger
Key files: Key files:
- `services/working-days.ts` — Romanian holidays, `addWorkingDays()`, `isWorkingDay()` - `services/working-days.ts` — Romanian holidays, `addWorkingDays()`, `isWorkingDay()`
- `services/deadline-catalog.ts` — 16 `DeadlineTypeDef` entries - `services/deadline-catalog.ts` — 18 `DeadlineTypeDef` entries across 6 categories
- `services/deadline-service.ts``createTrackedDeadline()`, `resolveDeadline()`, `aggregateDeadlines()` - `services/deadline-service.ts``createTrackedDeadline()`, `resolveDeadline()`, `aggregateDeadlines()`
- `components/attachment-preview.tsx` — QuickLook-style fullscreen preview (images: zoom/pan, PDFs: blob URL iframe, multi-file nav) - `components/attachment-preview.tsx` — QuickLook-style fullscreen preview (images: zoom/pan, PDFs: blob URL iframe, multi-file nav)
- `components/deadline-dashboard.tsx` — Stats + filters + table - `components/deadline-dashboard.tsx` — Stats + filters + table
- `components/deadline-add-dialog.tsx` — 3-step wizard (category → type → date preview) - `components/deadline-add-dialog.tsx` — 3-step wizard (category → type → date preview)
- `components/notification-preferences.tsx` — Bell button + dialog with per-type toggles
### Email Notifications (Brevo SMTP)
Platform-level notification service for daily email digests:
- **Brevo SMTP relay** via nodemailer (port 587, STARTTLS)
- **N8N cron**: weekdays 8:00 → POST `/api/notifications/digest` with Bearer token
- **Per-user preferences**: stored in KeyValueStore (`notifications` namespace), toggle global opt-out + 3 notification types
- **Digest content**: urgent deadlines (<=5 days), overdue deadlines, expiring documents (CU/AC)
- **HTML email**: inline-styled table layout, color-coded rows (red/yellow/blue), per-company grouping
Key files:
- `src/core/notifications/types.ts``NotificationType`, `NotificationPreference`, `DigestSection`, `DigestItem`
- `src/core/notifications/email-service.ts` — Nodemailer transport singleton (Brevo SMTP)
- `src/core/notifications/notification-service.ts``runDigest()`, `buildCompanyDigest()`, `renderDigestHtml()`, preference CRUD
- `src/app/api/notifications/digest/route.ts` — POST endpoint (N8N cron, Bearer auth)
- `src/app/api/notifications/preferences/route.ts` — GET/PUT (user session auth)
### ParcelSync — eTerra ANCPI GIS Integration ### ParcelSync — eTerra ANCPI GIS Integration
@@ -176,7 +195,7 @@ Key files:
| **Uptime Kuma** | 3001 | Service monitoring | | **Uptime Kuma** | 3001 | Service monitoring |
| **MinIO** | 9002 (API) / 9003 (UI) | Object storage | | **MinIO** | 9002 (API) / 9003 (UI) | Object storage |
| **Authentik** | 9100 | SSO (auth.beletage.ro) — **active** | | **Authentik** | 9100 | SSO (auth.beletage.ro) — **active** |
| **N8N** | 5678 | Workflow automation (future) | | **N8N** | 5678 | Workflow automation (daily digest cron) |
| **Stirling PDF** | 8087 | PDF tools | | **Stirling PDF** | 8087 | PDF tools |
| **IT-Tools** | 8085 | Developer utilities | | **IT-Tools** | 8085 | Developer utilities |
| **FileBrowser** | 8086 | File management | | **FileBrowser** | 8086 | File management |
@@ -296,7 +315,8 @@ src/modules/<name>/
| **NAS Paths** | ✅ Active | `\\newamun` (10.10.10.10), drives A/O/P/T, hostname+IP fallback, `src/config/nas-paths.ts` | | **NAS Paths** | ✅ Active | `\\newamun` (10.10.10.10), drives A/O/P/T, hostname+IP fallback, `src/config/nas-paths.ts` |
| **eTerra ANCPI** | ✅ Active | ParcelSync module, `eterra-client.ts`, health check + maintenance detection | | **eTerra ANCPI** | ✅ Active | ParcelSync module, `eterra-client.ts`, health check + maintenance detection |
| **PostGIS** | ✅ Active | `GisFeature` model, geometry storage, spatial queries, used by ParcelSync | | **PostGIS** | ✅ Active | `GisFeature` model, geometry storage, spatial queries, used by ParcelSync |
| **N8N automations** | Webhook URL configured | For notifications, backups, workflows | | **Email Notifications** | ✅ Implemented | Brevo SMTP daily digest, `/api/notifications/digest` + `/preferences`, N8N cron trigger |
| **N8N automations** | ✅ Active (digest cron) | Daily digest cron `0 8 * * 1-5`, Bearer token auth, future: backups, workflows |
--- ---
+12 -3
View File
@@ -32,7 +32,7 @@
| # | Module | Version | Status | Remaining Gaps | Future Enhancements | | # | Module | Version | Status | Remaining Gaps | Future Enhancements |
| --- | ------------------ | ------- | --------- | --------------------------------------------------- | ------------------------------------------------- | | --- | ------------------ | ------- | --------- | --------------------------------------------------- | ------------------------------------------------- |
| 1 | Registratura | 0.4.0 | HARDENING | Legal deadline workflow gaps, chain logic | Workflow automation, email integration, OCR | | 1 | Registratura | 0.5.0 | HARDENING | | Workflow automation, OCR, print/PDF export |
| 2 | Email Signature | 0.1.0 | COMPLETE | US/SDT addresses may need update | AD sync, branding packs, promo banners | | 2 | Email Signature | 0.1.0 | COMPLETE | US/SDT addresses may need update | AD sync, branding packs, promo banners |
| 3 | Word XML | 0.1.0 | COMPLETE | — | Schema validator, visual mapper | | 3 | Word XML | 0.1.0 | COMPLETE | — | Schema validator, visual mapper |
| 4 | Digital Signatures | 0.1.0 | COMPLETE | — | Permission layers, document insertion | | 4 | Digital Signatures | 0.1.0 | COMPLETE | — | Permission layers, document insertion |
@@ -826,9 +826,18 @@ Env vars (hardcoded in docker-compose.yml for Portainer CE):
--- ---
### 8.03 `[STANDARD]` Notification System ### 8.03 `[STANDARD]` Notification System (2026-03-11)
**What:** Bell icon in header. Deadline alerts, overdue warnings, tacit approval triggers. **What:** Email notification system with daily digest via Brevo SMTP + N8N cron.
**Implemented:**
- Brevo SMTP relay (nodemailer, port 587 STARTTLS)
- Daily digest email: urgent deadlines, overdue deadlines, expiring documents
- Per-user notification preferences (3 types + global opt-out) stored in KeyValueStore
- API routes: POST `/api/notifications/digest` (N8N Bearer auth), GET/PUT `/api/notifications/preferences` (session auth)
- UI: Bell button "Notificari" in Registratura toolbar → dialog with toggles
- HTML email: inline-styled tables, color-coded rows (red/yellow/blue), per-company grouping
- N8N cron: `0 8 * * 1-5` (weekdays 8:00)
**Files:** `src/core/notifications/`, `src/app/api/notifications/`, `components/notification-preferences.tsx`
--- ---
+8
View File
@@ -46,6 +46,14 @@ services:
- ETERRA_PASSWORD=${ETERRA_PASSWORD:-} - ETERRA_PASSWORD=${ETERRA_PASSWORD:-}
# DWG-to-DXF sidecar # DWG-to-DXF sidecar
- DWG2DXF_URL=http://dwg2dxf:5001 - DWG2DXF_URL=http://dwg2dxf:5001
# Email notifications (Brevo SMTP)
- BREVO_SMTP_HOST=smtp-relay.brevo.com
- BREVO_SMTP_PORT=587
- BREVO_SMTP_USER=${BREVO_SMTP_USER:-}
- BREVO_SMTP_PASS=${BREVO_SMTP_PASS:-}
- NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
- NOTIFICATION_FROM_NAME=ArchiTools
- NOTIFICATION_CRON_SECRET=${NOTIFICATION_CRON_SECRET:-}
depends_on: depends_on:
dwg2dxf: dwg2dxf:
condition: service_healthy condition: service_healthy
+34
View File
@@ -443,6 +443,40 @@ interface WordTemplate extends BaseEntity {
} }
``` ```
### Email Notifications (platform service)
```typescript
// src/core/notifications/types.ts
type NotificationType = "deadline-urgent" | "deadline-overdue" | "document-expiry";
interface NotificationPreference {
userId: string;
email: string;
name: string;
company: CompanyId;
enabledTypes: NotificationType[];
globalOptOut: boolean;
}
interface DigestItem {
entryNumber: string;
subject: string;
label: string;
dueDate: string; // YYYY-MM-DD
daysRemaining: number; // negative = overdue
color: "red" | "yellow" | "blue";
}
interface DigestSection {
type: NotificationType;
title: string;
items: DigestItem[];
}
```
> **Storage:** Preferences stored in `KeyValueStore` (namespace `notifications`, key `pref:<userId>`). No separate Prisma model needed.
--- ---
## Naming Conventions ## Naming Conventions
+6 -3
View File
@@ -430,7 +430,8 @@ ArchiTools runs alongside existing services on the internal network:
|---------|-----------------|---------| |---------|-----------------|---------|
| **Authentik** | Future SSO provider | User authentication and role assignment | | **Authentik** | Future SSO provider | User authentication and role assignment |
| **MinIO** | Future storage adapter | Object/file storage for documents, signatures, templates | | **MinIO** | Future storage adapter | Object/file storage for documents, signatures, templates |
| **N8N** | Future webhook/API | Workflow automation (document processing, notifications) | | **N8N** | ✅ Active (cron) | Daily digest cron (`0 8 * * 1-5`), future: backups, workflows |
| **Brevo SMTP** | ✅ Active | Email relay for notification digests (port 587, STARTTLS) |
| **Gitea** | Development | Source code hosting | | **Gitea** | Development | Source code hosting |
| **Stirling PDF** | Dashboard link | PDF manipulation (external tool link) | | **Stirling PDF** | Dashboard link | PDF manipulation (external tool link) |
| **IT-Tools** | Dashboard link | Technical utilities (external tool link) | | **IT-Tools** | Dashboard link | Technical utilities (external tool link) |
@@ -446,9 +447,11 @@ ArchiTools runs alongside existing services on the internal network:
**Storage integration (MinIO):** When the MinIO adapter is implemented, modules that manage files (Digital Signatures, Word Templates) will store binary assets in MinIO buckets while keeping metadata in the primary storage. **Storage integration (MinIO):** When the MinIO adapter is implemented, modules that manage files (Digital Signatures, Word Templates) will store binary assets in MinIO buckets while keeping metadata in the primary storage.
**Automation integration (N8N):** Modules can trigger N8N webhooks for automated workflows. Example: Registratura creates a new entry, triggering an N8N workflow that sends a notification or generates a document. **Automation integration (N8N):** N8N triggers scheduled workflows via API endpoints. Active: daily digest cron calls `POST /api/notifications/digest` with Bearer token auth. Future: document processing, backups.
**SSO integration (Authentik):** The auth stub will be replaced with an Authentik OIDC client. The middleware layer will validate tokens and populate `AuthContext`. No module code changes required. **Email notifications (Brevo SMTP):** Platform service in `src/core/notifications/`. Nodemailer transport singleton connects to Brevo SMTP relay. `runDigest()` loads all registry entries, groups by company, builds digest per subscriber filtering by their preference types (urgent, overdue, expiry), renders inline-styled HTML, sends via SMTP. Preferences stored in KeyValueStore (namespace `notifications`).
**SSO integration (Authentik):** Authentik OIDC provides user identity. NextAuth v4 JWT/session callbacks map Authentik groups to roles and companies. Notification preferences auto-refresh user email/name/company from session on each save.
--- ---
+12
View File
@@ -57,6 +57,18 @@ NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
# Example: NEXT_PUBLIC_FLAGS_OVERRIDE=module_ai_chat=true,module_password_vault=false # Example: NEXT_PUBLIC_FLAGS_OVERRIDE=module_ai_chat=true,module_password_vault=false
NEXT_PUBLIC_FLAGS_OVERRIDE= NEXT_PUBLIC_FLAGS_OVERRIDE=
# -----------------------------------------------------------------------------
# Email Notifications (Brevo SMTP)
# -----------------------------------------------------------------------------
# SMTP relay for daily digest emails (deadline alerts, document expiry)
BREVO_SMTP_HOST=smtp-relay.brevo.com
BREVO_SMTP_PORT=587
BREVO_SMTP_USER= # Brevo SMTP login (from Brevo dashboard)
BREVO_SMTP_PASS= # Brevo SMTP key (from Brevo dashboard)
NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
NOTIFICATION_FROM_NAME=ArchiTools
NOTIFICATION_CRON_SECRET= # Random Bearer token for N8N → digest API auth
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# External Services # External Services
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+14 -1
View File
@@ -200,13 +200,26 @@ NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
# MINIO_BUCKET=architools # MINIO_BUCKET=architools
# ────────────────────────────────────────── # ──────────────────────────────────────────
# Authentication (future: Authentik SSO) # Authentication (Authentik SSO)
# ────────────────────────────────────────── # ──────────────────────────────────────────
# AUTHENTIK_ISSUER=https://auth.internal # AUTHENTIK_ISSUER=https://auth.internal
# AUTHENTIK_CLIENT_ID=architools # AUTHENTIK_CLIENT_ID=architools
# AUTHENTIK_CLIENT_SECRET=<secret> # AUTHENTIK_CLIENT_SECRET=<secret>
# ──────────────────────────────────────────
# Email Notifications (Brevo SMTP)
# ──────────────────────────────────────────
BREVO_SMTP_HOST=smtp-relay.brevo.com
BREVO_SMTP_PORT=587
BREVO_SMTP_USER=<brevo-login>
BREVO_SMTP_PASS=<brevo-smtp-key>
NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
NOTIFICATION_FROM_NAME=ArchiTools
NOTIFICATION_CRON_SECRET=<random-bearer-token>
``` ```
> **N8N cron setup:** Create a workflow with Cron node (`0 8 * * 1-5`), HTTP Request node (POST `https://tools.beletage.ro/api/notifications/digest`, header `Authorization: Bearer <NOTIFICATION_CRON_SECRET>`). The endpoint returns `{ success, totalEmails, errors, companySummary }`.
### Variable Scoping Rules ### Variable Scoping Rules
| Prefix | Available In | Notes | | Prefix | Available In | Notes |
+22
View File
@@ -21,6 +21,7 @@
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^7.0.13",
"proj4": "^2.20.3", "proj4": "^2.20.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@@ -36,6 +37,7 @@
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/jszip": "^3.4.0", "@types/jszip": "^3.4.0",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^7.0.11",
"@types/proj4": "^2.5.6", "@types/proj4": "^2.5.6",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19", "@types/react": "^19",
@@ -4034,6 +4036,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pako": { "node_modules/@types/pako": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
@@ -9943,6 +9955,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"peer": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/npm-run-path": { "node_modules/npm-run-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
+2
View File
@@ -22,6 +22,7 @@
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^7.0.13",
"proj4": "^2.20.3", "proj4": "^2.20.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@@ -37,6 +38,7 @@
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/jszip": "^3.4.0", "@types/jszip": "^3.4.0",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^7.0.11",
"@types/proj4": "^2.5.6", "@types/proj4": "^2.5.6",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19", "@types/react": "^19",
+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);
}
+55
View File
@@ -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,
});
}
+17
View File
@@ -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} &middot; ${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>
&rarr; 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;
}
+99
View File
@@ -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;
}
@@ -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 { useState, useMemo, useCallback } from "react";
import { BookOpen, Plus } from "lucide-react"; import { BookOpen, Plus } from "lucide-react";
import { NotificationPreferences } from "./notification-preferences";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -421,6 +422,7 @@ export function RegistraturaModule() {
</ScrollArea> </ScrollArea>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<NotificationPreferences />
<Button onClick={() => setViewMode("add")} className="shrink-0"> <Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă <Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button> </Button>