Preuve · code, extrait

backend/flexylead-api-gateway-bootstrap.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–126, sur 218 (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 : 4 5–24 71–96

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

1const DEFAULT_API_SLUG = 'flexylead';
2const ALLOWED_APIS = new Set(['flexylead', 'flexylead-partner']);
3const LIVE_JOB_STATUSES = new Set(['pending', 'running', 'retrying', 'active']);
4const RETIRED_ROUTING_JOBS = new Set(['flexylead-dispatch-leadbyte-prospects']);
5const CANONICAL_JOBS = [
6  {
7    name: 'flexylead-process-ai-ready-appels',
8    every_seconds: 60,
9    timeout_seconds: 300,
10    max_retries: 2,
11    backoff_seconds: 30,
12    function_name: 'process-appel-db-only',
13    event: { limit: 1, mode: 'sweep', source: 'jobs' },
14  },
15  {
16    name: 'flexylead-reconcile-sale-revenue',
17    every_seconds: 300,
18    timeout_seconds: 60,
19    max_retries: 2,
20    backoff_seconds: 30,
21    function_name: 'flexylead-reconcile-sale-revenue',
22    event: { limit: 500, source: 'jobs' },
23  },
24];
25
26export default async function handler(event = {}, context = null) {
27  if (!context?.call) throw new Error('App context is unavailable.');
28
29  const body = event?.body && typeof event.body === 'object' ? event.body : event;
30  const action = clean(body?.action || 'probe');
31
32  if (action === 'probe') {
33    const result = await context.call('api', 'api_list', {});
34    return {
35      ok: true,
36      gateway_available: true,
37      api_count: Array.isArray(result?.apis) ? result.apis.length : 0,
38    };
39  }
40
41  if (action === 'configure') {
42    return configureGateway(context, body);
43  }
44
45  if (action === 'inventory') {
46    const [apis, functions, jobs, tables] = await Promise.all([
47      context.call('api', 'api_list', {}),
48      context.call('functions', 'functions_list', {}),
49      context.call('jobs', 'jobs_list', {}),
50      context.call('tables', 'tables_list', {}),
51    ]);
52    return {
53      ok: true,
54      apis: arrayOf(apis?.apis).map((api) => pick(api, ['id', 'slug', 'name', 'status', 'auth_kind'])),
55      functions: arrayOf(functions?.functions).map((fn) => pick(fn, [
56        'id', 'name', 'runtime', 'status', 'active_version', 'timeout_ms', 'updated_at',
57      ])),
58      jobs: Array.isArray(jobs?.jobs) ? jobs.jobs : Array.isArray(jobs) ? jobs : [],
59      tables: (Array.isArray(tables?.tables) ? tables.tables : Array.isArray(tables) ? tables : [])
60        .map((table) => ({ name: clean(table?.name), row_count: Number(table?.row_count || 0) })),
61    };
62  }
63
64  if (action === 'reconcile_jobs') {
65    return reconcileJobs(context);
66  }
67
68  throw new Error('Unsupported gateway bootstrap action.');
69}
70
71async function reconcileJobs(context) {
72  const listing = await context.call('jobs', 'jobs_list', { owner_app: 'flexylead' });
73  const existing = Array.isArray(listing?.jobs) ? listing.jobs : Array.isArray(listing) ? listing : [];
74  const results = [];
75  for (const job of existing) {
76    if (!RETIRED_ROUTING_JOBS.has(clean(job?.name)) || !LIVE_JOB_STATUSES.has(clean(job?.status).toLowerCase())) continue;
77    await context.call('jobs', 'jobs_cancel', { id: Number(job.id) });
78    results.push({ name: clean(job.name), id: Number(job.id), action: 'retired' });
79  }
80  for (const definition of CANONICAL_JOBS) {
81    const matching = existing.filter((job) => clean(job?.name) === definition.name && LIVE_JOB_STATUSES.has(clean(job?.status).toLowerCase()));
82    const canonical = matching.find((job) => isCanonicalJob(job, definition));
83    for (const job of matching) {
84      if (canonical && Number(job?.id) === Number(canonical?.id)) continue;
85      await context.call('jobs', 'jobs_cancel', { id: Number(job.id) });
86    }
87    if (canonical) {
88      results.push({ name: definition.name, id: Number(canonical.id), action: 'kept' });
89      continue;
90    }
91    const scheduled = await context.call('jobs', 'jobs_schedule', {
92      name: definition.name,
93      schedule: { kind: 'every', every_seconds: definition.every_seconds },
94      timezone: 'Europe/Madrid',
95      target: canonicalTarget(definition),
96      owner_app: 'flexylead',
97      idempotency_key: `${definition.name}:native:v1`,
98      max_retries: definition.max_retries,
99      backoff_seconds: definition.backoff_seconds,
100    });
101    const job = scheduled?.job || scheduled;
102    const id = Number(job?.id || 0);
103    if (!id) throw new Error(`Jobs did not return an id for ${definition.name}.`);
104    await context.call('jobs', 'jobs_run_now', { id });
105    results.push({ name: definition.name, id, action: 'scheduled_and_started' });
106  }
107  return { ok: true, jobs: results };
108}
109
110function isCanonicalJob(job, definition) {
111  const target = job?.target || {};
112  return clean(job?.schedule_kind) === 'every'
113    && Number(job?.every_seconds) === definition.every_seconds
114    && clean(target?.kind) === 'app_tool'
115    && clean(target?.app) === 'functions'
116    && clean(target?.tool) === 'functions_invoke'
117    && clean(target?.input?.name) === definition.function_name
118    && JSON.stringify(target?.input?.event || {}) === JSON.stringify(definition.event);
119}
120
121function canonicalTarget(definition) {
122  return {
123    kind: 'app_tool',
124    app: 'functions',
125    tool: 'functions_invoke',
126    input: { name: definition.function_name, event: definition.event },