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
@@ -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 };
}