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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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(/ /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 — 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 };
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user