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()