From d7bd1a7f5dfd5e0a919e010e35eba3dd326ac9b2 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Wed, 11 Mar 2026 14:42:21 +0200 Subject: [PATCH] feat: external status monitor for registratura (Primaria Cluj-Napoca) - Add ExternalStatusTracking types + ExternalDocStatus semantic states - Authority catalog with Primaria Cluj-Napoca (POST scraper + HTML parser) - Status check service: batch + single entry, change detection via hash - API routes: cron-triggered batch (/api/registratura/status-check) + user-triggered single (/api/registratura/status-check/single) - Add "status-change" notification type with instant email on change - Table badge: Radio icon color-coded by status (amber/blue/green/red) - Detail panel: full monitoring section with status, history, manual check - Auto-detection: prompt when recipient matches known authority - Activation dialog: configure petitioner name + confirm registration data Co-Authored-By: Claude Opus 4.6 --- .../api/notifications/preferences/route.ts | 1 + .../api/registratura/status-check/route.ts | 38 ++ .../registratura/status-check/single/route.ts | 34 ++ src/core/notifications/types.ts | 10 +- .../components/registry-entry-detail.tsx | 248 +++++++++- .../components/registry-table.tsx | 28 +- .../components/status-monitor-config.tsx | 161 +++++++ .../services/authority-catalog.ts | 166 +++++++ .../services/status-check-service.ts | 454 ++++++++++++++++++ src/modules/registratura/types.ts | 67 +++ 10 files changed, 1201 insertions(+), 6 deletions(-) create mode 100644 src/app/api/registratura/status-check/route.ts create mode 100644 src/app/api/registratura/status-check/single/route.ts create mode 100644 src/modules/registratura/components/status-monitor-config.tsx create mode 100644 src/modules/registratura/services/authority-catalog.ts create mode 100644 src/modules/registratura/services/status-check-service.ts diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 435b6f0..74972ee 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -12,6 +12,7 @@ const VALID_TYPES: NotificationType[] = [ "deadline-urgent", "deadline-overdue", "document-expiry", + "status-change", ]; type SessionUser = { diff --git a/src/app/api/registratura/status-check/route.ts b/src/app/api/registratura/status-check/route.ts new file mode 100644 index 0000000..8829b55 --- /dev/null +++ b/src/app/api/registratura/status-check/route.ts @@ -0,0 +1,38 @@ +/** + * External Status Check — Cron-triggered batch endpoint. + * + * N8N cron: POST with Bearer token, 4x/day (9, 12, 15, 17) weekdays. + * ?test=true for dry-run (check but don't save/email). + */ + +import { NextResponse } from "next/server"; +import { runStatusCheck } from "@/modules/registratura/services/status-check-service"; + +const CRON_SECRET = + process.env.STATUS_CHECK_CRON_SECRET ?? + process.env.NOTIFICATION_CRON_SECRET; + +export async function POST(request: Request) { + if (!CRON_SECRET) { + return NextResponse.json( + { error: "STATUS_CHECK_CRON_SECRET not configured" }, + { status: 500 }, + ); + } + + const authHeader = request.headers.get("Authorization"); + const token = authHeader?.replace("Bearer ", ""); + + if (token !== CRON_SECRET) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(request.url); + const dryRun = url.searchParams.get("test") === "true"; + + const result = await runStatusCheck(dryRun); + + return NextResponse.json(result, { + status: result.success ? 200 : 500, + }); +} diff --git a/src/app/api/registratura/status-check/single/route.ts b/src/app/api/registratura/status-check/single/route.ts new file mode 100644 index 0000000..bbe996e --- /dev/null +++ b/src/app/api/registratura/status-check/single/route.ts @@ -0,0 +1,34 @@ +/** + * Single Entry Status Check — User-triggered from UI. + * + * POST with session auth, body: { entryId: string } + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getAuthSession } from "@/core/auth"; +import { checkSingleEntry } from "@/modules/registratura/services/status-check-service"; + +export async function POST(req: NextRequest) { + const session = await getAuthSession(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await req.json(); + const { entryId } = body as { entryId: string }; + + if (!entryId) { + return NextResponse.json( + { error: "Missing entryId" }, + { status: 400 }, + ); + } + + const result = await checkSingleEntry(entryId); + return NextResponse.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/core/notifications/types.ts b/src/core/notifications/types.ts index 8606318..d144809 100644 --- a/src/core/notifications/types.ts +++ b/src/core/notifications/types.ts @@ -5,7 +5,8 @@ import type { CompanyId } from "@/core/auth/types"; export type NotificationType = | "deadline-urgent" | "deadline-overdue" - | "document-expiry"; + | "document-expiry" + | "status-change"; export interface NotificationTypeInfo { type: NotificationType; @@ -30,6 +31,11 @@ export const NOTIFICATION_TYPES: NotificationTypeInfo[] = [ label: "Documente care expira", description: "CU/AC si alte documente care expira in fereastra de alerta", }, + { + type: "status-change", + label: "Schimbare status extern", + description: "Notificare imediata cand o autoritate actualizeaza statusul documentului", + }, ]; // ── User preferences ── @@ -55,7 +61,7 @@ export function defaultPreference( email, name, company, - enabledTypes: ["deadline-urgent", "deadline-overdue", "document-expiry"], + enabledTypes: ["deadline-urgent", "deadline-overdue", "document-expiry", "status-change"], globalOptOut: false, }; } diff --git a/src/modules/registratura/components/registry-entry-detail.tsx b/src/modules/registratura/components/registry-entry-detail.tsx index c130535..78be0ff 100644 --- a/src/modules/registratura/components/registry-entry-detail.tsx +++ b/src/modules/registratura/components/registry-entry-detail.tsx @@ -20,6 +20,10 @@ import { X, Image as ImageIcon, Reply, + Radio, + RefreshCw, + ChevronDown, + ChevronUp, } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { Badge } from "@/shared/components/ui/badge"; @@ -33,7 +37,9 @@ import { SheetDescription, } from "@/shared/components/ui/sheet"; import type { RegistryEntry } from "../types"; -import { DEFAULT_DOC_TYPE_LABELS } from "../types"; +import { DEFAULT_DOC_TYPE_LABELS, EXTERNAL_STATUS_LABELS } from "../types"; +import type { ExternalDocStatus } from "../types"; +import { getAuthority } from "../services/authority-catalog"; import { getOverdueDays } from "../services/registry-service"; import { pathFileName, shareLabelFor } from "@/config/nas-paths"; import { cn } from "@/shared/lib/utils"; @@ -42,6 +48,8 @@ import { AttachmentPreview, getPreviewableAttachments, } from "./attachment-preview"; +import { findAuthorityForContact } from "../services/authority-catalog"; +import { StatusMonitorConfig } from "./status-monitor-config"; interface RegistryEntryDetailProps { entry: RegistryEntry | null; @@ -145,6 +153,15 @@ export function RegistryEntryDetail({ }: RegistryEntryDetailProps) { const [previewIndex, setPreviewIndex] = useState(null); const [copiedPath, setCopiedPath] = useState(null); + const [monitorConfigOpen, setMonitorConfigOpen] = useState(false); + + // Auto-detect if recipient matches a known authority + const matchedAuthority = useMemo(() => { + if (!entry) return undefined; + if (entry.externalStatusTracking?.active) return undefined; + if (!entry.recipientRegNumber) return undefined; + return findAuthorityForContact(entry.recipient); + }, [entry]); const previewableAtts = useMemo( () => (entry ? getPreviewableAttachments(entry.attachments) : []), @@ -586,8 +603,65 @@ export function RegistryEntryDetail({ )} - {/* ── External tracking ── */} - {(entry.externalStatusUrl || entry.externalTrackingId) && ( + {/* ── External status monitoring ── */} + {entry.externalStatusTracking?.active && ( + + )} + + {/* ── Auto-detect: suggest monitoring activation ── */} + {matchedAuthority && !entry.externalStatusTracking?.active && ( +
+
+ +
+

+ {matchedAuthority.name} suporta monitorizare automata +

+

+ Se poate verifica automat statusul cererii nr.{" "} + {entry.recipientRegNumber} de 4 ori pe zi. +

+ +
+
+ + { + // Save tracking to entry via API + try { + await fetch("/api/registratura", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: entry.id, + updates: { externalStatusTracking: tracking }, + }), + }); + window.location.reload(); + } catch { + // Best effort + } + }} + /> +
+ )} + + {/* ── External tracking (legacy fields) ── */} + {!entry.externalStatusTracking?.active && + (entry.externalStatusUrl || entry.externalTrackingId) && (
{entry.externalTrackingId && ( @@ -655,6 +729,174 @@ export function RegistryEntryDetail({ // ── Sub-components ── +// ── External Status Monitoring Section ── + +const STATUS_COLORS: Record = { + "in-operare": "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", + trimis: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400", + solutionat: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", + respins: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", + necunoscut: "bg-muted text-muted-foreground", +}; + +function ExternalStatusSection({ entry }: { entry: RegistryEntry }) { + const tracking = entry.externalStatusTracking; + if (!tracking) return null; + + const [checking, setChecking] = useState(false); + const [showHistory, setShowHistory] = useState(false); + const authority = getAuthority(tracking.authorityId); + + const handleManualCheck = useCallback(async () => { + setChecking(true); + try { + await fetch("/api/registratura/status-check/single", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entryId: entry.id }), + }); + // Reload page to show updated status + window.location.reload(); + } catch { + // Ignore — user will see if it worked on reload + } finally { + setChecking(false); + } + }, [entry.id]); + + const relativeTime = (iso: string) => { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "chiar acum"; + if (mins < 60) return `acum ${mins} min`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `acum ${hrs}h`; + const days = Math.floor(hrs / 24); + return `acum ${days}z`; + }; + + return ( +
+
+

+ Monitorizare status extern +

+ +
+ +
+ {/* Authority + status badge */} +
+ + {authority?.name ?? tracking.authorityId} + + + + {EXTERNAL_STATUS_LABELS[tracking.semanticStatus]} + +
+ + {/* Last check time */} + {tracking.lastCheckAt && ( +

+ Ultima verificare: {relativeTime(tracking.lastCheckAt)} +

+ )} + + {/* Error state */} + {tracking.lastError && ( +

{tracking.lastError}

+ )} + + {/* Latest status row */} + {tracking.lastStatusRow && ( +
+
+ + Sursa:{" "} + {tracking.lastStatusRow.sursa} + + + {" "} + {tracking.lastStatusRow.destinatie} + +
+ {tracking.lastStatusRow.modRezolvare && ( +
+ Rezolvare:{" "} + {tracking.lastStatusRow.modRezolvare} +
+ )} + {tracking.lastStatusRow.comentarii && ( +
+ {tracking.lastStatusRow.comentarii} +
+ )} +
+ {tracking.lastStatusRow.dataVenire} {tracking.lastStatusRow.oraVenire} +
+
+ )} + + {/* History toggle */} + {tracking.history.length > 0 && ( +
+ + + {showHistory && ( +
+ {[...tracking.history].reverse().map((change, i) => ( +
+
+ + {EXTERNAL_STATUS_LABELS[change.semanticStatus]} + + + {new Date(change.timestamp).toLocaleString("ro-RO")} + +
+
+ {change.row.sursa} → {change.row.destinatie} + {change.row.modRezolvare ? ` (${change.row.modRezolvare})` : ""} +
+
+ ))} +
+ )} +
+ )} +
+
+ ); +} + function DetailSection({ title, children, diff --git a/src/modules/registratura/components/registry-table.tsx b/src/modules/registratura/components/registry-table.tsx index bdcf4bc..47a36d7 100644 --- a/src/modules/registratura/components/registry-table.tsx +++ b/src/modules/registratura/components/registry-table.tsx @@ -16,6 +16,7 @@ import { Check, ArrowDownLeft, ArrowUpRight, + Radio, } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { Badge } from "@/shared/components/ui/badge"; @@ -34,7 +35,7 @@ import { DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu"; import type { RegistryEntry } from "../types"; -import { DEFAULT_DOC_TYPE_LABELS } from "../types"; +import { DEFAULT_DOC_TYPE_LABELS, EXTERNAL_STATUS_LABELS } from "../types"; import { getOverdueDays } from "../services/registry-service"; import { cn } from "@/shared/lib/utils"; @@ -383,6 +384,31 @@ export function RegistryTable({ {(entry.trackedDeadlines ?? []).length} )} + {entry.externalStatusTracking?.active && ( + + + + + + + + Status extern: {EXTERNAL_STATUS_LABELS[entry.externalStatusTracking.semanticStatus]} + + + )} )} diff --git a/src/modules/registratura/components/status-monitor-config.tsx b/src/modules/registratura/components/status-monitor-config.tsx new file mode 100644 index 0000000..285b009 --- /dev/null +++ b/src/modules/registratura/components/status-monitor-config.tsx @@ -0,0 +1,161 @@ +"use client"; + +/** + * Status Monitor Config — Dialog for activating external status monitoring. + * + * Shows when a recipient matches a known authority and registration number + * is filled. Lets user confirm/set petitioner name and activates monitoring. + */ + +import { useState, useEffect } from "react"; +import { Radio } from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog"; +import { Input } from "@/shared/components/ui/input"; +import { Label } from "@/shared/components/ui/label"; +import type { RegistryEntry, ExternalStatusTracking } from "../types"; +import type { AuthorityConfig } from "../services/authority-catalog"; + +interface StatusMonitorConfigProps { + open: boolean; + onOpenChange: (open: boolean) => void; + entry: RegistryEntry; + authority: AuthorityConfig; + onActivate: (tracking: ExternalStatusTracking) => void; +} + +export function StatusMonitorConfig({ + open, + onOpenChange, + entry, + authority, + onActivate, +}: StatusMonitorConfigProps) { + const [petitionerName, setPetitionerName] = useState(""); + const [regNumber, setRegNumber] = useState( + entry.recipientRegNumber ?? "", + ); + const [regDate, setRegDate] = useState(""); + + // Convert YYYY-MM-DD to dd.mm.yyyy + useEffect(() => { + if (entry.recipientRegDate) { + const parts = entry.recipientRegDate.split("-"); + if (parts.length === 3) { + setRegDate(`${parts[2]}.${parts[1]}.${parts[0]}`); + } + } + }, [entry.recipientRegDate]); + + // Load saved petitioner name from localStorage + useEffect(() => { + const saved = localStorage.getItem( + `status-monitor-petitioner:${authority.id}`, + ); + if (saved) setPetitionerName(saved); + }, [authority.id]); + + const canActivate = + petitionerName.trim().length >= 3 && + regNumber.trim().length > 0 && + regDate.trim().length > 0; + + const handleActivate = () => { + // Save petitioner name for future use + localStorage.setItem( + `status-monitor-petitioner:${authority.id}`, + petitionerName.trim(), + ); + + const tracking: ExternalStatusTracking = { + authorityId: authority.id, + petitionerName: petitionerName.trim(), + regNumber: regNumber.trim(), + regDate: regDate.trim(), + lastCheckAt: null, + lastStatusRow: null, + statusHash: "", + semanticStatus: "necunoscut", + history: [], + active: true, + lastError: null, + }; + + onActivate(tracking); + onOpenChange(false); + }; + + return ( + + + + + + Monitorizare status extern + + + {authority.name} suporta verificarea automata a statusului. + Configureaza datele de mai jos pentru a activa monitorizarea. + + + +
+
+ + setPetitionerName(e.target.value)} + placeholder="ex: BELETAGE SRL" + /> +
+ +
+
+ + setRegNumber(e.target.value)} + placeholder="ex: 12345" + /> +
+
+ + setRegDate(e.target.value)} + placeholder="ex: 11.03.2026" + /> +
+
+ +

+ Verificarea se face automat de 4 ori pe zi (9:00, 12:00, 15:00, + 17:00) in zilele lucratoare. Poti verifica manual oricand din + panoul de detalii. +

+
+ + + + + +
+
+ ); +} diff --git a/src/modules/registratura/services/authority-catalog.ts b/src/modules/registratura/services/authority-catalog.ts new file mode 100644 index 0000000..a2b63b0 --- /dev/null +++ b/src/modules/registratura/services/authority-catalog.ts @@ -0,0 +1,166 @@ +/** + * Authority Catalog — Known institutions with online status checking portals. + * + * Each authority defines: endpoint, payload builder, HTML response parser, + * and status derivation logic. Start with hardcoded entries, design for + * future DB storage linked to Address Book contacts. + */ + +import type { ExternalStatusRow, ExternalDocStatus } from "../types"; + +// ── Authority config interface ── + +export interface AuthorityConfig { + /** Unique ID (e.g., "primaria-cluj") */ + id: string; + /** Display name */ + name: string; + /** POST endpoint URL */ + endpoint: string; + /** Content-Type for POST */ + contentType: string; + /** Whether petitioner name is required */ + requiresPetitionerName: boolean; + /** Build the POST body from entry data */ + buildPayload: (params: { + regNumber: string; + petitionerName: string; + regDate: string; // dd.mm.yyyy + }) => string; + /** Parse HTML response into status rows */ + parseResponse: (html: string) => ExternalStatusRow[]; + /** Derive semantic status from the last row */ + deriveStatus: (row: ExternalStatusRow) => ExternalDocStatus; + /** Match a contact name to this authority */ + matchesContact: (contactName: string) => boolean; +} + +// ── HTML parsing helpers ── + +/** Strip HTML tags and normalize whitespace */ +function stripTags(html: string): string { + return html + .replace(/<[^>]*>/g, "") + .replace(/ /gi, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** Extract contents from a string */ +function extractCells(trHtml: string): string[] { + const cells: string[] = []; + const tdRegex = /]*>([\s\S]*?)<\/td>/gi; + let match = tdRegex.exec(trHtml); + while (match) { + cells.push(stripTags(match[1] ?? "")); + match = tdRegex.exec(trHtml); + } + return cells; +} + +// ── Primaria Cluj-Napoca ── + +function parseClujResponse(html: string): ExternalStatusRow[] { + // Find the table after "Starea documentului" caption/heading + const anchorIdx = html.indexOf("Starea documentului"); + if (anchorIdx === -1) return []; + + // Find the first after the anchor + const tableStart = html.indexOf("", tableStart); + if (tableEnd === -1) return []; + + const tableHtml = html.substring(tableStart, tableEnd + 8); + + // Extract all rows + const rows: ExternalStatusRow[] = []; + const trRegex = /]*>([\s\S]*?)<\/tr>/gi; + let isFirst = true; + let match = trRegex.exec(tableHtml); + + while (match) { + // Skip header row + if (isFirst) { + isFirst = false; + match = trRegex.exec(tableHtml); + continue; + } + + const cells = extractCells(match[1] ?? ""); + if (cells.length >= 8) { + rows.push({ + dataVenire: cells[0] ?? "", + oraVenire: cells[1] ?? "", + sursa: cells[2] ?? "", + dataTrimitere: cells[3] ?? "", + oraTrimitere: cells[4] ?? "", + destinatie: cells[5] ?? "", + modRezolvare: cells[6] ?? "", + comentarii: cells[7] ?? "", + }); + } + + match = trRegex.exec(tableHtml); + } + + return rows; +} + +function deriveClujStatus(row: ExternalStatusRow): ExternalDocStatus { + const text = `${row.modRezolvare} ${row.comentarii}`.toLowerCase(); + + if (/solu[tț]ionat|rezolvat|aprobat/.test(text)) return "solutionat"; + if (/respins/.test(text)) return "respins"; + if (/trimis/.test(text)) return "trimis"; + if (/[iî]n\s*operare/.test(text)) return "in-operare"; + + // If destinatie is set but no resolution, it's been forwarded + if (row.destinatie && !row.modRezolvare) return "trimis"; + + return "necunoscut"; +} + +const PRIMARIA_CLUJ: AuthorityConfig = { + id: "primaria-cluj", + name: "Primaria Municipiului Cluj-Napoca", + endpoint: "https://e-primariaclujnapoca.ro/registratura/nrinreg.php", + contentType: "application/x-www-form-urlencoded", + requiresPetitionerName: true, + + buildPayload({ regNumber, petitionerName, regDate }) { + const params = new URLSearchParams(); + params.set("Nr", regNumber); + params.set("Nume", petitionerName); + params.set("Data", regDate); + params.set("btn_cauta", "Caută cerere"); + return params.toString(); + }, + + parseResponse: parseClujResponse, + deriveStatus: deriveClujStatus, + + matchesContact(contactName: string): boolean { + const lower = contactName.toLowerCase(); + return ( + (lower.includes("primaria") || lower.includes("primăria")) && + lower.includes("cluj") + ); + }, +}; + +// ── Catalog ── + +export const AUTHORITY_CATALOG: AuthorityConfig[] = [PRIMARIA_CLUJ]; + +/** Get authority config by ID */ +export function getAuthority(id: string): AuthorityConfig | undefined { + return AUTHORITY_CATALOG.find((a) => a.id === id); +} + +/** Find matching authority for a contact name */ +export function findAuthorityForContact( + contactName: string, +): AuthorityConfig | undefined { + return AUTHORITY_CATALOG.find((a) => a.matchesContact(contactName)); +} diff --git a/src/modules/registratura/services/status-check-service.ts b/src/modules/registratura/services/status-check-service.ts new file mode 100644 index 0000000..9cb16c8 --- /dev/null +++ b/src/modules/registratura/services/status-check-service.ts @@ -0,0 +1,454 @@ +/** + * External Status Check Service + * + * Fetches document status from external authority portals, detects changes, + * updates entries, and sends instant email notifications. + */ + +import type { Prisma } from "@prisma/client"; +import type { + ExternalStatusRow, + ExternalStatusChange, + ExternalStatusTracking, + ExternalDocStatus, +} from "../types"; +import type { RegistryEntry } from "../types"; +import { getAuthority } from "./authority-catalog"; +import type { AuthorityConfig } from "./authority-catalog"; + +// ── Hash helper ── + +/** Simple deterministic hash for change detection */ +export function computeStatusHash(row: ExternalStatusRow): string { + const str = [ + row.dataVenire, + row.oraVenire, + row.sursa, + row.dataTrimitere, + row.oraTrimitere, + row.destinatie, + row.modRezolvare, + row.comentarii, + ].join("|"); + + // djb2 hash + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; + } + return Math.abs(hash).toString(36); +} + +// ── Single entry check ── + +export interface StatusCheckResult { + entryId: string; + entryNumber: string; + changed: boolean; + newRow: ExternalStatusRow | null; + newStatus: ExternalDocStatus; + error: string | null; +} + +/** Check a single entry's status at its authority portal */ +export async function checkExternalStatus( + entry: RegistryEntry, + authority: AuthorityConfig, +): Promise { + const tracking = entry.externalStatusTracking; + if (!tracking) { + return { + entryId: entry.id, + entryNumber: entry.number, + changed: false, + newRow: null, + newStatus: "necunoscut", + error: "No tracking configured", + }; + } + + try { + const payload = authority.buildPayload({ + regNumber: tracking.regNumber, + petitionerName: tracking.petitionerName, + regDate: tracking.regDate, + }); + + const res = await fetch(authority.endpoint, { + method: "POST", + headers: { + "Content-Type": authority.contentType, + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + body: payload, + signal: AbortSignal.timeout(15_000), + }); + + if (!res.ok) { + return { + entryId: entry.id, + entryNumber: entry.number, + changed: false, + newRow: null, + newStatus: tracking.semanticStatus, + error: `HTTP ${res.status}: ${res.statusText}`, + }; + } + + const html = await res.text(); + const rows = authority.parseResponse(html); + + if (rows.length === 0) { + return { + entryId: entry.id, + entryNumber: entry.number, + changed: false, + newRow: null, + newStatus: tracking.semanticStatus, + error: "Nu s-au gasit date de status in raspunsul autoritatii", + }; + } + + // Take the last row (most recent status) + const lastRow = rows[rows.length - 1]!; + const newHash = computeStatusHash(lastRow); + const newStatus = authority.deriveStatus(lastRow); + const changed = newHash !== tracking.statusHash; + + return { + entryId: entry.id, + entryNumber: entry.number, + changed, + newRow: lastRow, + newStatus, + error: null, + }; + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return { + entryId: entry.id, + entryNumber: entry.number, + changed: false, + newRow: null, + newStatus: tracking.semanticStatus, + error: message, + }; + } +} + +// ── Batch orchestrator ── + +export interface StatusCheckRunResult { + success: boolean; + checked: number; + changed: number; + emailsSent: number; + errors: string[]; +} + +/** Run status checks for all active entries */ +export async function runStatusCheck( + dryRun = false, +): Promise { + const { prisma } = await import("@/core/storage/prisma"); + + const result: StatusCheckRunResult = { + success: true, + checked: 0, + changed: 0, + emailsSent: 0, + errors: [], + }; + + // 1. Load all entries + const rows = await prisma.keyValueStore.findMany({ + where: { namespace: "registratura" }, + select: { key: true, value: true }, + }); + + const entries = rows + .filter((r) => r.key.startsWith("entry:")) + .map((r) => r.value as unknown as RegistryEntry) + .filter((e) => e.externalStatusTracking?.active); + + if (entries.length === 0) { + return { ...result, errors: ["Nu exista entries cu monitorizare activa"] }; + } + + // 2. Group by authority for rate limiting + const byAuthority = new Map(); + for (const entry of entries) { + const authId = entry.externalStatusTracking!.authorityId; + const group = byAuthority.get(authId) ?? []; + group.push(entry); + byAuthority.set(authId, group); + } + + // 3. Load notification subscribers + const { getAllPreferences } = await import( + "@/core/notifications/notification-service" + ); + const preferences = await getAllPreferences(); + const statusSubscribers = preferences.filter( + (p) => + !p.globalOptOut && + p.enabledTypes.includes("status-change" as import("@/core/notifications/types").NotificationType), + ); + + // 4. Check each authority group + for (const [authorityId, groupEntries] of byAuthority.entries()) { + const authority = getAuthority(authorityId); + if (!authority) { + result.errors.push(`Autoritate necunoscuta: ${authorityId}`); + continue; + } + + for (const entry of groupEntries) { + result.checked++; + + const checkResult = await checkExternalStatus(entry, authority); + + // Update tracking state + const tracking = entry.externalStatusTracking!; + tracking.lastCheckAt = new Date().toISOString(); + tracking.lastError = checkResult.error; + + if (checkResult.changed && checkResult.newRow) { + result.changed++; + const newHash = computeStatusHash(checkResult.newRow); + + const change: ExternalStatusChange = { + timestamp: new Date().toISOString(), + row: checkResult.newRow, + semanticStatus: checkResult.newStatus, + notified: false, + }; + + tracking.lastStatusRow = checkResult.newRow; + tracking.statusHash = newHash; + tracking.semanticStatus = checkResult.newStatus; + + // Cap history at 50 + tracking.history.push(change); + if (tracking.history.length > 50) { + tracking.history = tracking.history.slice(-50); + } + + if (!dryRun) { + // Save updated entry + await prisma.keyValueStore.update({ + where: { + namespace_key: { + namespace: "registratura", + key: `entry:${entry.id}`, + }, + }, + data: { + value: entry as unknown as Prisma.InputJsonValue, + }, + }); + + // Send instant email notification + const emailCount = await sendStatusChangeEmails( + entry, + change, + authority, + statusSubscribers, + ); + result.emailsSent += emailCount; + change.notified = emailCount > 0; + } + } else if (!dryRun && checkResult.error !== tracking.lastError) { + // Save error state update even if no status change + await prisma.keyValueStore.update({ + where: { + namespace_key: { + namespace: "registratura", + key: `entry:${entry.id}`, + }, + }, + data: { + value: entry as unknown as Prisma.InputJsonValue, + }, + }); + } + + // Rate limit: 500ms between requests to same authority + if (groupEntries.indexOf(entry) < groupEntries.length - 1) { + await new Promise((r) => setTimeout(r, 500)); + } + } + } + + return result; +} + +// ── Email notification ── + +async function sendStatusChangeEmails( + entry: RegistryEntry, + change: ExternalStatusChange, + authority: AuthorityConfig, + subscribers: Array<{ email: string; name: string; company: import("@/core/auth/types").CompanyId }>, +): Promise { + const { sendEmail } = await import("@/core/notifications/email-service"); + const { EXTERNAL_STATUS_LABELS } = await import("../types"); + + // Filter subscribers to same company as entry + const relevant = subscribers.filter((s) => s.company === entry.company); + let sent = 0; + + const statusLabel = + EXTERNAL_STATUS_LABELS[change.semanticStatus] ?? change.semanticStatus; + const row = change.row; + + const subject = `Status actualizat: ${entry.number} — ${statusLabel}`; + const html = ` +
+

Actualizare status extern

+

Detectat la ${new Date(change.timestamp).toLocaleString("ro-RO")}

+ +
+ + + + + + + + + + + + + + + + +
Nr. registru${entry.number}
Subiect${entry.subject}
Autoritate${authority.name}
Nr. inregistrare${entry.externalStatusTracking?.regNumber ?? "—"}
+ +

Ultimul status

+ + + + + + + + + + + + + +
SursaDestinatieMod rezolvareData
${row.sursa}${row.destinatie}${row.modRezolvare || "—"}${row.dataVenire} ${row.oraVenire}
+ + ${row.comentarii ? `

Comentarii: ${row.comentarii}

` : ""} + +

+ Alerte Termene — ArchiTools +

+
+ `; + + for (const subscriber of relevant) { + try { + await sendEmail({ to: subscriber.email, subject, html }); + sent++; + } catch (err) { + // Don't fail entire job on single email failure + console.error( + `Failed to send status email to ${subscriber.email}:`, + err, + ); + } + } + + return sent; +} + +// ── Single entry check (for UI-triggered manual check) ── + +export async function checkSingleEntry( + entryId: string, +): Promise { + const { prisma } = await import("@/core/storage/prisma"); + + const row = await prisma.keyValueStore.findUnique({ + where: { + namespace_key: { namespace: "registratura", key: `entry:${entryId}` }, + }, + }); + + if (!row) { + return { + entryId, + entryNumber: "", + changed: false, + newRow: null, + newStatus: "necunoscut", + error: "Entry not found", + }; + } + + const entry = row.value as unknown as RegistryEntry; + const tracking = entry.externalStatusTracking; + + if (!tracking?.active) { + return { + entryId, + entryNumber: entry.number, + changed: false, + newRow: null, + newStatus: "necunoscut", + error: "Monitorizarea nu este activa", + }; + } + + const authority = getAuthority(tracking.authorityId); + if (!authority) { + return { + entryId, + entryNumber: entry.number, + changed: false, + newRow: null, + newStatus: tracking.semanticStatus, + error: `Autoritate necunoscuta: ${tracking.authorityId}`, + }; + } + + const result = await checkExternalStatus(entry, authority); + + // Update tracking state + tracking.lastCheckAt = new Date().toISOString(); + tracking.lastError = result.error; + + if (result.changed && result.newRow) { + const newHash = computeStatusHash(result.newRow); + const change: ExternalStatusChange = { + timestamp: new Date().toISOString(), + row: result.newRow, + semanticStatus: result.newStatus, + notified: false, + }; + + tracking.lastStatusRow = result.newRow; + tracking.statusHash = newHash; + tracking.semanticStatus = result.newStatus; + tracking.history.push(change); + if (tracking.history.length > 50) { + tracking.history = tracking.history.slice(-50); + } + } + + // Save updated entry + await prisma.keyValueStore.update({ + where: { + namespace_key: { namespace: "registratura", key: `entry:${entryId}` }, + }, + data: { value: entry as unknown as Prisma.InputJsonValue }, + }); + + return { ...result, tracking }; +} diff --git a/src/modules/registratura/types.ts b/src/modules/registratura/types.ts index 62ccd35..f3354b7 100644 --- a/src/modules/registratura/types.ts +++ b/src/modules/registratura/types.ts @@ -241,6 +241,71 @@ export interface ACValidityTracking { notes: string[]; } +// ── External status monitoring types ── + +/** Semantic status of a document at an external authority */ +export type ExternalDocStatus = + | "in-operare" + | "trimis" + | "solutionat" + | "respins" + | "necunoscut"; + +/** Labels for external doc statuses (Romanian UI) */ +export const EXTERNAL_STATUS_LABELS: Record = { + "in-operare": "In operare", + trimis: "Trimis", + solutionat: "Solutionat", + respins: "Respins", + necunoscut: "Necunoscut", +}; + +/** A single row from the authority's status tracking table */ +export interface ExternalStatusRow { + dataVenire: string; + oraVenire: string; + sursa: string; + dataTrimitere: string; + oraTrimitere: string; + destinatie: string; + modRezolvare: string; + comentarii: string; +} + +/** A recorded change in external status */ +export interface ExternalStatusChange { + timestamp: string; + row: ExternalStatusRow; + semanticStatus: ExternalDocStatus; + notified: boolean; +} + +/** Full tracking state for an entry's external status monitoring */ +export interface ExternalStatusTracking { + /** Authority config ID (e.g., "primaria-cluj") */ + authorityId: string; + /** Petitioner name used for authority lookup */ + petitionerName: string; + /** Registration number at the authority */ + regNumber: string; + /** Registration date at the authority (dd.mm.yyyy for POST) */ + regDate: string; + /** ISO timestamp of last check */ + lastCheckAt: string | null; + /** Last known status row */ + lastStatusRow: ExternalStatusRow | null; + /** Hash of last status row for change detection */ + statusHash: string; + /** Derived semantic status */ + semanticStatus: ExternalDocStatus; + /** History of detected status changes (max 50) */ + history: ExternalStatusChange[]; + /** Whether monitoring is active */ + active: boolean; + /** Last error message (null if OK) */ + lastError: string | null; +} + export interface RegistryEntry { id: string; /** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */ @@ -287,6 +352,8 @@ export interface RegistryEntry { externalStatusUrl?: string; /** External tracking ID (e.g., portal reference number) */ externalTrackingId?: string; + /** External authority status monitoring */ + externalStatusTracking?: ExternalStatusTracking; /** AC (Autorizație de Construire) validity tracking */ acValidity?: ACValidityTracking; tags: string[];