307 WHERE CAST(p.id AS TEXT) IN (${missing.map(() => '?').join(', ')})`, missing); 308 for (const row of fetched) prospectById.set(clean(row.id), row); 309 } 310 const rows = []; 311 for (const pid of orderedIds) { 312 if (rows.length >= limit) break; 313 const row = prospectById.get(pid); 314 if (row) rows.push(row); 315 } 316 const aliasSet = new Set(aliases.map((value) => clean(value))); 317 for (const row of matchedProspects) { 318 if (rows.length >= limit) break; 319 const pid = clean(row.id); 320 if (seen.has(pid)) continue; 321 if (!aliasSet.has(clean(row.assigned_commercial_id))) continue; 322 seen.add(pid); 323 rows.push(row); 324 } 325 return { prospectRows: rows, callRows, version: latestVersion([...rows, ...callRows]) }; 326} 327 328export async function missedCallsResource(context, profile, request) { 329 const limit = bounded(request.limit, 1, 15, 10); 330 const centreId = clean(profile.centre_appel_id); 331 // The assignment destination is the authorization boundary. A routed call 332 // deliberately keeps its source centre on `appels`, so filtering calls by 333 // `centre_appel_id` made the ZC counter increase while the card disappeared. 334 // Load the small assignment window first, then resolve only those exact call 335 // IDs regardless of their source centre. 336 const aliases = commercialAliasList(profile); 337 if (!centreId || !aliases.length) throw new Error('Commercial queue scope missing'); 338 // Match the UI's ownership rule before pagination. Loading every colleague's 339 // assignments made a 3,000-row centre exhaust the browser's 100-page guard 340 // even when this commercial had only one follow-up. Claimed owner wins over 341 // the original assignee; unowned pool/group items remain visible for the 342 // existing group-membership check. 343 const owner = "COALESCE(NULLIF(TRIM(claimed_by), ''), NULLIF(TRIM(commercial_id), ''), '')"; 344 const cursor = positiveInteger(request.cursor); 345 const assignmentRows = await queryRows(context, ` 346 SELECT ${SELECTS.assignments.join(', ')} FROM {work_routing_assignments} 347 WHERE resource_type = 'missed_call' 348 AND status IN ('pooled', 'assigned', 'claimed', 'in_progress') 349 AND destination_centre_id = ? 350 AND (${owner} = '' OR ${owner} IN (${aliases.map(() => '?').join(', ')})) 351 ${cursor ? 'AND id < ?' : ''} 352 ORDER BY id DESC LIMIT ?`, [centreId, ...aliases, ...(cursor ? [cursor] : []), limit]); 353 const assignments = { rows: assignmentRows }; 354 const calls = await routedMissedCallRows(context, assignments.rows, limit); 355 const prospectIds = [...new Set(calls.map((row) => positiveInteger(row.prospect_id)).filter(Boolean))]; 356 const linkedProspects = prospectIds.length 357 ? await searchRows(context, 'prospects', [{ col: 'id', op: 'in', value: prospectIds }], prospectIds.length, SELECTS.prospects) 358 : { rows: [] }; 359 const safeCalls = suppressClosedProspectWork(calls, linkedProspects.rows, centreId) 360 .filter((row) => !closedProspectWorkReason(linkedProspectForCall(row, linkedProspects.rows), centreId)); 361 const safeCallRefs = new Set(safeCalls.flatMap((row) => [clean(row.id), clean(row.local_id)]).filter(Boolean)); 362 const safeAssignments = assignments.rows.filter((row) => safeCallRefs.has(clean(row.resource_id))); 363 return { 364 // Continue on assignment IDs, even when every call in this page was 365 // suppressed. Call IDs are not ordered like the routing assignments. 366 has_more: assignments.rows.length === limit, 367 next_cursor: assignments.rows.length === limit ? clean(assignments.rows.at(-1)?.id) : null,