Preuve · code, extrait

backend/flexylead-start-callback.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 61–121, 643–703, 1247–1307, sur 1 440 (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 : 91 673 1277

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

61  const prospectIdInput = clean(body?.prospect_id);
62  const prospectRowIdInput = Number(body?.prospect_row_id || 0) || null;
63  const reservationIdInput = clean(body?.reservation_id);
64  const reservationRowIdInput = Number(body?.reservation_row_id || 0) || null;
65  const targetInput = clean(body?.target_number);
66  const requestedDevice = clean(body?.device) || "WEB";
67  const reason = clean(body?.reason) || (callId || callRowId ? "missed_call" : "manual");
68  const notes = clean(body?.notes);
69  const requestedLineType = clean(body?.from_line_type);
70  const requestedIvrId = clean(body?.ivr_id);
71  const requestedRoutingEndpointId = clean(body?.routing_endpoint_id);
72  const requestId = clean(body?.request_id) || (context?.testMode === true ? `test:${Date.now()}:${Math.random()}` : "");
73  if (!requestId || requestId.length > 160) throw new Error("request_id is required");
74
75  const actor = await resolveActorCommercial(context, body, requestedCommercialId);
76  const commercial = actor.row;
77  const commercialId = actor.id;
78  const telephonyProvider = normalizedTelephonyProvider(commercial.telephony_provider);
79  if (telephonyProvider === "apteva_telephony" && !aptevaTelephonyEnabled()) {
80    throw new Error("La téléphonie Flexylead n’est pas activée dans cet environnement.");
81  }
82
83  const existingRequest = await findCallbackByRequestId(context, requestId);
84  if (existingRequest) {
85    return existingCallbackResponse(existingRequest, actor.aliases);
86  }
87
88  let parentCall = await getParentCall(context, callRowId, callId);
89  assertCallOwnership(parentCall, actor.aliases, reason);
90  parentCall = await resolveCallbackRoot(context, parentCall, actor.aliases, reason);
91  parentCall = await recoverFailedCallbackPointer(context, parentCall, actor.aliases);
92  assertActiveMissedCallClaim(parentCall, actor.aliases, reason);
93  const reservation = await getReservation(context, reservationRowIdInput, reservationIdInput);
94  if ((reservationIdInput || reservationRowIdInput) && !reservation) throw new Error("Rendez-vous introuvable.");
95  assertReservationOwnership(reservation, actor.aliases);
96  assertReservationCallable(reservation, actor.aliases);
97  const aiAppointment = reason === "ai_appointment" || clean(reservation?.reservation_type) === "ai_booking";
98  if (aiAppointment && !reservation) throw new Error("Le rendez-vous IA est obligatoire pour lancer cet appel.");
99  if (aiAppointment && !clean(reservation?.prospect_id)) {
100    throw new Error("Ce rendez-vous IA n’est pas relié à un prospect. L’appel est bloqué pour éviter un mauvais routage.");
101  }
102  const prospectId = prospectIdInput || clean(reservation?.prospect_id) || clean(parentCall?.prospect_id);
103  const prospect = await getProspect(context, prospectRowIdInput || prospectId);
104  assertCallbackProspectBinding(parentCall, prospect, prospectIdInput, prospectRowIdInput);
105  const claimedMissedCall = isClaimedMissedCall(parentCall, actor.aliases, reason);
106  // An active claim on this exact routed missed call is the authorization
107  // boundary. It can briefly precede the prospect ownership projection, so do
108  // not reject a legitimate cross-centre callback on the stale owner alone.
109  // Terminal/sold prospects and active sale locks remain blocked below.
110  if (prospect && !claimedMissedCall) assertProspectOwnership(prospect, actor.aliases);
111  assertProspectCallable(prospect, {
112    allowReopenForClaimedMissedCall: claimedMissedCall,
113  });
114  assertReservationProspect(reservation, prospect);
115  if (aiAppointment && !prospect) {
116    throw new Error("Prospect introuvable pour ce rendez-vous IA. L’appel est bloqué pour éviter un mauvais routage.");
117  }
118  const effectiveProspect = await resolveProspectCentreLock(context, prospect, now);
119  assertProspectCentreLock(effectiveProspect, commercial);
120  if (!parentCall && !prospect) throw new Error("Un appel ou un prospect attribué est obligatoire.");
121  const expectedTarget = normalizePhone(aiAppointment
···
643    };
644  }
645  return {
646    ringover: await context.integration("ringover", "start_callback", ringoverCallbackInput(targetNumber, device, timeout, fromNumber)),
647    fromNumberUsed: fromNumber || null,
648    warning: null,
649  };
650}
651
652export function assertCallbackProspectBinding(parentCall, prospect, requestedId, requestedRowId) {
653  const aliases = new Set([clean(prospect?.id), clean(prospect?.local_id)].filter(Boolean));
654  if ((requestedId && !aliases.has(clean(requestedId)))
655    || (requestedRowId && Number(prospect?.id) !== Number(requestedRowId))) {
656    throw new Error("Identifiants du prospect incohérents.");
657  }
658  if (!parentCall || !prospect) return;
659  const linkedId = clean(parentCall.prospect_id);
660  if (linkedId ? !aliases.has(linkedId) : !samePhone(prospect.telephone, prospectPhoneFromCall(parentCall))) {
661    throw new Error("Le prospect demandé ne correspond pas à l'appel réservé.");
662  }
663}
664
665function assertActiveMissedCallClaim(parentCall, commercialAliases, reason) {
666  if (!parentCall) return;
667  if (clean(parentCall.status) !== "manque") return;
668  if (clean(parentCall.missed_pool_status).toLowerCase() === "discarded") {
669    throw new Error("Cet appel a été écarté de la file.");
670  }
671  const followupStatus = clean(parentCall.followup_status);
672  const nrpCount = Number(parentCall.nrp_count) || 0;
673  const nrpWaiting = followupStatus === "nrp_waiting" && nrpCount > 0 && nrpCount < 4;
674  if (nrpCount >= 4 || ["ko", "lost", "sale", "contacted"].includes(followupStatus)) {
675    throw new Error("Le suivi de cet appel est terminé.");
676  }
677  if (nrpWaiting) {
678    const nextAttempt = Date.parse(clean(parentCall.next_followup_at));
679    if (!Number.isFinite(nextAttempt) || nextAttempt > Date.now()) {
680      throw new Error("La prochaine tentative NRP n'est pas encore disponible.");
681    }
682  }
683  // Older NRP rows retained the completed attempt ID. Only a confirmed, due
684  // NRP transition can supersede it; active callbacks remain protected.
685  if ((clean(parentCall.followup_appel_id) && !nrpWaiting) || followupStatus === "callback_started") {
686    throw new Error("Un rappel existe déjà pour cet appel.");
687  }
688  const owner = clean(parentCall.missed_claimed_by);
689  const expiresAt = missedClaimExpiryMs(parentCall);
690  if (!owner) throw new Error("Réservez cet appel manqué avant de lancer le rappel.");
691  if (!commercialAliases.has(owner)) throw new Error("Cet appel est déjà pris par un autre commercial.");
692  if (expiresAt != null && expiresAt <= Date.now()) {
693    throw new Error("La réservation de cet appel a expiré. Réservez-le de nouveau depuis la file commune.");
694  }
695}
696
697function missedClaimExpiryMs(call) {
698  const explicit = Date.parse(clean(call?.missed_claim_expires_at));
699  if (Number.isFinite(explicit)) return explicit;
700  const claimedAt = Date.parse(clean(call?.missed_claimed_at) || clean(call?.status_at));
701  return Number.isFinite(claimedAt) ? claimedAt + MISSED_CLAIM_TTL_MS : null;
702}
703
···
1247    sql: `SELECT v.id, v.centre_appel_id, v.date_vente
1248          FROM {ventes} v
1249          WHERE CASE WHEN json_valid(v.client) THEN COALESCE(
1250              json_extract(v.client, '$.telephone'), json_extract(v.client, '$.phone'),
1251              json_extract(v.client, '$.tel'), json_extract(v.client, '$.mobile'), ''
1252            ) ELSE '' END IN (${placeholders})
1253            AND COALESCE(v.centre_appel_id, '') <> ''
1254            AND COALESCE(v.vente_role, '') NOT LIKE 'non_countable%'
1255            AND LOWER(COALESCE(v.etat, '')) NOT LIKE '%annul%'
1256            AND LOWER(COALESCE(v.etat, '')) NOT LIKE '%cancel%'
1257            AND LOWER(COALESCE(v.etat, '')) NOT LIKE '%refus%'
1258            AND LOWER(COALESCE(v.etat, '')) NOT LIKE '%retract%'
1259            AND LOWER(COALESCE(v.etat, '')) NOT LIKE '%test%'
1260            AND LOWER(COALESCE(v.etat, '')) NOT LIKE '%duplicate%'
1261            AND LOWER(COALESCE(v.etat, '')) NOT LIKE '%doublon%'
1262            AND COALESCE(CASE WHEN json_valid(v.extra) THEN json_extract(v.extra, '$.is_test') END, 0) NOT IN (1, 'true')
1263            AND COALESCE(CASE WHEN json_valid(v.extra) THEN json_extract(v.extra, '$.airtable.is_test') END, 0) NOT IN (1, 'true')
1264            AND v.date_vente >= ? AND v.date_vente <= ?
1265          ORDER BY v.date_vente ASC, v.id ASC LIMIT 1`,
1266    params: [...phoneVariants, cutoff.toISOString().slice(0, 10), now.toISOString().slice(0, 10)],
1267    });
1268    sale = result?.rows?.[0];
1269  }
1270  if (!sale?.centre_appel_id || !sale?.date_vente) return prospect;
1271  const soldAt = Date.parse(clean(sale.date_vente));
1272  if (!Number.isFinite(soldAt)) return prospect;
1273  return {
1274    ...prospect,
1275    centre_lock_id: clean(sale.centre_appel_id),
1276    centre_lock_until: new Date(soldAt + 90 * 24 * 60 * 60 * 1000).toISOString(),
1277    centre_lock_source: "sale",
1278  };
1279}
1280
1281async function assertExternalProspectTarget(context, targetNumber) {
1282  const [routes, commercialNumbers] = await Promise.all([
1283    context.call("tables", "rows_search", {
1284      table: "routing_endpoints",
1285      where: [{ col: "active", op: "eq", value: true }],
1286      limit: 500,
1287    }),
1288    context.call("tables", "rows_search", {
1289      table: "commercial_numbers",
1290      where: [{ col: "active", op: "eq", value: true }],
1291      limit: 1000,
1292    }),
1293  ]);
1294  const internalNumbers = [
1295    ...(routes?.rows || []).flatMap((route) => stringList(route.numbers)),
1296    ...(commercialNumbers?.rows || []).map((row) => clean(row.number)),
1297  ];
1298  if (internalNumbers.some((number) => samePhone(number, targetNumber))) {
1299    throw new Error("Le numéro du prospect correspond à un numéro interne Flexylead/Ringover. L’appel est bloqué.");
1300  }
1301}
1302
1303function normalizeCentre(value) {
1304  const key = clean(value)
1305    .normalize("NFD")
1306    .replace(/[\u0300-\u036f]/g, "")
1307    .toLowerCase()