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-urgent",
|
||||||
"deadline-overdue",
|
"deadline-overdue",
|
||||||
"document-expiry",
|
"document-expiry",
|
||||||
|
"status-change",
|
||||||
];
|
];
|
||||||
|
|
||||||
type SessionUser = {
|
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 =
|
export type NotificationType =
|
||||||
| "deadline-urgent"
|
| "deadline-urgent"
|
||||||
| "deadline-overdue"
|
| "deadline-overdue"
|
||||||
| "document-expiry";
|
| "document-expiry"
|
||||||
|
| "status-change";
|
||||||
|
|
||||||
export interface NotificationTypeInfo {
|
export interface NotificationTypeInfo {
|
||||||
type: NotificationType;
|
type: NotificationType;
|
||||||
@@ -30,6 +31,11 @@ export const NOTIFICATION_TYPES: NotificationTypeInfo[] = [
|
|||||||
label: "Documente care expira",
|
label: "Documente care expira",
|
||||||
description: "CU/AC si alte documente care expira in fereastra de alerta",
|
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 ──
|
// ── User preferences ──
|
||||||
@@ -55,7 +61,7 @@ export function defaultPreference(
|
|||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
company,
|
company,
|
||||||
enabledTypes: ["deadline-urgent", "deadline-overdue", "document-expiry"],
|
enabledTypes: ["deadline-urgent", "deadline-overdue", "document-expiry", "status-change"],
|
||||||
globalOptOut: false,
|
globalOptOut: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import {
|
|||||||
X,
|
X,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Reply,
|
Reply,
|
||||||
|
Radio,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
@@ -33,7 +37,9 @@ import {
|
|||||||
SheetDescription,
|
SheetDescription,
|
||||||
} from "@/shared/components/ui/sheet";
|
} from "@/shared/components/ui/sheet";
|
||||||
import type { RegistryEntry } from "../types";
|
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 { getOverdueDays } from "../services/registry-service";
|
||||||
import { pathFileName, shareLabelFor } from "@/config/nas-paths";
|
import { pathFileName, shareLabelFor } from "@/config/nas-paths";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
@@ -42,6 +48,8 @@ import {
|
|||||||
AttachmentPreview,
|
AttachmentPreview,
|
||||||
getPreviewableAttachments,
|
getPreviewableAttachments,
|
||||||
} from "./attachment-preview";
|
} from "./attachment-preview";
|
||||||
|
import { findAuthorityForContact } from "../services/authority-catalog";
|
||||||
|
import { StatusMonitorConfig } from "./status-monitor-config";
|
||||||
|
|
||||||
interface RegistryEntryDetailProps {
|
interface RegistryEntryDetailProps {
|
||||||
entry: RegistryEntry | null;
|
entry: RegistryEntry | null;
|
||||||
@@ -145,6 +153,15 @@ export function RegistryEntryDetail({
|
|||||||
}: RegistryEntryDetailProps) {
|
}: RegistryEntryDetailProps) {
|
||||||
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
|
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
|
||||||
const [copiedPath, setCopiedPath] = useState<string | 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(
|
const previewableAtts = useMemo(
|
||||||
() => (entry ? getPreviewableAttachments(entry.attachments) : []),
|
() => (entry ? getPreviewableAttachments(entry.attachments) : []),
|
||||||
@@ -586,8 +603,65 @@ export function RegistryEntryDetail({
|
|||||||
</DetailSection>
|
</DetailSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── External tracking ── */}
|
{/* ── External status monitoring ── */}
|
||||||
{(entry.externalStatusUrl || entry.externalTrackingId) && (
|
{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ă">
|
<DetailSection title="Urmărire externă">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{entry.externalTrackingId && (
|
{entry.externalTrackingId && (
|
||||||
@@ -655,6 +729,174 @@ export function RegistryEntryDetail({
|
|||||||
|
|
||||||
// ── Sub-components ──
|
// ── 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({
|
function DetailSection({
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ArrowDownLeft,
|
ArrowDownLeft,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
|
Radio,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
@@ -34,7 +35,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/components/ui/dropdown-menu";
|
} from "@/shared/components/ui/dropdown-menu";
|
||||||
import type { RegistryEntry } from "../types";
|
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 { getOverdueDays } from "../services/registry-service";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
@@ -383,6 +384,31 @@ export function RegistryTable({
|
|||||||
{(entry.trackedDeadlines ?? []).length}
|
{(entry.trackedDeadlines ?? []).length}
|
||||||
</Badge>
|
</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>
|
</span>
|
||||||
</td>
|
</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[];
|
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 {
|
export interface RegistryEntry {
|
||||||
id: string;
|
id: string;
|
||||||
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
|
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
|
||||||
@@ -287,6 +352,8 @@ export interface RegistryEntry {
|
|||||||
externalStatusUrl?: string;
|
externalStatusUrl?: string;
|
||||||
/** External tracking ID (e.g., portal reference number) */
|
/** External tracking ID (e.g., portal reference number) */
|
||||||
externalTrackingId?: string;
|
externalTrackingId?: string;
|
||||||
|
/** External authority status monitoring */
|
||||||
|
externalStatusTracking?: ExternalStatusTracking;
|
||||||
/** AC (Autorizație de Construire) validity tracking */
|
/** AC (Autorizație de Construire) validity tracking */
|
||||||
acValidity?: ACValidityTracking;
|
acValidity?: ACValidityTracking;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user