diff --git a/CLAUDE.md b/CLAUDE.md index d64fe8d..af2d12b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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// | **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 | --- diff --git a/ROADMAP.md b/ROADMAP.md index 272e9e6..bedb514 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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` --- diff --git a/docker-compose.yml b/docker-compose.yml index d31f2b2..588ce1a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 218c81a..d9ee4ba 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -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:`). No separate Prisma model needed. + --- ## Naming Conventions diff --git a/docs/architecture/SYSTEM-ARCHITECTURE.md b/docs/architecture/SYSTEM-ARCHITECTURE.md index bfb71d6..c7e8224 100644 --- a/docs/architecture/SYSTEM-ARCHITECTURE.md +++ b/docs/architecture/SYSTEM-ARCHITECTURE.md @@ -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. --- diff --git a/docs/guides/CONFIGURATION.md b/docs/guides/CONFIGURATION.md index 144cac9..fb53809 100644 --- a/docs/guides/CONFIGURATION.md +++ b/docs/guides/CONFIGURATION.md @@ -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 # ----------------------------------------------------------------------------- diff --git a/docs/guides/DOCKER-DEPLOYMENT.md b/docs/guides/DOCKER-DEPLOYMENT.md index 1df65e3..d05ee03 100644 --- a/docs/guides/DOCKER-DEPLOYMENT.md +++ b/docs/guides/DOCKER-DEPLOYMENT.md @@ -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= + +# ────────────────────────────────────────── +# Email Notifications (Brevo SMTP) +# ────────────────────────────────────────── +BREVO_SMTP_HOST=smtp-relay.brevo.com +BREVO_SMTP_PORT=587 +BREVO_SMTP_USER= +BREVO_SMTP_PASS= +NOTIFICATION_FROM_EMAIL=noreply@beletage.ro +NOTIFICATION_FROM_NAME=ArchiTools +NOTIFICATION_CRON_SECRET= ``` +> **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 `). The endpoint returns `{ success, totalEmails, errors, companySummary }`. + ### Variable Scoping Rules | Prefix | Available In | Notes | diff --git a/package-lock.json b/package-lock.json index 820e4b5..1f2b14c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3511ec7..77fe63c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/notifications/digest/route.ts b/src/app/api/notifications/digest/route.ts new file mode 100644 index 0000000..0140ac0 --- /dev/null +++ b/src/app/api/notifications/digest/route.ts @@ -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 + */ +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, + }); +} diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts new file mode 100644 index 0000000..435b6f0 --- /dev/null +++ b/src/app/api/notifications/preferences/route.ts @@ -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 + >; + + // 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); +} diff --git a/src/core/notifications/email-service.ts b/src/core/notifications/email-service.ts new file mode 100644 index 0000000..2c2f8b6 --- /dev/null +++ b/src/core/notifications/email-service.ts @@ -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 { + 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, + }); +} diff --git a/src/core/notifications/index.ts b/src/core/notifications/index.ts new file mode 100644 index 0000000..4509cf0 --- /dev/null +++ b/src/core/notifications/index.ts @@ -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"; diff --git a/src/core/notifications/notification-service.ts b/src/core/notifications/notification-service.ts new file mode 100644 index 0000000..c0428a2 --- /dev/null +++ b/src/core/notifications/notification-service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 = { + red: "#ef4444", + yellow: "#f59e0b", + blue: "#3b82f6", + }; + + const bgMap: Record = { + 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 += ` + + ${item.entryNumber} + ${item.subject} + ${item.label} + ${formatDateRo(item.dueDate)} + ${daysText} + `; + } + + sectionsHtml += ` +
+

${section.title} (${section.items.length})

+ + + + + + + + + + + + ${rowsHtml} + +
Nr.SubiectTermenScadentStatus
+
`; + } + + return ` + + + +
+
+ +
+

ArchiTools — Digest zilnic

+

${companyName} · ${formatDateRo(date)}

+
+ + +
+ ${sectionsHtml} + +
+

+ Acest email este generat automat de ArchiTools. Poti dezactiva notificarile din + Registratura + → butonul Notificari. +

+
+
+
+
+ +`; +} + +// ── Company labels ── + +const COMPANY_LABELS: Record = { + beletage: "Beletage", + "urban-switch": "Urban Switch", + "studii-de-teren": "Studii de Teren", + group: "Grup", +}; + +// ── Main orchestrator ── + +export async function runDigest(): Promise { + 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(); + + 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; +} diff --git a/src/core/notifications/types.ts b/src/core/notifications/types.ts new file mode 100644 index 0000000..8606318 --- /dev/null +++ b/src/core/notifications/types.ts @@ -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; +} + +// ── Email payload ── + +export interface EmailPayload { + to: string; + subject: string; + html: string; +} diff --git a/src/modules/registratura/components/notification-preferences.tsx b/src/modules/registratura/components/notification-preferences.tsx new file mode 100644 index 0000000..9f5f9a5 --- /dev/null +++ b/src/modules/registratura/components/notification-preferences.tsx @@ -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(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 ( + <> + + + + + + Preferinte notificari + + + {loading ? ( +
+ +
+ ) : pref ? ( +
+ {/* Email display */} +
+ Email: {user?.email ?? pref.email} +
+ + {/* Global toggle */} +
+
+ +

+ Digest email in fiecare dimineata (L-V) +

+
+ +
+ + {/* Per-type toggles */} + {!pref.globalOptOut && ( +
+ {NOTIFICATION_TYPES.map((nt) => ( +
+
+ +

+ {nt.description} +

+
+ handleTypeToggle(nt.type, checked)} + /> +
+ ))} +
+ )} + + {/* Save indicator */} + {saving && ( +
+ + Se salveaza... +
+ )} +
+ ) : ( +
+ Nu s-au putut incarca preferintele. +
+ )} +
+
+ + ); +} diff --git a/src/modules/registratura/components/registratura-module.tsx b/src/modules/registratura/components/registratura-module.tsx index 39034b0..8a9f7aa 100644 --- a/src/modules/registratura/components/registratura-module.tsx +++ b/src/modules/registratura/components/registratura-module.tsx @@ -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() { +