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:
@@ -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 |
|
||||
| 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 |
|
||||
| 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** |
|
||||
| 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** |
|
||||
@@ -119,21 +119,40 @@ legacy/ # Original HTML tools for reference
|
||||
|
||||
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)
|
||||
- **Backward deadlines** (e.g., AC extension: 45 working days BEFORE expiry)
|
||||
- **Chain deadlines** (resolving one prompts adding the next)
|
||||
- **Chain deadlines** (resolving one prompts adding the next — e.g., CU analiza → emitere, PUZ/PUD analiza → post-CTATU → emitere)
|
||||
- **Tacit approval** (auto-detected when overdue + applicable type)
|
||||
- **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:
|
||||
|
||||
- `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()`
|
||||
- `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-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
|
||||
|
||||
@@ -176,7 +195,7 @@ Key files:
|
||||
| **Uptime Kuma** | 3001 | Service monitoring |
|
||||
| **MinIO** | 9002 (API) / 9003 (UI) | Object storage |
|
||||
| **Authentik** | 9100 | SSO (auth.beletage.ro) — **active** |
|
||||
| **N8N** | 5678 | Workflow automation (future) |
|
||||
| **N8N** | 5678 | Workflow automation (daily digest cron) |
|
||||
| **Stirling PDF** | 8087 | PDF tools |
|
||||
| **IT-Tools** | 8085 | Developer utilities |
|
||||
| **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` |
|
||||
| **eTerra ANCPI** | ✅ Active | ParcelSync module, `eterra-client.ts`, health check + maintenance detection |
|
||||
| **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
@@ -32,7 +32,7 @@
|
||||
|
||||
| # | 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 |
|
||||
| 3 | Word XML | 0.1.0 | COMPLETE | — | Schema validator, visual mapper |
|
||||
| 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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ services:
|
||||
- ETERRA_PASSWORD=${ETERRA_PASSWORD:-}
|
||||
# DWG-to-DXF sidecar
|
||||
- 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:
|
||||
dwg2dxf:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -430,7 +430,8 @@ ArchiTools runs alongside existing services on the internal network:
|
||||
|---------|-----------------|---------|
|
||||
| **Authentik** | Future SSO provider | User authentication and role assignment |
|
||||
| **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 |
|
||||
| **Stirling PDF** | Dashboard link | PDF manipulation (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.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -57,6 +57,18 @@ NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
|
||||
# Example: NEXT_PUBLIC_FLAGS_OVERRIDE=module_ai_chat=true,module_password_vault=false
|
||||
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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -200,13 +200,26 @@ NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
|
||||
# MINIO_BUCKET=architools
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# Authentication (future: Authentik SSO)
|
||||
# Authentication (Authentik SSO)
|
||||
# ──────────────────────────────────────────
|
||||
# AUTHENTIK_ISSUER=https://auth.internal
|
||||
# AUTHENTIK_CLIENT_ID=architools
|
||||
# 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
|
||||
|
||||
| Prefix | Available In | Notes |
|
||||
|
||||
Generated
+22
@@ -21,6 +21,7 @@
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.13",
|
||||
"proj4": "^2.20.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -36,6 +37,7 @@
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/proj4": "^2.5.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
@@ -4034,6 +4036,16 @@
|
||||
"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": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
@@ -9943,6 +9955,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.13",
|
||||
"proj4": "^2.20.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -37,6 +38,7 @@
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/proj4": "^2.5.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user