Preuve · code, extrait

backend/flexylead-ringover-smart-routing.mjs

Dépôt jeremy-flexylead/flexylead-crm · commit ccbbaa07870e0f786729003dd6f8862e4bcc8256 · dossier de production synchronisé le 14 septembre 2026 à 08 h 46

Extrait tel qu'à ce commit : lignes 1–31, 164–224, 3040–3125, sur 3 289 (numérotation d'origine, 30 lignes de contexte autour de chaque citation) · fichier intégral sur GitHub (dépôt privé, accès requis)

Lignes citées par l'audit : 1 194 3070–3095

178 lignes reproduites · ← retour à l'audit · catalogue des preuves

1import { createHmac, timingSafeEqual } from "node:crypto";
2
3const PROVIDER = "ringover";
4const DEFAULT_TIMEZONE = "Europe/Paris";
5const DEFAULT_MAX_AGE_SECONDS = 300;
6const DEFAULT_RESPONSE_BUDGET_MS = 1_000;
7const DEFAULT_DEPENDENCY_TIMEOUT_MS = 650;
8const DEFAULT_CONFIGURATION_CACHE_TTL_MS = 30_000;
9const DEFAULT_RESPONSE_RESERVE_MS = 150;
10const COMMERCIAL_SESSION_MAX_AGE_SECONDS = 180;
11const DEFAULT_LIVE_PERFORMANCE_CACHE_TTL_MS = 300_000;
12const DEFAULT_LIVE_PERFORMANCE_QUERY_TIMEOUT_MS = 500;
13const DEFAULT_LIVE_PERFORMANCE_MAX_STALE_MS = 900_000;
14const DEFAULT_EMERGENCY_PREFERRED_CENTRE_ID = "flexylead";
15const MAX_CONSECUTIVE_PACED_ROUTES = 2;
16const DECISION_TABLE = "call_routing_decisions";
17const RULE_TABLE = "call_routing_rules";
18const CAMPAIGN_GROUP_TABLE = "campaign_routing_groups";
19
20let liveRoutingConfigurationCache = null;
21const livePerformanceScoreCaches = new Map();
22
23// Tenant/project scoped singleton, provisioned offline. CAS replaces the whole
24// bounded state, atomically reserving a chain AND consuming a provider request.
25// No in-process mutex, unsupported transaction API or hot-path schema changes.
26export const DISPATCH_RESERVATION_TABLE = "ringover_dispatch_reservations";
27const DISPATCH_MAX_LEASE_MS = 120_000;
28const DISPATCH_REPLAY_MS = 86_400_000;
29const DISPATCH_MAX_CALLS = 4096;
30// The five-second lease margin includes a three-second response-replay window
31// plus transport headroom. Retries never extend the original absolute lease.
···
164      agents: chosen.map((c, i) => ({ ...c.agent, order: i + 1 })) }
165      : nextStepResponse("Agents déjà en ligne ou réservés");
166    const ttl = chosen.length ? dispatchLeaseMilliseconds(selected) : 0;
167    // Retain bulky responses only for the bounded replay window.
168    for (const entry of state.calls) if (time > entry.at + DISPATCH_REPLAY_WINDOW_MS) {
169      delete entry.response; delete entry.primaryCommercialId;
170    }
171    state.calls.push({ id: callId, at: time, response: selected, primaryCommercialId: chosen[0]?.commercial || null });
172    state.leases.push(...chosen.map(c => ({ commercial: c.commercial, callId, since: time, until: time + ttl })));
173    try {
174      await call("rows_update", { table: DISPATCH_RESERVATION_TABLE, id: 1,
175        expected_revision: String(stored._revision), fields: { state_json: state } });
176      // Timeout/late writes reserve only until a fixed absolute expiry. Never
177      // compensate blindly: another request may already own the agent.
178      if (now() > time + 1000 || Date.now() >= deadline) throw new Error("Dispatch acknowledgement too late");
179      return { response: selected, primaryCommercialId: chosen[0]?.commercial || null };
180    } catch (error) {
181      if (!/revision conflict|row changed since it was loaded/i.test(String(error?.message || error))) throw error;
182    }
183  }
184  throw new Error("Dispatch contention; next IVR step required");
185}
186
187export default async function handler(event, context = {}) {
188  const startedAt = Date.now();
189  const config = readConfig(context);
190  const request = normalizeRequest(event);
191
192  try {
193    assertRequest(request);
194    verifyRingoverSignature(request, config);
195  } catch (error) {
196    return structuredResponse(Number(error?.statusCode) || 400, {
197      error: clean(error?.message) || "Requête Ringover refusée",
198    }, startedAt, "rejected");
199  }
200
201  try {
202    const result = await resolveAndPersist(request.body, context, config, startedAt);
203    return structuredResponse(200, result.response, startedAt, result.mode, {
204      performanceMode: result.performanceMode,
205      performanceStrategy: result.performanceStrategy,
206      performanceRank: result.performanceRank,
207      timings: result.timings,
208    });
209  } catch (error) {
210    console.error("smart routing fallback", clean(error?.message || error));
211    // An unexpected error must never discard a caller's ownership constraint.
212    // No verified affinity is available at this boundary: do not send to an
213    // arbitrary configured centre.
214    const fallback = nextStepResponse("Routage indisponible — attribution non vérifiée");
215    const mode = fallback.agents.length ? "emergency-routing" : "safe-next-step";
216    return structuredResponse(200, fallback, startedAt, mode);
217  }
218}
219
220export async function resolveAndPersist(body, context, config, startedAt = Date.now()) {
221  const result = await resolveRoutingCandidate(body, context, config, startedAt);
222  if (!config.reservationsEnabled || !result.response?.agents?.length) return result;
223  const call = (tool, args) => dispatchDeadlineCall(context, tool, args, startedAt + config.responseBudgetMs - 30);
224  // One final gate covers normal, sticky, replay and emergency response paths.
···
3040      context.configurationCacheTtlMs ?? env.RINGOVER_SMART_ROUTING_CONFIGURATION_CACHE_TTL_MS,
3041      DEFAULT_CONFIGURATION_CACHE_TTL_MS,
3042      1_000,
3043      300_000,
3044    ),
3045    emergencyResponse: context.emergencyResponse
3046      ?? emergencyResponseFromEnvironment(env, emergencyPreferredCentreId),
3047    emergencyPreferredCentreId,
3048    fastMode: truthy(context.fastMode ?? env.RINGOVER_SMART_ROUTING_FAST_MODE),
3049    reservationsEnabled: false, // Temporarily disabled at user request: restore concurrent dispatch capacity.
3050  };
3051}
3052
3053// Prefer a centre-scoped agent list over the legacy free-form response. This
3054// makes the cold-start fallback impossible to configure as a mixed-centre
3055// chain by accident while preserving the old variable as a safe fallback.
3056export function emergencyResponseFromEnvironment(env = {}, preferredCentreId = DEFAULT_EMERGENCY_PREFERRED_CENTRE_ID) {
3057  const preferredAgents = arrayValue(env.RINGOVER_SMART_ROUTING_EMERGENCY_PREFERRED_AGENTS_JSON);
3058  if (preferredAgents.length) {
3059    return {
3060      name: "Routage de secours centre préféré",
3061      destination_centre_id: clean(preferredCentreId) || DEFAULT_EMERGENCY_PREFERRED_CENTRE_ID,
3062      dispatch: "topdown",
3063      max_attempts: 1,
3064      agents: preferredAgents,
3065    };
3066  }
3067  return objectValue(env.RINGOVER_SMART_ROUTING_EMERGENCY_RESPONSE_JSON);
3068}
3069
3070function verifyRingoverSignature(request, config) {
3071  const v3Signature = header(request.headers, "x-ringover-webhook-signature-v3");
3072  if (v3Signature) {
3073    const timestamp = clean(header(request.headers, "x-ringover-request-signature-v3-timestamp"));
3074    assertFreshTimestamp(timestamp, config.maxAgeSeconds);
3075    const expected = createHmac("sha256", config.secret)
3076      .update(`POST${config.expectedUrl}${request.rawBody}${timestamp}`)
3077      .digest();
3078    if (!safeEqual(Buffer.from(v3Signature, "base64"), expected)) {
3079      throw httpError(401, "Signature Ringover V3 invalide");
3080    }
3081    return;
3082  }
3083
3084  const signature = stripBearer(header(request.headers, "x-ringover-webhook-signature"));
3085  const parts = signature.split(".");
3086  if (parts.length !== 3) throw httpError(401, "Signature Ringover absente ou invalide");
3087  const [encodedHeader, encodedPayload, encodedSignature] = parts;
3088  const jwtHeader = parseBase64UrlJson(encodedHeader);
3089  if (jwtHeader.alg !== "HS512") throw httpError(401, "Algorithme Ringover refusé");
3090  const expected = createHmac("sha512", config.secret)
3091    .update(`${encodedHeader}.${encodedPayload}`)
3092    .digest();
3093  if (!safeEqual(Buffer.from(encodedSignature, "base64url"), expected)) {
3094    throw httpError(401, "Signature Ringover invalide");
3095  }
3096  const claims = parseBase64UrlJson(encodedPayload);
3097  if (clean(claims.url) !== config.expectedUrl) throw httpError(401, "URL Ringover signée inattendue");
3098  if (!sameJson(parseSignedPayload(claims.payload), request.body)) {
3099    throw httpError(401, "Payload Ringover signé incohérent");
3100  }
3101  assertFreshTimestamp(request.body?.timestamp, config.maxAgeSeconds);
3102}
3103
3104function assertRequest(request) {
3105  if (request.method !== "POST") throw httpError(405, "Méthode refusée");
3106  if (request.body?.event !== "routing" || request.body?.resource !== "call") {
3107    throw httpError(400, "Événement Smart Routing attendu");
3108  }
3109  if (request.body?.data?.direction !== "inbound") throw httpError(400, "Appel entrant attendu");
3110  if (!clean(request.body?.data?.call_id)) throw httpError(400, "call_id absent");
3111  if (!normalizePhone(request.body?.data?.from_number)) throw httpError(400, "Numéro appelant absent");
3112  if (!normalizePhone(request.body?.data?.to_number)) throw httpError(400, "Numéro appelé absent");
3113}
3114
3115function normalizeRequest(event) {
3116  const body = event?.body && typeof event.body === "object" ? event.body : event;
3117  return {
3118    method: clean(event?.method || "POST").toUpperCase(),
3119    headers: event?.headers && typeof event.headers === "object" ? event.headers : {},
3120    rawBody: typeof event?.raw_body === "string" ? event.raw_body : JSON.stringify(body ?? null),
3121    body,
3122  };
3123}
3124
3125function structuredResponse(statusCode, body, startedAt, mode, diagnostics = {}) {