Ctrl K
ComunidadGuías…

src/core/state/notification-outbox.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { randomUUID } from 'node:crypto';
import type { Job } from '../contracts/types';
import { NOTIFICATION_KINDS, NOTIFICATION_STATUSES, type NotificationEvent, type NotificationKind, type NotificationSnapshot, type NotificationStatus } from '../contracts/notification-outbox';

export function copyNotification<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
export function terminalJob(job: Job): boolean { return ['done', 'error', 'rejected'].includes(job.status); }
export function completionMatches(current: Job | null, original: Job): boolean {
  return !!current && !terminalJob(current) && current.job_id === original.job_id && current.request_id === original.request_id
    && current.status === original.status && (current.claim_token ?? null) === (original.claim_token ?? null)
    && (current.executor_id ?? null) === (original.executor_id ?? null)
    && (current.claimed_at ?? null) === (original.claimed_at ?? null);
}
export function newNotification(eventId: string, kind: NotificationKind, payload: NotificationEvent['payload'], now: string,
  recovery: NotificationEvent['recovery'] = 'retry'): NotificationEvent {
  return { event_id: eventId, kind, job_id: payload.job.job_id, request_id: payload.job.request_id,
    payload: copyNotification(payload), status: 'pending', attempts: 0, cycle_attempts: 0, recovery,
    available_at: now, created_at: now, updated_at: now };
}
/** Called only in the store's atomic nonterminal -> terminal write, never during recovery scans. */
export function terminalNotifications(previous: Job, next: Job, context?: { brandName?: string }): NotificationEvent[] {
  if (terminalJob(previous) || !terminalJob(next)) return [];
  const prefix = `job:${randomUUID()}`;
  const events: NotificationEvent[] = [];
  const add = (kind: NotificationKind, recovery: NotificationEvent['recovery'] = 'retry') =>
    events.push(newNotification(`${prefix}:${kind}`, kind, { job: next, brand_name: context?.brandName ?? 'BailingHub' }, next.updated_at, recovery));
  if (next.callback_url) add('callback');
  const deliveryType = String(next.dispatch?.delivery?.['type'] ?? '');
  const native = next.source.startsWith('wecom:') || deliveryType === 'channel';
  if ((next.status === 'done' || next.status === 'error') && next.source !== 'delivery' && !next.metadata?.['no_delivery']
    && (deliveryType || next.source.startsWith('wecom:'))) add('delivery', native ? 'uncertain' : 'retry');
  if (next.status === 'error' && (next.source === 'delivery' || next.target?.endsWith('-notify'))) add('delivery-dlq');
  return events;
}
// Only codes produced by Core itself may cross the persisted status/UI boundary.
// Sources: the outbox worker and lease recovery, delivery/approval binding checks,
// and postSignedNotificationWithDeps. Provider error text is never an error code.
const SAFE_NOTIFICATION_ERROR_CODES = new Set([
  'notification_delivery_failed',
  'notification_delivery_uncertain',
  'notification_attempt_limit',
  'notification_child_binding_conflict',
  'approval_notification_binding_conflict',
  'webhook_acknowledgement_unavailable',
]);

export function safeNotificationError(value: string | undefined): string | undefined {
  if (value === undefined) return undefined;
  return SAFE_NOTIFICATION_ERROR_CODES.has(value) || (value.length === 16 && /^webhook_http_[1-5][0-9]{2}$/.test(value))
    ? value : 'notification_delivery_failed';
}
export function notificationLimit(value: number | undefined): number { return Math.min(100, Math.max(1, Math.trunc(value ?? 25) || 25)); }
export function notificationOffset(value: number | undefined): number { return Number.isSafeInteger(value) && value! >= 0 ? Math.min(value!, 1_000_000) : 0; }
export function notificationKinds(kinds?: NotificationKind[]): NotificationKind[] { return kinds === undefined ? [...NOTIFICATION_KINDS] : [...new Set(kinds.filter(kind => NOTIFICATION_KINDS.includes(kind)))]; }
export function notificationStatuses(statuses?: NotificationStatus[]): NotificationStatus[] { return statuses === undefined ? [...NOTIFICATION_STATUSES] : [...new Set(statuses.filter(status => NOTIFICATION_STATUSES.includes(status)))]; }
export function notificationLeaseMs(ms?: number): number { return Math.min(600_000, Math.max(100, Math.trunc(ms ?? 60_000) || 60_000)); }
export function notificationSnapshot(events: NotificationEvent[], now: string): NotificationSnapshot {
  const counts = Object.fromEntries(NOTIFICATION_STATUSES.map(status => [status, 0])) as NotificationSnapshot['counts'];
  let oldest = Infinity, expiredLeases = 0;
  const stamp = Date.parse(now);
  for (const event of events) {
    counts[event.status]++;
    if (event.status === 'pending') oldest = Math.min(oldest, Date.parse(event.created_at));
    if (event.status === 'processing' && Date.parse(event.lease_until ?? '') <= stamp) expiredLeases++;
  }
  return { counts, oldestPendingAgeSeconds: Number.isFinite(oldest) ? Math.max(0, (stamp - oldest) / 1000) : 0, expiredLeases };
}