1734 }), { aiCalls: 0, appointments: 0, callsWithAppointment: 0, convertedAppointments: 0, sales: 0, callsWithSale: 0, revenue: 0 }); 1735 Object.assign(summary, { 1736 pendingOutcomes: appointments.filter((row) => ['', 'pending'].includes(row.commercialOutcome)).length, 1737 cancelled: appointments.filter((row) => row.status === 'cancelled').length, 1738 noShows: appointments.filter((row) => row.status === 'no_show').length, 1739 routingPending: 0, ownershipMismatches: 0, 1740 callToAppointmentRate: summary.aiCalls ? rate(summary.callsWithAppointment, summary.aiCalls) : null, 1741 appointmentToSaleRate: summary.appointments ? rate(summary.convertedAppointments, summary.appointments) : null, 1742 finalConversionRate: summary.aiCalls ? rate(summary.callsWithSale, summary.aiCalls) : null, 1743 outcomes: Object.fromEntries(callRows.map((row) => clean(row.ai_outcome || 'unknown')).reduce((map, key) => map.set(key, (map.get(key) || 0) + 1), new Map())), 1744 }); 1745 return { 1746 aiOperations: { 1747 summary, history, rows, appointments, commercialPerformance: [], assignedCommercials: teamRows.map((row) => ({ id: clean(row.id), name: clean(row.name) })), 1748 agents: Array.from(new Set(callRows.map((row) => clean(row.ai_agent_id)).filter(Boolean))), 1749 centres: centreRows.map((row) => ({ id: clean(row.id), name: clean(row.name) || clean(row.id) })), 1750 }, 1751 version: `${range.from}:${range.toExclusive}:${callRows.length}:${appointmentRows.length}`, 1752 }; 1753} 1754 1755async function resolveActor(event, context) { 1756 const userId = Number(requireTrustedPrincipal(event, context).subject); 1757 if (!userId) throw httpError(401, "Authenticated user subject is missing."); 1758 const authContext = await context.call("auth", "auth_users_get_context", { organization_id: AUTH_ORGANIZATION_ID, id: userId }); 1759 if (clean(authContext?.user?.status).toLowerCase() !== "active") throw httpError(403, "User is inactive."); 1760 const permissions = new Set(arrayOf(authContext?.authorization?.permissions).map(clean)); 1761 return { id: String(userId), email: clean(authContext?.user?.email).toLowerCase(), permissions }; 1762} 1763 1764async function resolveCommercial(context, actor, requestedId) { 1765 const own = await findCommercial(context, [['auth_user_id', actor.id], ['email', actor.email]]); 1766 if (!own) throw httpError(403, 'Commercial profile not found.'); 1767 const ownAliases = new Set(commercialAliasList(own)); 1768 if (!requestedId || ownAliases.has(requestedId)) return own; 1769 const requested = await findCommercial(context, [['local_id', requestedId], ['id', requestedId], ['auth_user_id', requestedId]]); 1770 if (!requested) throw httpError(404, 'Requested commercial not found.'); 1771 const canSeeAll = actor.permissions.has('commercial:all_centres'); 1772 const canSupervise = actor.permissions.has('commercial:team:supervise') && clean(own.centre_appel_id) === clean(requested.centre_appel_id); 1773 if (!canSeeAll && !canSupervise) throw httpError(403, 'Commercial is outside the authenticated scope.'); 1774 return requested; 1775} 1776 1777async function findCommercial(context, candidates) { 1778 const supported = new Set(['id', 'auth_user_id', 'email']); 1779 const usable = candidates.filter(([column, value]) => supported.has(column) && clean(value)); 1780 if (!usable.length) return null; 1781 const predicates = usable.map(([column]) => column === 'email' 1782 ? 'lower(CAST(email AS TEXT)) = lower(?)' 1783 : `CAST(${column} AS TEXT) = CAST(? AS TEXT)`); 1784 const result = await context.call('tables', 'tables_query', { 1785 sql: `SELECT * FROM {commerciaux} 1786 WHERE COALESCE(active, 1) = 1 AND (${predicates.join(' OR ')}) 1787 ORDER BY id DESC LIMIT 10`, 1788 params: usable.map(([, value]) => clean(value)), 1789 }); 1790 const rows = Array.isArray(result) ? result : result?.rows || []; 1791 return rows.find((row) => usable.some(([column, value]) => clean(row[column]).toLowerCase() === clean(value).toLowerCase())) || null; 1792} 1793 1794async function searchPage(context, table, where, request, select, fallback, max) { 1795 const limit = bounded(request.limit, 1, max, fallback); 1796 const beforeId = positiveInteger(request.cursor); 1797 const filtered = beforeId ? [...where, { col: 'id', op: 'lt', value: beforeId }] : where; 1798 const result = await searchRows(context, table, filtered, limit, select); 1799 return pageContract(result, limit); 1800} 1801 1802async function searchHumanCallsPage(context, where, request) { 1803 const limit = bounded(request.limit, 1, 50, 15);