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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user