@iann29/rastro 0.10.9 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +8 -0
  2. package/agent/integration.md +45 -0
  3. package/agent/manifest.json +4 -4
  4. package/dist/client/federation.d.ts +82 -9
  5. package/dist/client/federation.d.ts.map +1 -1
  6. package/dist/client/federation.js +40 -2
  7. package/dist/client/federation.js.map +1 -1
  8. package/dist/client/index.d.ts +464 -8
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +259 -7
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +10 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -1
  14. package/dist/component/_generated/api.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +273 -21
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/_generated/server.d.ts +2 -0
  18. package/dist/component/_generated/server.d.ts.map +1 -1
  19. package/dist/component/_generated/server.js.map +1 -1
  20. package/dist/component/constants.d.ts +31 -0
  21. package/dist/component/constants.d.ts.map +1 -1
  22. package/dist/component/constants.js +31 -0
  23. package/dist/component/constants.js.map +1 -1
  24. package/dist/component/convex.config.d.ts +2 -0
  25. package/dist/component/convex.config.js +7 -0
  26. package/dist/component/convex.config.js.map +1 -1
  27. package/dist/component/errors.d.ts +1 -1
  28. package/dist/component/errors.d.ts.map +1 -1
  29. package/dist/component/errors.js.map +1 -1
  30. package/dist/component/http.d.ts.map +1 -1
  31. package/dist/component/http.js +64 -0
  32. package/dist/component/http.js.map +1 -1
  33. package/dist/component/leadAdsValidators.d.ts +1092 -0
  34. package/dist/component/leadAdsValidators.d.ts.map +1 -0
  35. package/dist/component/leadAdsValidators.js +304 -0
  36. package/dist/component/leadAdsValidators.js.map +1 -0
  37. package/dist/component/leadFormRules.d.ts +60 -0
  38. package/dist/component/leadFormRules.d.ts.map +1 -0
  39. package/dist/component/leadFormRules.js +140 -0
  40. package/dist/component/leadFormRules.js.map +1 -0
  41. package/dist/component/leadFormStore.d.ts +16 -0
  42. package/dist/component/leadFormStore.d.ts.map +1 -0
  43. package/dist/component/leadFormStore.js +92 -0
  44. package/dist/component/leadFormStore.js.map +1 -0
  45. package/dist/component/leadgenToken.d.ts +56 -0
  46. package/dist/component/leadgenToken.d.ts.map +1 -0
  47. package/dist/component/leadgenToken.js +262 -0
  48. package/dist/component/leadgenToken.js.map +1 -0
  49. package/dist/component/leads.d.ts +76 -7
  50. package/dist/component/leads.d.ts.map +1 -1
  51. package/dist/component/leads.js +112 -78
  52. package/dist/component/leads.js.map +1 -1
  53. package/dist/component/meta.d.ts.map +1 -1
  54. package/dist/component/meta.js +21 -1
  55. package/dist/component/meta.js.map +1 -1
  56. package/dist/component/metaFake.d.ts +44 -0
  57. package/dist/component/metaFake.d.ts.map +1 -1
  58. package/dist/component/metaFake.js +400 -1
  59. package/dist/component/metaFake.js.map +1 -1
  60. package/dist/component/metaGraph.d.ts +91 -0
  61. package/dist/component/metaGraph.d.ts.map +1 -1
  62. package/dist/component/metaGraph.js +217 -0
  63. package/dist/component/metaGraph.js.map +1 -1
  64. package/dist/component/metaLeads.d.ts +681 -0
  65. package/dist/component/metaLeads.d.ts.map +1 -0
  66. package/dist/component/metaLeads.js +2634 -0
  67. package/dist/component/metaLeads.js.map +1 -0
  68. package/dist/component/schema.d.ts +414 -5
  69. package/dist/component/schema.d.ts.map +1 -1
  70. package/dist/component/schema.js +169 -1
  71. package/dist/component/schema.js.map +1 -1
  72. package/dist/component/validators.d.ts +235 -11
  73. package/dist/component/validators.d.ts.map +1 -1
  74. package/dist/component/validators.js +14 -1
  75. package/dist/component/validators.js.map +1 -1
  76. package/dist/tracker/generated.d.ts +1 -1
  77. package/dist/tracker/generated.js +1 -1
  78. package/dist/ui/campaigns/CampaignsView.d.ts +5 -1
  79. package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -1
  80. package/dist/ui/campaigns/CampaignsView.js.map +1 -1
  81. package/dist/ui/campaigns/Drawer.d.ts.map +1 -1
  82. package/dist/ui/campaigns/Drawer.js +8 -26
  83. package/dist/ui/campaigns/Drawer.js.map +1 -1
  84. package/dist/ui/campaigns/labels.d.ts +29 -1
  85. package/dist/ui/campaigns/labels.d.ts.map +1 -1
  86. package/dist/ui/campaigns/labels.js +77 -2
  87. package/dist/ui/campaigns/labels.js.map +1 -1
  88. package/dist/ui/campaigns/model.js +2 -2
  89. package/dist/ui/campaigns/model.js.map +1 -1
  90. package/dist/ui/forms/FirstContact.d.ts +19 -0
  91. package/dist/ui/forms/FirstContact.d.ts.map +1 -0
  92. package/dist/ui/forms/FirstContact.js +81 -0
  93. package/dist/ui/forms/FirstContact.js.map +1 -0
  94. package/dist/ui/forms/FormLead.d.ts +28 -0
  95. package/dist/ui/forms/FormLead.d.ts.map +1 -0
  96. package/dist/ui/forms/FormLead.js +199 -0
  97. package/dist/ui/forms/FormLead.js.map +1 -0
  98. package/dist/ui/forms/FormRulesDrawer.d.ts +49 -0
  99. package/dist/ui/forms/FormRulesDrawer.d.ts.map +1 -0
  100. package/dist/ui/forms/FormRulesDrawer.js +149 -0
  101. package/dist/ui/forms/FormRulesDrawer.js.map +1 -0
  102. package/dist/ui/forms/FormsView.d.ts +10 -0
  103. package/dist/ui/forms/FormsView.d.ts.map +1 -0
  104. package/dist/ui/forms/FormsView.js +215 -0
  105. package/dist/ui/forms/FormsView.js.map +1 -0
  106. package/dist/ui/forms/Icons.d.ts +46 -0
  107. package/dist/ui/forms/Icons.d.ts.map +1 -0
  108. package/dist/ui/forms/Icons.js +45 -0
  109. package/dist/ui/forms/Icons.js.map +1 -0
  110. package/dist/ui/forms/RuleRow.d.ts +37 -0
  111. package/dist/ui/forms/RuleRow.d.ts.map +1 -0
  112. package/dist/ui/forms/RuleRow.js +80 -0
  113. package/dist/ui/forms/RuleRow.js.map +1 -0
  114. package/dist/ui/forms/WhatsAppButtonGuide.d.ts +33 -0
  115. package/dist/ui/forms/WhatsAppButtonGuide.d.ts.map +1 -0
  116. package/dist/ui/forms/WhatsAppButtonGuide.js +90 -0
  117. package/dist/ui/forms/WhatsAppButtonGuide.js.map +1 -0
  118. package/dist/ui/forms/index.d.ts +11 -0
  119. package/dist/ui/forms/index.d.ts.map +1 -0
  120. package/dist/ui/forms/index.js +7 -0
  121. package/dist/ui/forms/index.js.map +1 -0
  122. package/dist/ui/forms/journey.d.ts +19 -0
  123. package/dist/ui/forms/journey.d.ts.map +1 -0
  124. package/dist/ui/forms/journey.js +54 -0
  125. package/dist/ui/forms/journey.js.map +1 -0
  126. package/dist/ui/forms/model.d.ts +239 -0
  127. package/dist/ui/forms/model.d.ts.map +1 -0
  128. package/dist/ui/forms/model.js +556 -0
  129. package/dist/ui/forms/model.js.map +1 -0
  130. package/dist/ui/forms/sample.d.ts +36 -0
  131. package/dist/ui/forms/sample.d.ts.map +1 -0
  132. package/dist/ui/forms/sample.js +444 -0
  133. package/dist/ui/forms/sample.js.map +1 -0
  134. package/dist/ui/forms/types.d.ts +454 -0
  135. package/dist/ui/forms/types.d.ts.map +1 -0
  136. package/dist/ui/forms/types.js +2 -0
  137. package/dist/ui/forms/types.js.map +1 -0
  138. package/dist/ui/index.d.ts +2 -0
  139. package/dist/ui/index.d.ts.map +1 -1
  140. package/dist/ui/index.js +4 -0
  141. package/dist/ui/index.js.map +1 -1
  142. package/dist/ui/integrations/Overlay.d.ts +3 -1
  143. package/dist/ui/integrations/Overlay.d.ts.map +1 -1
  144. package/dist/ui/integrations/Overlay.js +2 -2
  145. package/dist/ui/integrations/Overlay.js.map +1 -1
  146. package/dist/ui/portfolio/PortfolioView.d.ts +4 -1
  147. package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -1
  148. package/dist/ui/portfolio/PortfolioView.js.map +1 -1
  149. package/dist/ui/sample.d.ts +3 -1
  150. package/dist/ui/sample.d.ts.map +1 -1
  151. package/dist/ui/sample.js +2 -0
  152. package/dist/ui/sample.js.map +1 -1
  153. package/dist/ui/seed.d.ts +1 -1
  154. package/dist/ui/ui.css +2301 -0
  155. package/docs/federation.md +19 -0
  156. package/docs/meta-ads.md +713 -7
  157. package/docs/upgrading.md +70 -0
  158. package/llms.txt +1 -0
  159. package/package.json +1 -1
  160. package/src/component/_generated/api.ts +10 -0
  161. package/src/component/_generated/component.ts +289 -20
  162. package/src/component/_generated/server.ts +2 -0
  163. package/src/component/constants.ts +33 -0
  164. package/src/component/convex.config.ts +7 -0
  165. package/src/component/errors.ts +3 -1
  166. package/src/component/http.ts +75 -0
  167. package/src/component/leadAdsValidators.ts +365 -0
  168. package/src/component/leadFormRules.ts +202 -0
  169. package/src/component/leadFormStore.ts +129 -0
  170. package/src/component/leadgenToken.ts +348 -0
  171. package/src/component/leads.ts +136 -84
  172. package/src/component/meta.ts +21 -1
  173. package/src/component/metaFake.ts +485 -1
  174. package/src/component/metaGraph.ts +327 -0
  175. package/src/component/metaLeads.ts +3101 -0
  176. package/src/component/schema.ts +187 -1
  177. package/src/component/validators.ts +17 -1
  178. package/src/tracker/generated.ts +1 -1
@@ -32,6 +32,7 @@ import {
32
32
  MAX_LINKS_PER_CONNECTION,
33
33
  PAUSED_AT,
34
34
  } from "./metaSync.js";
35
+ import { removeConnectionPages, syncOwnerPages } from "./metaLeads.js";
35
36
  import { sanitizeOpaqueId } from "./sanitize.js";
36
37
  import { decryptSecret, encryptSecret, hasSecretsKey } from "./secrets.js";
37
38
  import {
@@ -584,6 +585,13 @@ async function connectToken(
584
585
  lastError: login?.lastError ?? null,
585
586
  },
586
587
  );
588
+ // The Pages and their own tokens, on a host that opted into Lead Ads. Never
589
+ // fails the login: a login without the Page permissions simply lists none.
590
+ await syncOwnerPages(ctx, client, {
591
+ connectionId,
592
+ token,
593
+ tokenIv: sealed.iv,
594
+ });
587
595
  return { connectionId, businessName, adAccounts, pixels };
588
596
  }
589
597
 
@@ -683,8 +691,8 @@ export const listAdAccounts = action({
683
691
  let adAccounts: MetaAdAccount[];
684
692
  let pixels: MetaPixel[];
685
693
  let pixelsComplete: boolean;
694
+ const client = graph();
686
695
  try {
687
- const client = graph();
688
696
  adAccounts = (await client.adAccounts(token, optionalLoginApp())).map(
689
697
  toAccount,
690
698
  );
@@ -710,6 +718,13 @@ export const listAdAccounts = action({
710
718
  pixels,
711
719
  pixelsComplete,
712
720
  });
721
+ // The Pages too (Lead Ads hosts): a Page granted since the login shows
722
+ // up, one taken back stops.
723
+ await syncOwnerPages(ctx, client, {
724
+ connectionId: connection.connectionId,
725
+ token,
726
+ tokenIv: connection.tokenIv,
727
+ });
713
728
  return adAccounts;
714
729
  },
715
730
  });
@@ -787,6 +802,10 @@ async function removeOwnerBatch(
787
802
  .take(UNLINK_BATCH)
788
803
  : [];
789
804
  for (const link of links) await unlinkRow(ctx, link);
805
+ // Its Pages and their links go with it (the leads already in stay).
806
+ const morePages = connectionId
807
+ ? await removeConnectionPages(ctx, connectionId, UNLINK_BATCH)
808
+ : false;
790
809
  // A pixel picked through the connection sent with its token: it goes too.
791
810
  const connectionPixels = connectionId
792
811
  ? await ctx.db
@@ -819,6 +838,7 @@ async function removeOwnerBatch(
819
838
  const cursor = ownerPixels.at(-1)?._creationTime;
820
839
  if (
821
840
  links.length === UNLINK_BATCH ||
841
+ morePages ||
822
842
  connectionPixels.length === UNLINK_BATCH ||
823
843
  ownerPixels.length === UNLINK_BATCH
824
844
  ) {
@@ -16,7 +16,10 @@ import {
16
16
  * which Meta refuses like an expired token. A token
17
17
  * `rastro-fake-sample-token:<days>` moves every date by that many whole days,
18
18
  * so a demo seeded today reads the sample as its last 30 days, sync after
19
- * sync. Conversions API sends are accepted. No network call is made.
19
+ * sync. Conversions API sends are accepted. Lead Ads: a Facebook Page per
20
+ * client (and "Sorriso Kids", whose leads Meta did not grant), their instant
21
+ * forms, and a steady stream of made-up leads (`FAKE_PAGES` below). No
22
+ * network call is made.
20
23
  */
21
24
 
22
25
  const BUSINESS = { id: "sample-business", name: "Connection Company" };
@@ -177,6 +180,375 @@ function account(adAccountId: string): FakeAccount {
177
180
  return found;
178
181
  }
179
182
 
183
+ // ---------------------------------------------------------------------------
184
+ // Lead Ads: a Facebook Page per sample client, their instant forms, and a
185
+ // steady stream of leads. Lead `k` of a form is created at `k × every +
186
+ // offset` (UTC ms), so the leads before now always exist and new ones show up
187
+ // as time passes, for the webhook-free reconciliation to find. Answers and
188
+ // contacts derive from `k` (made-up people, never real ones).
189
+
190
+ const HOUR_MS = 3_600_000;
191
+ const FAKE_PAGE_TOKEN = "rastro-fake-page-token:";
192
+ const LEAD_HISTORY_MS = 90 * DAY_MS;
193
+ /** How many leads one fake listing holds at most (Meta's cap); tests lower it. */
194
+ export const fakeLimits = { maxLeads: 2_000 };
195
+ /**
196
+ * The Pages the app is subscribed to, as Meta keeps it: one subscription per
197
+ * Page and app, whichever host asked for it. Tests read it, and take a Page
198
+ * off it the way another host's unsubscription would.
199
+ */
200
+ export const fakeSubscriptions = new Set<string>();
201
+
202
+ type FakeQuestion = {
203
+ key: string;
204
+ label: string;
205
+ type: string;
206
+ options?: Array<{ key: string; value: string }>;
207
+ };
208
+
209
+ interface FakeForm {
210
+ formId: string;
211
+ /** The two digits every leadgen id of this form starts with (after "7"). */
212
+ serial: string;
213
+ name: string;
214
+ status: string;
215
+ questions: FakeQuestion[];
216
+ whatsappButton: boolean | null;
217
+ /** How long after its creation (days before now) the form exists. */
218
+ createdDaysAgo: number;
219
+ /** A lead every this many ms; 0 for a form that brings none. */
220
+ everyMs: number;
221
+ offsetMs: number;
222
+ }
223
+
224
+ interface FakePage {
225
+ pageId: string;
226
+ name: string;
227
+ /** The sample client whose ads bring the leads, or null. */
228
+ sampleSiteId: string | null;
229
+ leadsAllowed: boolean;
230
+ forms: FakeForm[];
231
+ }
232
+
233
+ const CONTACT_QUESTIONS: FakeQuestion[] = [
234
+ { key: "full_name", label: "Nome completo", type: "FULL_NAME" },
235
+ { key: "phone_number", label: "Telefone", type: "PHONE" },
236
+ ];
237
+ const EMAIL_QUESTION: FakeQuestion = {
238
+ key: "email",
239
+ label: "E-mail",
240
+ type: "EMAIL",
241
+ };
242
+ const choice = (
243
+ key: string,
244
+ label: string,
245
+ options: Array<[string, string]>,
246
+ ): FakeQuestion => ({
247
+ key,
248
+ label,
249
+ type: "CUSTOM",
250
+ options: options.map(([optionKey, value]) => ({ key: optionKey, value })),
251
+ });
252
+
253
+ /** The fake Pages: one per sample client, plus one without lead access. */
254
+ export const FAKE_PAGES: readonly FakePage[] = [
255
+ {
256
+ pageId: "4100000000001",
257
+ name: "Loja X",
258
+ sampleSiteId: "sample-loja-x",
259
+ leadsAllowed: true,
260
+ forms: [
261
+ {
262
+ formId: "4200000000101",
263
+ serial: "11",
264
+ name: "Provador VIP",
265
+ status: "ACTIVE",
266
+ questions: [
267
+ ...CONTACT_QUESTIONS,
268
+ choice("tamanho", "Qual o seu tamanho?", [
269
+ ["p", "P"],
270
+ ["m", "M"],
271
+ ["g", "G"],
272
+ ]),
273
+ ],
274
+ whatsappButton: true,
275
+ createdDaysAgo: 120,
276
+ everyMs: 8 * HOUR_MS,
277
+ offsetMs: 25 * 60_000,
278
+ },
279
+ ],
280
+ },
281
+ {
282
+ pageId: "4100000000002",
283
+ name: "Clínica Sorriso",
284
+ sampleSiteId: "sample-clinica-sorriso",
285
+ leadsAllowed: true,
286
+ forms: [
287
+ {
288
+ formId: "4200000000201",
289
+ serial: "21",
290
+ name: "Avaliação gratuita",
291
+ status: "ACTIVE",
292
+ questions: [
293
+ ...CONTACT_QUESTIONS,
294
+ EMAIL_QUESTION,
295
+ choice("orcamento", "Orçamento para o tratamento", [
296
+ ["ate_1000", "Até R$ 1.000"],
297
+ ["de_1000_a_5000", "R$ 1.000 a R$ 5.000"],
298
+ ["acima_5000", "Acima de R$ 5.000"],
299
+ ]),
300
+ choice("agendar", "Quer agendar a avaliação?", [
301
+ ["sim", "Sim"],
302
+ ["nao", "Não"],
303
+ ]),
304
+ choice("tratamento", "Tratamento de interesse", [
305
+ ["implante", "Implante"],
306
+ ["lente", "Lente de contato"],
307
+ ["clareamento", "Clareamento"],
308
+ ]),
309
+ ],
310
+ whatsappButton: false,
311
+ createdDaysAgo: 200,
312
+ everyMs: 675 * 60_000,
313
+ offsetMs: 4 * 60_000,
314
+ },
315
+ {
316
+ formId: "4200000000202",
317
+ serial: "22",
318
+ name: "Clareamento — orçamento",
319
+ status: "ACTIVE",
320
+ questions: [
321
+ ...CONTACT_QUESTIONS,
322
+ choice("quando", "Quando quer começar?", [
323
+ ["esta_semana", "Esta semana"],
324
+ ["este_mes", "Este mês"],
325
+ ["sem_pressa", "Sem pressa"],
326
+ ]),
327
+ ],
328
+ whatsappButton: true,
329
+ createdDaysAgo: 90,
330
+ everyMs: 40 * HOUR_MS,
331
+ offsetMs: 19 * HOUR_MS + 10 * 60_000,
332
+ },
333
+ {
334
+ formId: "4200000000203",
335
+ serial: "23",
336
+ name: "Lente de contato dental",
337
+ status: "ACTIVE",
338
+ questions: [
339
+ ...CONTACT_QUESTIONS,
340
+ EMAIL_QUESTION,
341
+ choice("lente_dentes", "Quantos dentes?", [
342
+ ["ate_4", "Até 4"],
343
+ ["de_6_a_10", "De 6 a 10"],
344
+ ["arcada", "A arcada toda"],
345
+ ]),
346
+ ],
347
+ whatsappButton: null,
348
+ createdDaysAgo: 2,
349
+ everyMs: 12 * HOUR_MS,
350
+ offsetMs: 9 * HOUR_MS + 2 * 60_000,
351
+ },
352
+ {
353
+ formId: "4200000000204",
354
+ serial: "24",
355
+ name: "Promo setembro",
356
+ status: "ARCHIVED",
357
+ questions: [
358
+ ...CONTACT_QUESTIONS,
359
+ EMAIL_QUESTION,
360
+ choice("promo", "Qual tratamento da promoção?", [
361
+ ["limpeza", "Limpeza"],
362
+ ["clareamento", "Clareamento"],
363
+ ]),
364
+ ],
365
+ whatsappButton: null,
366
+ createdDaysAgo: 60,
367
+ everyMs: 0,
368
+ offsetMs: 0,
369
+ },
370
+ ],
371
+ },
372
+ {
373
+ pageId: "4100000000003",
374
+ name: "Pet Mania",
375
+ sampleSiteId: "sample-pet-mania",
376
+ leadsAllowed: true,
377
+ forms: [
378
+ {
379
+ formId: "4200000000301",
380
+ serial: "31",
381
+ name: "Agende o banho",
382
+ status: "ACTIVE",
383
+ questions: [
384
+ ...CONTACT_QUESTIONS,
385
+ choice("porte", "Porte do pet", [
386
+ ["pequeno", "Pequeno"],
387
+ ["medio", "Médio"],
388
+ ["grande", "Grande"],
389
+ ]),
390
+ ],
391
+ whatsappButton: null,
392
+ createdDaysAgo: 150,
393
+ everyMs: 10 * HOUR_MS,
394
+ offsetMs: 2 * HOUR_MS,
395
+ },
396
+ ],
397
+ },
398
+ {
399
+ pageId: "4100000000004",
400
+ name: "Casa Aurora",
401
+ sampleSiteId: "sample-casa-aurora",
402
+ leadsAllowed: true,
403
+ forms: [
404
+ {
405
+ formId: "4200000000401",
406
+ serial: "41",
407
+ name: "Orçamento de móveis",
408
+ status: "ACTIVE",
409
+ questions: [
410
+ ...CONTACT_QUESTIONS,
411
+ EMAIL_QUESTION,
412
+ choice("ambiente", "Qual ambiente?", [
413
+ ["sala", "Sala"],
414
+ ["quarto", "Quarto"],
415
+ ["cozinha", "Cozinha"],
416
+ ]),
417
+ {
418
+ key: "detalhes",
419
+ label: "Conte o que você precisa",
420
+ type: "CUSTOM",
421
+ },
422
+ ],
423
+ whatsappButton: false,
424
+ createdDaysAgo: 100,
425
+ everyMs: 20 * HOUR_MS,
426
+ offsetMs: 13 * HOUR_MS,
427
+ },
428
+ ],
429
+ },
430
+ {
431
+ pageId: "4100000000099",
432
+ name: "Sorriso Kids",
433
+ sampleSiteId: null,
434
+ leadsAllowed: false,
435
+ forms: [],
436
+ },
437
+ ];
438
+
439
+ const FAKE_FIRST = [
440
+ "Marina",
441
+ "Rafael",
442
+ "Juliana",
443
+ "Bruno",
444
+ "Patrícia",
445
+ "Thiago",
446
+ "Larissa",
447
+ "Felipe",
448
+ "Camila",
449
+ "Gustavo",
450
+ ];
451
+ const FAKE_LAST = ["Costa", "Souza", "Lima", "Alves", "Rocha", "Pires"];
452
+
453
+ const fakePage = (pageId: string) =>
454
+ FAKE_PAGES.find((page) => page.pageId === pageId) ?? null;
455
+
456
+ function fakeForm(formId: string) {
457
+ for (const page of FAKE_PAGES) {
458
+ const form = page.forms.find((row) => row.formId === formId);
459
+ if (form) return { page, form };
460
+ }
461
+ return null;
462
+ }
463
+
464
+ const formCreatedAt = (form: FakeForm, now: number) =>
465
+ Math.floor((now - form.createdDaysAgo * DAY_MS) / DAY_MS) * DAY_MS;
466
+
467
+ /** The `k`-th lead of a form, as Meta returns it. */
468
+ function fakeLead(page: FakePage, form: FakeForm, k: number) {
469
+ const createdTime = k * form.everyMs + form.offsetMs;
470
+ const first = FAKE_FIRST[k % FAKE_FIRST.length]!;
471
+ const last = FAKE_LAST[(k * 7) % FAKE_LAST.length]!;
472
+ const digits = String((k * 7919 + Number(form.serial) * 104_729) % 1e8)
473
+ .padStart(8, "0")
474
+ .slice(0, 8);
475
+ const fields = form.questions.map((question, index) => {
476
+ switch (question.type) {
477
+ case "FULL_NAME":
478
+ return { name: question.key, values: [`${first} ${last}`] };
479
+ case "PHONE":
480
+ return { name: question.key, values: [`+55549${digits}`] };
481
+ case "EMAIL":
482
+ return {
483
+ name: question.key,
484
+ values: [
485
+ `${first.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase()}.${k}@example.com`,
486
+ ],
487
+ };
488
+ default: {
489
+ const options = question.options ?? [];
490
+ if (options.length === 0) {
491
+ return { name: question.key, values: ["Preciso de um orçamento"] };
492
+ }
493
+ // Meta returns the option's shown value; the ingestion maps it back.
494
+ const option = options[(k + index * 3) % options.length]!;
495
+ return { name: question.key, values: [option.value] };
496
+ }
497
+ }
498
+ });
499
+ const ads =
500
+ page.sampleSiteId === null
501
+ ? []
502
+ : (SAMPLE_SEED.find(
503
+ (site) => site.siteId === page.sampleSiteId,
504
+ )?.campaigns.flatMap((campaign) =>
505
+ campaign.adsets.flatMap((adset) =>
506
+ adset.ads.map((ad) => ({
507
+ adId: ad.adId,
508
+ adsetId: adset.adsetId,
509
+ campaignId: campaign.campaignId,
510
+ })),
511
+ ),
512
+ ) ?? []);
513
+ const ad = ads.length > 0 ? ads[k % ads.length]! : null;
514
+ return {
515
+ leadgenId: `7${form.serial}${String(k).padStart(9, "0")}`,
516
+ createdTime,
517
+ formId: form.formId,
518
+ adId: ad?.adId ?? null,
519
+ adsetId: ad?.adsetId ?? null,
520
+ campaignId: ad?.campaignId ?? null,
521
+ platform: k % 3 === 0 ? "ig" : "fb",
522
+ isOrganic: ad === null,
523
+ fields,
524
+ };
525
+ }
526
+
527
+ function checkPageToken(token: string, pageId?: string) {
528
+ checkToken(token);
529
+ if (pageId !== undefined && token !== `${FAKE_PAGE_TOKEN}${pageId}`) {
530
+ throw new MetaGraphError(
531
+ "permission",
532
+ "the Page token does not act for this Page (fake)",
533
+ 200,
534
+ );
535
+ }
536
+ }
537
+
538
+ const pageIdOfToken = (token: string) =>
539
+ token.startsWith(FAKE_PAGE_TOKEN) ? token.slice(FAKE_PAGE_TOKEN.length) : "";
540
+
541
+ function leadPermission(page: FakePage | null): FakePage {
542
+ if (!page?.leadsAllowed) {
543
+ throw new MetaGraphError(
544
+ "permission",
545
+ "leads_retrieval was not granted for this Page (fake)",
546
+ 200,
547
+ );
548
+ }
549
+ return page;
550
+ }
551
+
180
552
  export function fakeMetaGraph(): MetaGraph {
181
553
  return {
182
554
  async exchangeCode(_app, code) {
@@ -277,5 +649,117 @@ export function fakeMetaGraph(): MetaGraph {
277
649
  checkToken(token);
278
650
  return { eventsReceived: payload.data.length, fbtraceId: "fake-trace" };
279
651
  },
652
+ async pages(token) {
653
+ checkToken(token);
654
+ return {
655
+ pages: FAKE_PAGES.map((page) => ({
656
+ pageId: page.pageId,
657
+ name: page.name,
658
+ accessToken: `${FAKE_PAGE_TOKEN}${page.pageId}`,
659
+ tasks: ["ADVERTISE", "ANALYZE", "CREATE_CONTENT", "MANAGE"],
660
+ })),
661
+ complete: true,
662
+ };
663
+ },
664
+ async leadAccess(token) {
665
+ checkToken(token);
666
+ return {
667
+ all: false,
668
+ pageIds: FAKE_PAGES.filter((page) => page.leadsAllowed).map(
669
+ (page) => page.pageId,
670
+ ),
671
+ };
672
+ },
673
+ async subscribePage(pageToken, pageId) {
674
+ checkPageToken(pageToken, pageId);
675
+ leadPermission(fakePage(pageId));
676
+ fakeSubscriptions.add(pageId);
677
+ },
678
+ async leadForms(pageToken, pageId) {
679
+ checkPageToken(pageToken, pageId);
680
+ const page = leadPermission(fakePage(pageId));
681
+ const now = Date.now();
682
+ return {
683
+ forms: page.forms.map((form) => ({
684
+ formId: form.formId,
685
+ name: form.name,
686
+ status: form.status,
687
+ questions: form.questions.map((question) => ({ ...question })),
688
+ whatsappButton: form.whatsappButton,
689
+ createdTime: formCreatedAt(form, now),
690
+ })),
691
+ usage: USAGE,
692
+ complete: true,
693
+ };
694
+ },
695
+ async lead(pageToken, leadgenId) {
696
+ checkToken(pageToken);
697
+ const match = /^7(\d{2})(\d{9})$/.exec(leadgenId);
698
+ const found = match
699
+ ? FAKE_PAGES.flatMap((page) =>
700
+ page.forms
701
+ .filter((form) => form.serial === match[1])
702
+ .map((form) => ({ page, form })),
703
+ )[0]
704
+ : undefined;
705
+ const now = Date.now();
706
+ const lead =
707
+ found && found.form.everyMs > 0
708
+ ? fakeLead(found.page, found.form, Number(match![2]))
709
+ : null;
710
+ if (
711
+ !found ||
712
+ !lead ||
713
+ pageIdOfToken(pageToken) !== found.page.pageId ||
714
+ lead.createdTime > now ||
715
+ lead.createdTime < formCreatedAt(found.form, now)
716
+ ) {
717
+ throw new MetaGraphError(
718
+ "permission",
719
+ "Unsupported get request: the lead does not exist (fake)",
720
+ 100,
721
+ );
722
+ }
723
+ return lead;
724
+ },
725
+ async formLeads(pageToken, formId, sinceSec, untilSec) {
726
+ checkToken(pageToken);
727
+ const found = fakeForm(formId);
728
+ if (!found || pageIdOfToken(pageToken) !== found.page.pageId) {
729
+ throw new MetaGraphError(
730
+ "permission",
731
+ "the form is not this Page's (fake)",
732
+ 100,
733
+ );
734
+ }
735
+ leadPermission(found.page);
736
+ const { page, form } = found;
737
+ const now = Date.now();
738
+ if (form.everyMs <= 0) return { leads: [], usage: USAGE, complete: true };
739
+ const from = Math.max(
740
+ sinceSec * 1000 + 1,
741
+ now - LEAD_HISTORY_MS,
742
+ formCreatedAt(form, now),
743
+ );
744
+ const to =
745
+ untilSec === undefined ? now : Math.min(now, untilSec * 1000 - 1);
746
+ const first = Math.max(
747
+ Math.ceil((from - form.offsetMs) / form.everyMs),
748
+ 0,
749
+ );
750
+ const last = Math.floor((to - form.offsetMs) / form.everyMs);
751
+ // Meta lists the newest first and stops at its cap: a truncated
752
+ // listing holds the newest leads of the window, never the oldest.
753
+ const leads = [];
754
+ for (let k = last; k >= first; k -= 1) {
755
+ if (leads.length >= fakeLimits.maxLeads) break;
756
+ leads.push(fakeLead(page, form, k));
757
+ }
758
+ return {
759
+ leads,
760
+ usage: USAGE,
761
+ complete: last - first + 1 <= fakeLimits.maxLeads,
762
+ };
763
+ },
280
764
  };
281
765
  }