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 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-11 14:42:21 +02:00
parent 1c51236c31
commit d7bd1a7f5d
10 changed files with 1201 additions and 6 deletions
@@ -12,6 +12,7 @@ const VALID_TYPES: NotificationType[] = [
"deadline-urgent",
"deadline-overdue",
"document-expiry",
"status-change",
];
type SessionUser = {
@@ -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,
});
}
@@ -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 });
}
}
+8 -2
View File
@@ -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,
};
}
@@ -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<number | null>(null);
const [copiedPath, setCopiedPath] = useState<string | null>(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({
</DetailSection>
)}
{/* ── External tracking ── */}
{(entry.externalStatusUrl || entry.externalTrackingId) && (
{/* ── External status monitoring ── */}
{entry.externalStatusTracking?.active && (
<ExternalStatusSection
entry={entry}
/>
)}
{/* ── Auto-detect: suggest monitoring activation ── */}
{matchedAuthority && !entry.externalStatusTracking?.active && (
<div className="rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-950/20 p-3">
<div className="flex items-start gap-2">
<Radio className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-xs font-medium">
{matchedAuthority.name} suporta monitorizare automata
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
Se poate verifica automat statusul cererii nr.{" "}
{entry.recipientRegNumber} de 4 ori pe zi.
</p>
<Button
variant="outline"
size="sm"
className="mt-2 h-6 text-xs"
onClick={() => setMonitorConfigOpen(true)}
>
Configureaza monitorizarea
</Button>
</div>
</div>
<StatusMonitorConfig
open={monitorConfigOpen}
onOpenChange={setMonitorConfigOpen}
entry={entry}
authority={matchedAuthority}
onActivate={async (tracking) => {
// 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
}
}}
/>
</div>
)}
{/* ── External tracking (legacy fields) ── */}
{!entry.externalStatusTracking?.active &&
(entry.externalStatusUrl || entry.externalTrackingId) && (
<DetailSection title="Urmărire externă">
<div className="grid grid-cols-2 gap-3">
{entry.externalTrackingId && (
@@ -655,6 +729,174 @@ export function RegistryEntryDetail({
// ── Sub-components ──
// ── External Status Monitoring Section ──
const STATUS_COLORS: Record<ExternalDocStatus, string> = {
"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 (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Monitorizare status extern
</h3>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleManualCheck}
disabled={checking}
>
<RefreshCw className={cn("h-3 w-3 mr-1", checking && "animate-spin")} />
{checking ? "Se verifică..." : "Verifică acum"}
</Button>
</div>
<div className="space-y-2">
{/* Authority + status badge */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">
{authority?.name ?? tracking.authorityId}
</span>
<Badge className={cn("text-[10px] px-1.5 py-0", STATUS_COLORS[tracking.semanticStatus])}>
<Radio className="mr-0.5 inline h-2.5 w-2.5" />
{EXTERNAL_STATUS_LABELS[tracking.semanticStatus]}
</Badge>
</div>
{/* Last check time */}
{tracking.lastCheckAt && (
<p className="text-[10px] text-muted-foreground">
Ultima verificare: {relativeTime(tracking.lastCheckAt)}
</p>
)}
{/* Error state */}
{tracking.lastError && (
<p className="text-[10px] text-red-500">{tracking.lastError}</p>
)}
{/* Latest status row */}
{tracking.lastStatusRow && (
<div className="rounded border bg-muted/30 p-2 text-xs space-y-1">
<div className="flex gap-3">
<span>
<span className="text-muted-foreground">Sursa:</span>{" "}
{tracking.lastStatusRow.sursa}
</span>
<span>
<span className="text-muted-foreground"></span>{" "}
{tracking.lastStatusRow.destinatie}
</span>
</div>
{tracking.lastStatusRow.modRezolvare && (
<div>
<span className="text-muted-foreground">Rezolvare:</span>{" "}
<span className="font-medium">{tracking.lastStatusRow.modRezolvare}</span>
</div>
)}
{tracking.lastStatusRow.comentarii && (
<div className="text-muted-foreground">
{tracking.lastStatusRow.comentarii}
</div>
)}
<div className="text-muted-foreground">
{tracking.lastStatusRow.dataVenire} {tracking.lastStatusRow.oraVenire}
</div>
</div>
)}
{/* History toggle */}
{tracking.history.length > 0 && (
<div>
<button
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowHistory(!showHistory)}
>
{showHistory ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
Istoric ({tracking.history.length} schimbări)
</button>
{showHistory && (
<div className="mt-1 space-y-1.5 max-h-48 overflow-y-auto">
{[...tracking.history].reverse().map((change, i) => (
<div
key={`${change.timestamp}-${i}`}
className="rounded border bg-muted/20 p-1.5 text-[10px]"
>
<div className="flex items-center gap-2">
<Badge
className={cn(
"text-[8px] px-1 py-0",
STATUS_COLORS[change.semanticStatus],
)}
>
{EXTERNAL_STATUS_LABELS[change.semanticStatus]}
</Badge>
<span className="text-muted-foreground">
{new Date(change.timestamp).toLocaleString("ro-RO")}
</span>
</div>
<div className="mt-0.5 text-muted-foreground">
{change.row.sursa} {change.row.destinatie}
{change.row.modRezolvare ? ` (${change.row.modRezolvare})` : ""}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
function DetailSection({
title,
children,
@@ -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}
</Badge>
)}
{entry.externalStatusTracking?.active && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={`text-[10px] px-1 py-0 ${
entry.externalStatusTracking.semanticStatus === "solutionat"
? "border-green-400 text-green-600"
: entry.externalStatusTracking.semanticStatus === "trimis"
? "border-blue-400 text-blue-600"
: entry.externalStatusTracking.semanticStatus === "in-operare"
? "border-amber-400 text-amber-600"
: entry.externalStatusTracking.semanticStatus === "respins"
? "border-red-400 text-red-600"
: "border-muted-foreground"
}`}
>
<Radio className="mr-0.5 inline h-2.5 w-2.5" />
</Badge>
</TooltipTrigger>
<TooltipContent>
Status extern: {EXTERNAL_STATUS_LABELS[entry.externalStatusTracking.semanticStatus]}
</TooltipContent>
</Tooltip>
)}
</span>
</td>
)}
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Radio className="h-4 w-4" />
Monitorizare status extern
</DialogTitle>
<DialogDescription>
{authority.name} suporta verificarea automata a statusului.
Configureaza datele de mai jos pentru a activa monitorizarea.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
<Label htmlFor="petitioner-name">
Nume deponent (cum apare la autoritate)
</Label>
<Input
id="petitioner-name"
value={petitionerName}
onChange={(e) => setPetitionerName(e.target.value)}
placeholder="ex: BELETAGE SRL"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="reg-number">Nr. inregistrare</Label>
<Input
id="reg-number"
value={regNumber}
onChange={(e) => setRegNumber(e.target.value)}
placeholder="ex: 12345"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="reg-date">Data inregistrare (zz.ll.aaaa)</Label>
<Input
id="reg-date"
value={regDate}
onChange={(e) => setRegDate(e.target.value)}
placeholder="ex: 11.03.2026"
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
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.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Anuleaza
</Button>
<Button onClick={handleActivate} disabled={!canActivate}>
Activeaza monitorizarea
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
/** Extract <td> contents from a <tr> string */
function extractCells(trHtml: string): string[] {
const cells: string[] = [];
const tdRegex = /<td[^>]*>([\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 <table> after the anchor
const tableStart = html.indexOf("<table", anchorIdx);
if (tableStart === -1) return [];
const tableEnd = html.indexOf("</table>", tableStart);
if (tableEnd === -1) return [];
const tableHtml = html.substring(tableStart, tableEnd + 8);
// Extract all <tr> rows
const rows: ExternalStatusRow[] = [];
const trRegex = /<tr[^>]*>([\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));
}
@@ -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<StatusCheckResult> {
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<StatusCheckRunResult> {
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<string, RegistryEntry[]>();
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<number> {
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 = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px;">
<h2 style="color: #1a1a1a; margin-bottom: 4px;">Actualizare status extern</h2>
<p style="color: #666; margin-top: 0;">Detectat la ${new Date(change.timestamp).toLocaleString("ro-RO")}</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px; border: 1px solid #ddd; font-weight: bold; background: #f5f5f5;">Nr. registru</td>
<td style="padding: 8px; border: 1px solid #ddd;">${entry.number}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd; font-weight: bold; background: #f5f5f5;">Subiect</td>
<td style="padding: 8px; border: 1px solid #ddd;">${entry.subject}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd; font-weight: bold; background: #f5f5f5;">Autoritate</td>
<td style="padding: 8px; border: 1px solid #ddd;">${authority.name}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd; font-weight: bold; background: #f5f5f5;">Nr. inregistrare</td>
<td style="padding: 8px; border: 1px solid #ddd;">${entry.externalStatusTracking?.regNumber ?? "—"}</td>
</tr>
</table>
<h3 style="color: #1a1a1a; margin-bottom: 8px;">Ultimul status</h3>
<table style="width: 100%; border-collapse: collapse; margin: 8px 0;">
<tr style="background: #f5f5f5;">
<th style="padding: 6px 8px; border: 1px solid #ddd; text-align: left; font-size: 12px;">Sursa</th>
<th style="padding: 6px 8px; border: 1px solid #ddd; text-align: left; font-size: 12px;">Destinatie</th>
<th style="padding: 6px 8px; border: 1px solid #ddd; text-align: left; font-size: 12px;">Mod rezolvare</th>
<th style="padding: 6px 8px; border: 1px solid #ddd; text-align: left; font-size: 12px;">Data</th>
</tr>
<tr>
<td style="padding: 6px 8px; border: 1px solid #ddd;">${row.sursa}</td>
<td style="padding: 6px 8px; border: 1px solid #ddd;">${row.destinatie}</td>
<td style="padding: 6px 8px; border: 1px solid #ddd; font-weight: bold;">${row.modRezolvare || "—"}</td>
<td style="padding: 6px 8px; border: 1px solid #ddd;">${row.dataVenire} ${row.oraVenire}</td>
</tr>
</table>
${row.comentarii ? `<p style="color: #666; font-size: 13px;"><strong>Comentarii:</strong> ${row.comentarii}</p>` : ""}
<p style="color: #999; font-size: 11px; margin-top: 24px; border-top: 1px solid #eee; padding-top: 12px;">
Alerte Termene &mdash; ArchiTools
</p>
</div>
`;
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<StatusCheckResult & { tracking?: ExternalStatusTracking }> {
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 };
}
+67
View File
@@ -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<ExternalDocStatus, string> = {
"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[];