@iann29/rastro 0.9.0 → 0.10.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 (257) hide show
  1. package/README.md +9 -1
  2. package/agent/integration.md +23 -1
  3. package/agent/manifest.json +4 -4
  4. package/dist/client/federation.d.ts +114 -9
  5. package/dist/client/federation.d.ts.map +1 -1
  6. package/dist/client/federation.js +54 -2
  7. package/dist/client/federation.js.map +1 -1
  8. package/dist/client/index.d.ts +637 -8
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +385 -9
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +14 -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 +277 -1
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/_generated/server.d.ts +4 -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 +5 -0
  21. package/dist/component/constants.d.ts.map +1 -1
  22. package/dist/component/constants.js +10 -0
  23. package/dist/component/constants.js.map +1 -1
  24. package/dist/component/convex.config.d.ts +4 -0
  25. package/dist/component/convex.config.js +9 -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/leads.d.ts +69 -3
  31. package/dist/component/leads.d.ts.map +1 -1
  32. package/dist/component/leads.js +419 -16
  33. package/dist/component/leads.js.map +1 -1
  34. package/dist/component/meta.d.ts +352 -0
  35. package/dist/component/meta.d.ts.map +1 -0
  36. package/dist/component/meta.js +1239 -0
  37. package/dist/component/meta.js.map +1 -0
  38. package/dist/component/metaCapi.d.ts +162 -0
  39. package/dist/component/metaCapi.d.ts.map +1 -0
  40. package/dist/component/metaCapi.js +754 -0
  41. package/dist/component/metaCapi.js.map +1 -0
  42. package/dist/component/metaFake.d.ts +5 -0
  43. package/dist/component/metaFake.d.ts.map +1 -0
  44. package/dist/component/metaFake.js +225 -0
  45. package/dist/component/metaFake.js.map +1 -0
  46. package/dist/component/metaGraph.d.ts +165 -0
  47. package/dist/component/metaGraph.d.ts.map +1 -0
  48. package/dist/component/metaGraph.js +507 -0
  49. package/dist/component/metaGraph.js.map +1 -0
  50. package/dist/component/metaSync.d.ts +186 -0
  51. package/dist/component/metaSync.d.ts.map +1 -0
  52. package/dist/component/metaSync.js +961 -0
  53. package/dist/component/metaSync.js.map +1 -0
  54. package/dist/component/pipeline.d.ts +98 -0
  55. package/dist/component/pipeline.d.ts.map +1 -0
  56. package/dist/component/pipeline.js +324 -0
  57. package/dist/component/pipeline.js.map +1 -0
  58. package/dist/component/schema.d.ts +404 -2
  59. package/dist/component/schema.d.ts.map +1 -1
  60. package/dist/component/schema.js +168 -2
  61. package/dist/component/schema.js.map +1 -1
  62. package/dist/component/secrets.d.ts +15 -0
  63. package/dist/component/secrets.d.ts.map +1 -0
  64. package/dist/component/secrets.js +67 -0
  65. package/dist/component/secrets.js.map +1 -0
  66. package/dist/component/validators.d.ts +1052 -15
  67. package/dist/component/validators.d.ts.map +1 -1
  68. package/dist/component/validators.js +307 -3
  69. package/dist/component/validators.js.map +1 -1
  70. package/dist/tracker/generated.d.ts +1 -1
  71. package/dist/tracker/generated.d.ts.map +1 -1
  72. package/dist/tracker/generated.js +1 -1
  73. package/dist/tracker/generated.js.map +1 -1
  74. package/dist/ui/campaigns/CampaignsView.d.ts +48 -0
  75. package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -0
  76. package/dist/ui/campaigns/CampaignsView.js +47 -0
  77. package/dist/ui/campaigns/CampaignsView.js.map +1 -0
  78. package/dist/ui/campaigns/Conversions.d.ts +16 -0
  79. package/dist/ui/campaigns/Conversions.d.ts.map +1 -0
  80. package/dist/ui/campaigns/Conversions.js +40 -0
  81. package/dist/ui/campaigns/Conversions.js.map +1 -0
  82. package/dist/ui/campaigns/Drawer.d.ts +24 -0
  83. package/dist/ui/campaigns/Drawer.d.ts.map +1 -0
  84. package/dist/ui/campaigns/Drawer.js +120 -0
  85. package/dist/ui/campaigns/Drawer.js.map +1 -0
  86. package/dist/ui/campaigns/Icons.d.ts +35 -0
  87. package/dist/ui/campaigns/Icons.d.ts.map +1 -0
  88. package/dist/ui/campaigns/Icons.js +41 -0
  89. package/dist/ui/campaigns/Icons.js.map +1 -0
  90. package/dist/ui/campaigns/Summary.d.ts +21 -0
  91. package/dist/ui/campaigns/Summary.d.ts.map +1 -0
  92. package/dist/ui/campaigns/Summary.js +56 -0
  93. package/dist/ui/campaigns/Summary.js.map +1 -0
  94. package/dist/ui/campaigns/Thumbnail.d.ts +11 -0
  95. package/dist/ui/campaigns/Thumbnail.d.ts.map +1 -0
  96. package/dist/ui/campaigns/Thumbnail.js +23 -0
  97. package/dist/ui/campaigns/Thumbnail.js.map +1 -0
  98. package/dist/ui/campaigns/Tree.d.ts +12 -0
  99. package/dist/ui/campaigns/Tree.d.ts.map +1 -0
  100. package/dist/ui/campaigns/Tree.js +105 -0
  101. package/dist/ui/campaigns/Tree.js.map +1 -0
  102. package/dist/ui/campaigns/glyphs.d.ts +20 -0
  103. package/dist/ui/campaigns/glyphs.d.ts.map +1 -0
  104. package/dist/ui/campaigns/glyphs.js +19 -0
  105. package/dist/ui/campaigns/glyphs.js.map +1 -0
  106. package/dist/ui/campaigns/index.d.ts +4 -0
  107. package/dist/ui/campaigns/index.d.ts.map +1 -0
  108. package/dist/ui/campaigns/index.js +3 -0
  109. package/dist/ui/campaigns/index.js.map +1 -0
  110. package/dist/ui/campaigns/labels.d.ts +53 -0
  111. package/dist/ui/campaigns/labels.d.ts.map +1 -0
  112. package/dist/ui/campaigns/labels.js +169 -0
  113. package/dist/ui/campaigns/labels.js.map +1 -0
  114. package/dist/ui/campaigns/model.d.ts +99 -0
  115. package/dist/ui/campaigns/model.d.ts.map +1 -0
  116. package/dist/ui/campaigns/model.js +230 -0
  117. package/dist/ui/campaigns/model.js.map +1 -0
  118. package/dist/ui/campaigns/modes.d.ts +18 -0
  119. package/dist/ui/campaigns/modes.d.ts.map +1 -0
  120. package/dist/ui/campaigns/modes.js +7 -0
  121. package/dist/ui/campaigns/modes.js.map +1 -0
  122. package/dist/ui/index.d.ts +21 -0
  123. package/dist/ui/index.d.ts.map +1 -0
  124. package/dist/ui/index.js +19 -0
  125. package/dist/ui/index.js.map +1 -0
  126. package/dist/ui/integrations/AccountPicker.d.ts +26 -0
  127. package/dist/ui/integrations/AccountPicker.d.ts.map +1 -0
  128. package/dist/ui/integrations/AccountPicker.js +97 -0
  129. package/dist/ui/integrations/AccountPicker.js.map +1 -0
  130. package/dist/ui/integrations/ClientCards.d.ts +17 -0
  131. package/dist/ui/integrations/ClientCards.d.ts.map +1 -0
  132. package/dist/ui/integrations/ClientCards.js +38 -0
  133. package/dist/ui/integrations/ClientCards.js.map +1 -0
  134. package/dist/ui/integrations/ClientsTable.d.ts +36 -0
  135. package/dist/ui/integrations/ClientsTable.d.ts.map +1 -0
  136. package/dist/ui/integrations/ClientsTable.js +127 -0
  137. package/dist/ui/integrations/ClientsTable.js.map +1 -0
  138. package/dist/ui/integrations/Connect.d.ts +29 -0
  139. package/dist/ui/integrations/Connect.d.ts.map +1 -0
  140. package/dist/ui/integrations/Connect.js +49 -0
  141. package/dist/ui/integrations/Connect.js.map +1 -0
  142. package/dist/ui/integrations/Connection.d.ts +37 -0
  143. package/dist/ui/integrations/Connection.d.ts.map +1 -0
  144. package/dist/ui/integrations/Connection.js +94 -0
  145. package/dist/ui/integrations/Connection.js.map +1 -0
  146. package/dist/ui/integrations/Dialogs.d.ts +18 -0
  147. package/dist/ui/integrations/Dialogs.d.ts.map +1 -0
  148. package/dist/ui/integrations/Dialogs.js +25 -0
  149. package/dist/ui/integrations/Dialogs.js.map +1 -0
  150. package/dist/ui/integrations/EventsFeed.d.ts +15 -0
  151. package/dist/ui/integrations/EventsFeed.d.ts.map +1 -0
  152. package/dist/ui/integrations/EventsFeed.js +57 -0
  153. package/dist/ui/integrations/EventsFeed.js.map +1 -0
  154. package/dist/ui/integrations/EventsSheet.d.ts +28 -0
  155. package/dist/ui/integrations/EventsSheet.d.ts.map +1 -0
  156. package/dist/ui/integrations/EventsSheet.js +121 -0
  157. package/dist/ui/integrations/EventsSheet.js.map +1 -0
  158. package/dist/ui/integrations/Icons.d.ts +36 -0
  159. package/dist/ui/integrations/Icons.d.ts.map +1 -0
  160. package/dist/ui/integrations/Icons.js +38 -0
  161. package/dist/ui/integrations/Icons.js.map +1 -0
  162. package/dist/ui/integrations/IntegrationsView.d.ts +14 -0
  163. package/dist/ui/integrations/IntegrationsView.d.ts.map +1 -0
  164. package/dist/ui/integrations/IntegrationsView.js +113 -0
  165. package/dist/ui/integrations/IntegrationsView.js.map +1 -0
  166. package/dist/ui/integrations/Overlay.d.ts +18 -0
  167. package/dist/ui/integrations/Overlay.d.ts.map +1 -0
  168. package/dist/ui/integrations/Overlay.js +36 -0
  169. package/dist/ui/integrations/Overlay.js.map +1 -0
  170. package/dist/ui/integrations/RowMenu.d.ts +15 -0
  171. package/dist/ui/integrations/RowMenu.d.ts.map +1 -0
  172. package/dist/ui/integrations/RowMenu.js +47 -0
  173. package/dist/ui/integrations/RowMenu.js.map +1 -0
  174. package/dist/ui/integrations/UnlinkDialog.d.ts +18 -0
  175. package/dist/ui/integrations/UnlinkDialog.d.ts.map +1 -0
  176. package/dist/ui/integrations/UnlinkDialog.js +29 -0
  177. package/dist/ui/integrations/UnlinkDialog.js.map +1 -0
  178. package/dist/ui/integrations/format.d.ts +28 -0
  179. package/dist/ui/integrations/format.d.ts.map +1 -0
  180. package/dist/ui/integrations/format.js +101 -0
  181. package/dist/ui/integrations/format.js.map +1 -0
  182. package/dist/ui/integrations/index.d.ts +5 -0
  183. package/dist/ui/integrations/index.d.ts.map +1 -0
  184. package/dist/ui/integrations/index.js +3 -0
  185. package/dist/ui/integrations/index.js.map +1 -0
  186. package/dist/ui/integrations/model.d.ts +84 -0
  187. package/dist/ui/integrations/model.d.ts.map +1 -0
  188. package/dist/ui/integrations/model.js +131 -0
  189. package/dist/ui/integrations/model.js.map +1 -0
  190. package/dist/ui/integrations/overlay.d.ts +10 -0
  191. package/dist/ui/integrations/overlay.d.ts.map +1 -0
  192. package/dist/ui/integrations/overlay.js +77 -0
  193. package/dist/ui/integrations/overlay.js.map +1 -0
  194. package/dist/ui/integrations/parts.d.ts +36 -0
  195. package/dist/ui/integrations/parts.d.ts.map +1 -0
  196. package/dist/ui/integrations/parts.js +85 -0
  197. package/dist/ui/integrations/parts.js.map +1 -0
  198. package/dist/ui/integrations/types.d.ts +96 -0
  199. package/dist/ui/integrations/types.d.ts.map +1 -0
  200. package/dist/ui/integrations/types.js +2 -0
  201. package/dist/ui/integrations/types.js.map +1 -0
  202. package/dist/ui/metrics.d.ts +106 -0
  203. package/dist/ui/metrics.d.ts.map +1 -0
  204. package/dist/ui/metrics.js +166 -0
  205. package/dist/ui/metrics.js.map +1 -0
  206. package/dist/ui/portfolio/PortfolioView.d.ts +26 -0
  207. package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -0
  208. package/dist/ui/portfolio/PortfolioView.js +216 -0
  209. package/dist/ui/portfolio/PortfolioView.js.map +1 -0
  210. package/dist/ui/portfolio/SpendChart.d.ts +10 -0
  211. package/dist/ui/portfolio/SpendChart.d.ts.map +1 -0
  212. package/dist/ui/portfolio/SpendChart.js +196 -0
  213. package/dist/ui/portfolio/SpendChart.js.map +1 -0
  214. package/dist/ui/portfolio/alerts.d.ts +74 -0
  215. package/dist/ui/portfolio/alerts.d.ts.map +1 -0
  216. package/dist/ui/portfolio/alerts.js +291 -0
  217. package/dist/ui/portfolio/alerts.js.map +1 -0
  218. package/dist/ui/portfolio/figures.d.ts +79 -0
  219. package/dist/ui/portfolio/figures.d.ts.map +1 -0
  220. package/dist/ui/portfolio/figures.js +103 -0
  221. package/dist/ui/portfolio/figures.js.map +1 -0
  222. package/dist/ui/portfolio/format.d.ts +8 -0
  223. package/dist/ui/portfolio/format.d.ts.map +1 -0
  224. package/dist/ui/portfolio/format.js +28 -0
  225. package/dist/ui/portfolio/format.js.map +1 -0
  226. package/dist/ui/sample.d.ts +474 -0
  227. package/dist/ui/sample.d.ts.map +1 -0
  228. package/dist/ui/sample.js +553 -0
  229. package/dist/ui/sample.js.map +1 -0
  230. package/dist/ui/seed.d.ts +188 -0
  231. package/dist/ui/seed.d.ts.map +1 -0
  232. package/dist/ui/seed.js +563 -0
  233. package/dist/ui/seed.js.map +1 -0
  234. package/dist/ui/ui.css +3356 -0
  235. package/docs/federation.md +46 -1
  236. package/docs/meta-ads.md +746 -0
  237. package/docs/upgrading.md +95 -0
  238. package/llms.txt +3 -0
  239. package/package.json +15 -3
  240. package/src/component/_generated/api.ts +14 -0
  241. package/src/component/_generated/component.ts +323 -1
  242. package/src/component/_generated/server.ts +4 -0
  243. package/src/component/constants.ts +10 -0
  244. package/src/component/convex.config.ts +9 -0
  245. package/src/component/errors.ts +5 -1
  246. package/src/component/leads.ts +517 -17
  247. package/src/component/meta.ts +1461 -0
  248. package/src/component/metaCapi.ts +921 -0
  249. package/src/component/metaFake.ts +277 -0
  250. package/src/component/metaGraph.ts +722 -0
  251. package/src/component/metaSync.ts +1164 -0
  252. package/src/component/pipeline.ts +392 -0
  253. package/src/component/schema.ts +197 -1
  254. package/src/component/secrets.ts +98 -0
  255. package/src/component/validators.ts +376 -3
  256. package/src/tracker/generated.ts +1 -1
  257. package/src/ui/seed.ts +755 -0
@@ -0,0 +1,746 @@
1
+ # Meta ads: spend, return and conversions
2
+
3
+ Rastro already counts the leads a host's server reports and files them by origin
4
+ and by campaign → ad set → ad (the README's "Leads from the host's server"). The
5
+ Meta ads layer puts money next to those counts. For each site it reads from the
6
+ Meta Marketing API what the linked ad accounts spent per ad and per day, with
7
+ impressions and clicks, and the campaign, ad set and ad names, status and
8
+ creative. With spend beside the leads, a screen shows what each campaign, ad and
9
+ site cost per lead, per qualified lead and per sale, and what it returned. In
10
+ the other direction, Rastro sends the host's sales, and the leads that reach the
11
+ board columns the host maps to an event, to the site's Meta pixel as
12
+ conversions, so Meta can optimize the ads for sales rather than for
13
+ conversations.
14
+
15
+ Everything stays in the host's deployment, like the rest of Rastro: the
16
+ encrypted tokens, the spend, the catalog and the conversion queue live in the
17
+ host's Rastro component, and a federated dashboard reads them through the host's
18
+ grant.
19
+
20
+ ## Availability
21
+
22
+ The Meta connection and the Conversions API sender ship in 0.10.0:
23
+
24
+ | Part | Now |
25
+ | ---------------------------------------------------------------------- | --------------------------------------------------------- |
26
+ | `metaAds` capability, `leads.daily`, `meta.status`, `meta.conversions` | Served; spend and status from the linked ad accounts |
27
+ | Spend fields and `bySite` in `leads.report` | Spend per row; ads that spent without leads listed |
28
+ | Connecting Meta, linking ad accounts, the pixel | Host-side `Rastro` methods; federated with `configure` |
29
+ | Facebook Login for Business, board columns and the event map | Served; a mapped column queues its event |
30
+ | Spend and catalog sync | One scheduler chain for every linked ad account |
31
+ | `RASTRO_SECRETS_KEY`, `RASTRO_META_FAKE`, `RASTRO_META_APP_ID/SECRET` | Optional env bindings |
32
+ | Conversions sent to the pixel | Sales and new leads from the lead methods; mapped columns |
33
+
34
+ ## Turn on the `metaAds` capability
35
+
36
+ A host opts in where it builds its federated surface, beside `leads`, and
37
+ re-exports the three new reads from `convex/rastroFederation.ts`:
38
+
39
+ ```ts
40
+ const federated = exposeFederatedAnalyticsApi(components.rastroAnalytics, {
41
+ issuer: federationIssuer,
42
+ resolveConnection,
43
+ leads: true,
44
+ metaAds: true,
45
+ });
46
+
47
+ export const {
48
+ // ...the functions the module already exports
49
+ leadsReport,
50
+ listLeads,
51
+ leadJourney,
52
+ leadsDaily,
53
+ metaStatus,
54
+ metaConversions,
55
+ } = federated;
56
+ ```
57
+
58
+ Deploy, then use **Verificar novamente** in the dashboard's Conexões so the
59
+ connection records the new capability.
60
+
61
+ - `leadsDaily` rides the `leads` capability: any host with `leads: true` serves
62
+ it.
63
+ - `metaStatus` and `metaConversions` need `metaAds: true`. Without it they fail
64
+ with `FEDERATION_INVALID_SCOPE` ("This host does not connect Meta ads") and
65
+ the manifest neither advertises `metaAds` nor names them, so a dashboard knows
66
+ to hide spend for that host.
67
+ - The spend fields of `leadsReport` need no flag. They stay `null` until a site
68
+ links an ad account. Spend only reaches readers through the lead reports, so a
69
+ host that turns on `metaAds` also passes `leads: true`.
70
+ - All of them are reads for any reader (`analytics:read`), inside the sites of
71
+ the host's grant. A public link's token is refused with
72
+ `FEDERATION_PUBLIC_FORBIDDEN`.
73
+
74
+ A host's own dashboard gets the same reads from `exposeAnalyticsApi`, whose
75
+ authorizer sees them as the operations `lead.daily`, `meta.status` and
76
+ `meta.conversions`. Server code calls the component through the `Rastro` class:
77
+ `leadsDaily`, `metaStatus` and `metaConversions`, typed as `LeadsDaily`,
78
+ `MetaStatus` and `MetaConversion`.
79
+
80
+ ## Reads
81
+
82
+ `null` means unknown, never zero. A spend of `null` says no synced ad account
83
+ covers that row: the site links none, its accounts are still in their first
84
+ backfill ("syncing"), the origin is organic, the accounts bill in two
85
+ currencies, or the host predates the field. A spend of `0` says a synced account
86
+ covered the row and spent nothing. The new fields are optional in the types so a
87
+ reader handles hosts on older releases; treat an absent field like `null`.
88
+
89
+ Money is in integer cents: Meta reports spend as a decimal string in the ad
90
+ account's currency, and Rastro stores it rounded to cents. All times are Unix
91
+ epoch milliseconds and ranges are inclusive, as in every other report.
92
+
93
+ ### `leads.report` (`leadsReport`)
94
+
95
+ The arguments are unchanged: one to ten unique `siteIds` and an inclusive
96
+ `from`/`to` of at most 366 UTC days. The answer gains:
97
+
98
+ - `spendCents`, `impressions` and `clicks` (`number | null`) on `totals`, on
99
+ each `origins` item and on every campaign, ad set and ad row. Among the
100
+ origins only `ctwa_ad`, the leads that came from an ad, can carry spend; the
101
+ organic ones stay `null`.
102
+ - On ad rows: `siteId`, the site the ad brought leads to, for multi-site
103
+ reports; `creativeType` (`"video"`, `"image"`, `"carousel"` or `"other"`) and
104
+ `adStatus` (Meta's effective status, such as `"ACTIVE"` or `"PAUSED"`), both
105
+ `null` until the catalog sync fills them. `thumbnailUrl` is served from the
106
+ host's storage, because the URLs Meta hands out expire.
107
+ - `bySite`: one row per requested site, in request order, with the site's
108
+ `leads`, `qualified`, `won` and `revenueCents`, its `spendCents`, and `paid`,
109
+ the same four counters for the site's `ctwa_ad` leads alone. `paid` is what
110
+ the spend bought. Each row also says, for that site alone, `spendCurrency`
111
+ (its synced accounts' currency, `null` without one) and `currencyMismatch`
112
+ (`true` when that currency is not the site's, or when its accounts bill in two
113
+ currencies, in which case its `spendCents` is `null`).
114
+ - `spendCurrency`: the currency of the spend in `totals` and the `ctwa_ad`
115
+ origin, `null` without spend. `currency` stays the sites' currency, the one
116
+ revenue is in. Spend adds up only in one currency: when the sites' accounts
117
+ bill in more than one, `totals`, the origins and the campaign tree carry
118
+ `null` spend, `spendCurrency` is `null` and each site's spend is still in its
119
+ `bySite` row.
120
+ - `currencyMismatch`: `true` when any site's spend is not comparable with its
121
+ revenue, or the sites' accounts bill in two currencies. Show the spend, and
122
+ compute no return from it.
123
+ - `spendIncomplete`: `true` when Meta's rows ran past their own read budget
124
+ (12,000 per-ad rows for the campaign tree, 12,000 site-day rows for the rest).
125
+ The campaign, ad set and ad spend is then `null` (all spend, in the far rarer
126
+ second case). Leads, revenue and, in the first case, the site totals stay
127
+ exact: linking Meta never makes a report fail.
128
+
129
+ An ad that spent in the range without bringing a lead is listed too, with zero
130
+ leads, which is how a screen finds the ads that burn budget. An ad of a synced
131
+ account that spent nothing in the range reports `0`; an ad no synced account
132
+ covers reports `null`. Spend reads on budgets of its own (see
133
+ `spendIncomplete`); only the lead rollups count against the report's 12,000 rows
134
+ and `REPORT_INCOMPLETE`.
135
+
136
+ ### `leads.daily` (`leadsDaily`)
137
+
138
+ The lead funnel per cohort day, for sparklines and "sales × spend per day"
139
+ charts.
140
+
141
+ - Arguments: one to ten unique `siteIds`, an inclusive `from`/`to` covering at
142
+ most 92 UTC days, and at most one filter: `adId` for one ad's leads, or
143
+ `origin` for one origin's (`"ctwa_ad"` for the leads the ads bought). Passing
144
+ both fails with `INVALID_ARGUMENT`. Without a filter the series counts every
145
+ lead.
146
+ - Answer: every UTC day the range touches, oldest first, empty days included
147
+ with zeros: `{ dayStart, leads, qualified, won, revenueCents, spendCents }`.
148
+ - Counting is by cohort, like `leads.report`: a qualification or sale lands on
149
+ the day its lead first arrived.
150
+ - `spendCents` is that day's spend for the same scope: the site's ads, one ad,
151
+ or, with `origin: "ctwa_ad"`, the ads again. It is `null` for an organic
152
+ origin and for sites no linked ad account covers. Spend keeps the ad account's
153
+ local date while cohorts use the UTC day, so a daily chart may show a day of
154
+ skew; totals over a week or more agree.
155
+
156
+ ### `meta.status` (`metaStatus`)
157
+
158
+ Where each site stands with Meta.
159
+
160
+ - Arguments: one to ten unique `siteIds`, and optionally `now`, the reader's
161
+ clock in milliseconds (a query never reads the clock), for the sent counts. A
162
+ host from before this field refuses an unknown argument, so a dashboard passes
163
+ `now` only to hosts on this version.
164
+ - Answer, one entry per site:
165
+
166
+ ```ts
167
+ {
168
+ siteId: string;
169
+ linked: boolean;
170
+ adAccounts: Array<{
171
+ adAccountId: string; // "act_…"
172
+ name: string;
173
+ currency: string;
174
+ timezone: string;
175
+ syncState: "backfill" | "live" | "error";
176
+ lastSyncAt: number | null;
177
+ lastError: string | null;
178
+ // The first backfill: 30-day blocks read of the total (3 for 90 days).
179
+ // null once the account has synced.
180
+ backfill?: { done: number; total: number } | null;
181
+ }>;
182
+ pixel: {
183
+ enabled: boolean;
184
+ pixelId: string | null;
185
+ source?: "connection" | "token" | null;
186
+ name?: string | null;
187
+ lastError?: string | null;
188
+ own?: boolean; // asked with an owner: whether that owner set the pixel
189
+ testMode?: boolean; // a test event code is set
190
+ }
191
+ conversions: {
192
+ pending: number;
193
+ sent: number;
194
+ failed: number;
195
+ // Accepted by Meta in the last 30 UTC days (today and the 29 before), by
196
+ // event, and today alone. null without `now`.
197
+ last30d?: {
198
+ Purchase: number;
199
+ QualifiedLead: number;
200
+ Lead: number;
201
+ Schedule: number;
202
+ } | null;
203
+ sentToday?: number | null;
204
+ }
205
+ }
206
+ ```
207
+
208
+ It never carries a token. `lastError` starts with a code: `META_TOKEN_INVALID`
209
+ (Meta refused the token; every account it fed stops until the host pastes a new
210
+ one), `RATE_LIMITED` (the sync is backing off), `META_UNAVAILABLE`,
211
+ `META_TRUNCATED`, `RUN_TIMEOUT` or `SECRETS_KEY_MISSING`. The `conversions`
212
+ counters count the site's whole send queue by status (see
213
+ [Conversions](#conversions)); a removed lead leaves them, and its sends leave
214
+ `last30d` and `sentToday`. Those two come from per-day counters, read through an
215
+ index (at most 31 days times 8 shards), never from a scan of the queue.
216
+ `backfill` reads the sync's own cursor: a blocked or slow backfill shows where
217
+ it stopped. With `testMode` the pixel has a `testEventCode`: its events reach
218
+ only Events Manager's Test Events, never the ads' optimization, so a screen
219
+ should say so ("modo de teste: as vendas vão só para o Test Events").
220
+
221
+ ### `meta.conversions` (`metaConversions`)
222
+
223
+ The conversions queued for or sent to the site's pixel, newest first, for a
224
+ "sent back to Meta" list.
225
+
226
+ - Arguments: one `siteId`, `limit` from 1 to 50 (default 50), and the cursor:
227
+ the last row's `createdAt` as `before` and its `eventId` as `beforeEventId`.
228
+ One call can queue two events in the same millisecond (a sale moved into a
229
+ mapped column), so pass both; `before` alone pages strictly before that time.
230
+ - Answer:
231
+
232
+ ```ts
233
+ Array<{
234
+ eventId: string;
235
+ eventName: "Lead" | "QualifiedLead" | "Schedule" | "Purchase";
236
+ leadId: string | null;
237
+ leadLabel: string | null;
238
+ adId: string | null;
239
+ adName: string | null; // from the ad catalog, when synced or named
240
+ valueCents: number | null;
241
+ status: "pending" | "sent" | "failed";
242
+ createdAt: number;
243
+ sentAt: number | null;
244
+ }>;
245
+ ```
246
+
247
+ It never carries the hashed user data a conversion was sent with. `leadLabel`
248
+ and `adId` are the lead's as it is now; `valueCents` is set on `Purchase` only.
249
+
250
+ ## Derived metrics
251
+
252
+ Rastro stores totals and never a ratio. Cost per lead, per qualified lead and
253
+ per sale, and the return on spend, come from the helpers in `@iann29/rastro/ui`,
254
+ so the dashboard and every host that embeds the screens compute them one way:
255
+
256
+ ```ts
257
+ import { formatReturn, returnBand, rowMetrics } from "@iann29/rastro/ui";
258
+
259
+ const site = report.bySite?.find((row) => row.siteId === siteId);
260
+ if (site && !report.currencyMismatch) {
261
+ const metrics = rowMetrics({ ...site.paid, spendCents: site.spendCents });
262
+ // { costPerLeadCents, costPerQualifiedCents, costPerSaleCents, returnOnSpend }
263
+ const label = formatReturn(metrics.returnOnSpend); // "9,3x" or "—"
264
+ const band = returnBand(metrics.returnOnSpend); // "strong", "ok", "weak" or null
265
+ }
266
+ ```
267
+
268
+ The helpers are pure and null-safe: without spend every figure is `null`, a
269
+ count of zero gives a `null` cost, and a spend of zero gives a `null` return.
270
+ `returnBand` starts `strong` at 8x and `ok` at 4x. `formatMoney` and
271
+ `formatReturn` print an em dash for an unknown value, never `R$ 0`.
272
+
273
+ Compute a cost or a return from counters the spend bought: an ad, ad set or
274
+ campaign row, the `ctwa_ad` origin, or a site's `paid`. The totals and a site's
275
+ own counters include organic leads, which cost nothing, so they would flatter
276
+ the ads. With `currencyMismatch` compute neither.
277
+
278
+ ## Host environment
279
+
280
+ Four optional env bindings come with the Meta connection. The host declares them
281
+ in its app env and passes them to the component the same way as GeoIP:
282
+
283
+ ```ts
284
+ // convex/convex.config.ts
285
+ const app = defineApp({
286
+ env: {
287
+ RASTRO_SECRETS_KEY: v.optional(v.string()),
288
+ RASTRO_META_FAKE: v.optional(v.literal("true")),
289
+ RASTRO_META_APP_ID: v.optional(v.string()),
290
+ RASTRO_META_APP_SECRET: v.optional(v.string()),
291
+ },
292
+ });
293
+ app.use(rastro, {
294
+ env: {
295
+ RASTRO_SECRETS_KEY: app.env.RASTRO_SECRETS_KEY,
296
+ RASTRO_META_FAKE: app.env.RASTRO_META_FAKE,
297
+ RASTRO_META_APP_ID: app.env.RASTRO_META_APP_ID,
298
+ RASTRO_META_APP_SECRET: app.env.RASTRO_META_APP_SECRET,
299
+ },
300
+ });
301
+ ```
302
+
303
+ - `RASTRO_SECRETS_KEY` encrypts the Meta tokens at rest: 32 random bytes,
304
+ base64-encoded, for example from `openssl rand -base64 32`. Store it as a
305
+ deployment secret, a new one per deployment, never in code or chat. Without
306
+ it, connecting Meta or a pixel is refused with `SECRETS_KEY_MISSING`, and
307
+ everything else keeps working. A new key cannot read the tokens stored under
308
+ the old one: connect again after a rotation.
309
+ - `RASTRO_META_FAKE=true` replaces the Meta Graph API with deterministic
310
+ fixtures, for the example host, demos and tests. Never set it on a customer
311
+ deployment.
312
+ - `RASTRO_META_APP_ID` and `RASTRO_META_APP_SECRET` are the Meta app behind
313
+ Facebook Login for Business (the "Amage Rastro" app). The secret lives only
314
+ here, in each host's env, as a deployment secret; the frontends carry the app
315
+ id and the login configuration id, which are public. Without both, the login
316
+ fails with `META_LOGIN_UNAVAILABLE` and a pasted token still connects.
317
+
318
+ ## Security invariants
319
+
320
+ - Tokens are encrypted at rest with the host's key (AES-GCM), and only the
321
+ component's actions decrypt them. No query returns a token, `meta.status`
322
+ included. To replace a token, paste the new one.
323
+ - Rastro never holds a phone number in plain text. To match a conversion, the
324
+ host sends SHA-256 hashes of the phone, and optionally of its own contact id,
325
+ with the lead or its stage change. They live only in the send queue, are
326
+ erased once delivery ends, sent or failed, and no read returns them.
327
+ - Federated writes need two keys. A dashboard connects Meta, links accounts,
328
+ sets the pixel and maps events only with `metaAds` on the host and
329
+ `analytics:configure` on both the token and the host's grant (owners and
330
+ admins), like goals. It always acts as its own Meta owner on the host,
331
+ `rastro:connection:<connectionId>`, derived from the authorized connection; no
332
+ argument names an owner. Board columns (`setPipeline`) stay host-only. The
333
+ writes over the whole owner (`connectMeta`, `refreshMetaAccounts`,
334
+ `disconnectMeta`) are refused with `FEDERATION_SITE_DENIED`, naming the sites,
335
+ while the owner still feeds a site the grant no longer covers.
336
+ - What leaves the host's server, exactly, when someone logs in:
337
+ - the two token exchanges (`POST /oauth/access_token`, the code's and the
338
+ long-lived trade's) carry the app id, the app secret and the code or the
339
+ short-lived token **in a form body**, never in a URL;
340
+ - `GET /debug_token`, only when an exchange did not say how long its token
341
+ lives: the app's token (`<app id>|<app secret>`) in the `Authorization`
342
+ header, and the token being inspected in the query, where Meta requires it;
343
+ - every other call carries the connection's token in the `Authorization`
344
+ header.
345
+
346
+ Nothing of it is logged or returned, and the authorization code is used once
347
+ and never stored.
348
+
349
+ - On the federated path a dashboard never takes or undoes what another owner on
350
+ the host made: linking an account another owner linked to the site, unlinking
351
+ another owner's link, and replacing or removing another owner's pixel are each
352
+ `CONFLICT`. The host's own `Rastro` methods can do all of it.
353
+ - A lead's ad click id stays in the host's deployment, as before; no report
354
+ returns it.
355
+ - Each conversion carries an `eventId` derived from the lead and the event, so a
356
+ retry never counts twice at Meta.
357
+
358
+ ## The Meta connection
359
+
360
+ Host-side methods of the `Rastro` class. With `metaAds: true` the federated
361
+ surface serves its own versions to the Rastro dashboard, acting for the
362
+ dashboard connection's owner (see
363
+ [Federated Meta functions](#federated-meta-functions)). Every call to Meta pins
364
+ the Graph API version `v25.0` and sends the token in a header, never in a URL.
365
+
366
+ ```ts
367
+ // An action: validates the token and stores it sealed.
368
+ const { adAccounts } = await rastro.setMetaConnection(ctx, {
369
+ ownerKey: `uzeai:affiliate:${affiliateId}`,
370
+ accessToken, // a system user token with ads_read
371
+ });
372
+ // A mutation: the account must be in that listing.
373
+ await rastro.linkSiteAdAccount(ctx, {
374
+ siteId,
375
+ ownerKey: `uzeai:affiliate:${affiliateId}`,
376
+ adAccountId: "act_1029384756",
377
+ });
378
+ ```
379
+
380
+ - `setMetaConnection` (action) validates a Meta token for an opaque `ownerKey`
381
+ of the host's choosing (`GET /me`), lists its ad accounts
382
+ (`GET /me/adaccounts`) and stores the token encrypted. The first release takes
383
+ a system user token with `ads_read` from the owner's own Meta app. Pasting a
384
+ new token for the same owner replaces the old one and resumes the accounts a
385
+ dead token had stopped. It fails with `SECRETS_KEY_MISSING` without the key
386
+ and `META_TOKEN_INVALID` when Meta refuses the token.
387
+ - `listMetaAdAccounts` (action) reads the accounts from Meta again and refreshes
388
+ the listing a link is checked against. `removeMetaConnection` forgets the
389
+ token and unlinks every account it fed.
390
+ - `linkSiteAdAccount` (mutation) links a site to one of the listed accounts, at
391
+ most 20 per site and 500 per connection. **An ad account feeds one site**: its
392
+ spend is that client's, so linking it to a second site fails with `CONFLICT`
393
+ (unlink it first). `unlinkSiteAdAccount` stops the account feeding the site
394
+ and deletes its spend there in scheduled batches, so its spend leaves the
395
+ reports; a sync in flight writes nothing more once the link is gone.
396
+ `syncMetaNow` asks for a sync of the site's accounts at once, or right after
397
+ the one running.
398
+ - `setSitePixel` (action) checks the Conversions API token reaches the pixel
399
+ (dataset) and stores it sealed; `removeSitePixel` forgets it. `sendLeadEvent`
400
+ (off by default) decides whether a new lead is sent as `Lead`.
401
+
402
+ ### The sync
403
+
404
+ Components have no crons, so a single scheduler chain syncs every linked
405
+ account. It takes the accounts whose next sync has come, four at a time, and
406
+ stops by itself when no account is linked; a link, a new token or `syncMetaNow`
407
+ starts it again. Per account, in the account's own timezone:
408
+
409
+ | When | Spend read | Catalog |
410
+ | ----------------------------- | ------------------------------- | --------------------------- |
411
+ | Right after linking | 90 days, one 30-day block a run | Every ad, with the last one |
412
+ | Every 45 minutes | Today and yesterday | Only ads with leads unnamed |
413
+ | Once a day, from 04:00 | The last 7 days | Every ad |
414
+ | Once a week, at the daily run | The last 28 days (Meta settles) | Every ad |
415
+
416
+ - Spend comes from `GET /act_…/insights?level=ad&time_increment=1` and is stored
417
+ per ad and account-local date, in integer cents. A resynced range replaces
418
+ what Meta no longer lists, so a day corrected to zero disappears.
419
+ - The catalog (`GET /act_…/ads`) fills names, `adStatus` and `creativeType`, and
420
+ copies each creative's thumbnail (320 px) into the host's storage once.
421
+ - The backfill resumes from its last finished block after a failure, and an
422
+ account back from a pause of more than 27 days backfills the gap first. Until
423
+ its first backfill ends, an account's spend reads as unknown.
424
+ - Rate limits: when any of Meta's usage headers passes 75%, or Meta throttles a
425
+ call, the account's next sync waits at least an hour (longer if Meta says so).
426
+ Other failures retry after 15 minutes, doubling up to 6 hours.
427
+ - A token Meta refuses stops every account it fed, unless the host pasted a new
428
+ one meanwhile. A missing `RASTRO_SECRETS_KEY` only delays the sync.
429
+ - A day with more rows than one call reads is read in smaller ranges, down to
430
+ single days; a day still over the cap keeps what came and the account shows
431
+ `META_TRUNCATED` in `lastError`.
432
+ - Each run holds its account under its own id: a run that outlives its 15-minute
433
+ lease is replaced (`RUN_TIMEOUT`) and its late writes are dropped.
434
+
435
+ ### Demos and tests
436
+
437
+ With `RASTRO_META_FAKE=true` the component answers from the sample agency of
438
+ `@iann29/rastro/ui/sample` instead of calling Meta: four ad accounts
439
+ (`act_1029384756` and three more), their catalog and the spend of every ad day
440
+ by day. Any token works except one containing `invalid`, which is refused like
441
+ an expired token. A token `rastro-fake-sample-token:<days>` moves every date by
442
+ that many whole days. The example host's `seedDemo` moves the whole sample so
443
+ its last day is yesterday (or the day before the `now` it is given), writes the
444
+ leads, and connects, links and syncs the agency through these same methods with
445
+ the matching token, so its demo dashboard shows the sample's numbers as its last
446
+ 30 days, sync after sync. `seedDemo({ agency: false })` seeds the web demo
447
+ alone, for tests that do not need the agency.
448
+
449
+ ## Facebook Login for Business
450
+
451
+ The way in for a manager: a Meta window asks them to choose the business, ad
452
+ accounts and pixels to share with the "Amage Rastro" app, and the screen gets
453
+ the connection without anyone copying a token. The pasted token
454
+ (`setMetaConnection`) stays as the fallback, and a pixel's own Conversions API
455
+ token as plan B.
456
+
457
+ 1. The frontend opens a popup on
458
+ `https://www.facebook.com/v25.0/dialog/oauth?client_id=<app id>&config_id=<login configuration id>&redirect_uri=<origin>/meta/retorno&response_type=code&state=<random>`.
459
+ 2. The callback page posts `{ code, state }` to the opener and closes. The
460
+ opener checks `state` against the one it drew.
461
+ 3. The opener sends `code` and the same `redirectUri` to the host, which calls
462
+ `connectMetaWithCode` (or, from the Rastro dashboard, the federated
463
+ `connectMeta`).
464
+
465
+ ```ts
466
+ // An action on the host: the owner is the host's to choose.
467
+ const { adAccounts, pixels } = await rastro.connectMetaWithCode(ctx, {
468
+ ownerKey: `uzeai:affiliate:${affiliateId}`,
469
+ code,
470
+ redirectUri: "https://app.example.com/meta/retorno",
471
+ });
472
+ ```
473
+
474
+ - `connectMetaWithCode` (action) trades the code for a token
475
+ (`POST /oauth/access_token`, the app id and secret in the form body). How long
476
+ the token lives comes from the answer (`expires_in`) or, when it says nothing,
477
+ from Meta (`GET /debug_token`). A **system user token** (Login for Business
478
+ with a system user configuration) never expires and is kept as it is. A token
479
+ with **under 7 days** left (a user token from a login without a system user
480
+ configuration lives for hours) is traded at once for a long-lived one
481
+ (`grant_type=fb_exchange_token`, about 60 days). If that trade fails, the
482
+ login still succeeds: the token that came is kept with its expiry, so
483
+ `expiringSoon` is up at once and the screen asks for a new login in time, and
484
+ the connection's `lastError` says `TOKEN_EXCHANGE_FAILED` (with nothing of
485
+ Meta's answer in it). Meta refusing the app's id or secret is
486
+ `META_LOGIN_UNAVAILABLE`, like a missing env. Then it takes the same path as a
487
+ pasted token: `/me`, the ad accounts, sealed with `RASTRO_SECRETS_KEY`. It
488
+ records who authorized (`/me`'s id and name), when, and when the token
489
+ expires. A code Meta refuses (used, expired, another redirect) fails with
490
+ `META_TOKEN_INVALID`; without the app env, `META_LOGIN_UNAVAILABLE`. The
491
+ redirect must be https (http only on `localhost`).
492
+ - Every connect and `listMetaAdAccounts` also lists the **pixels** the accounts
493
+ own (`GET /act_…/adspixels?fields=id,name`, for the first 100 accounts,
494
+ deduplicated). An account the token cannot read pixels for is skipped; when
495
+ Meta throttles or fails on one, the pixels already known are kept.
496
+ - Reconnecting, possibly with another business's token, lines the links and
497
+ pixels up with what the new token lists: a link to an account it does not list
498
+ stops with `AD_ACCOUNT_NOT_GRANTED` in `lastError`, a pixel it does not list
499
+ says `PIXEL_NOT_GRANTED` in `metaStatus` and sends nothing, and both resume as
500
+ soon as a connect or a refresh lists them again. Only the links of listed
501
+ accounts resume after a dead token.
502
+ - `metaConnection({ ownerKey })` (query) is what a screen reads: `null`, or the
503
+ status, `authorizedBy: { name }` (null for a pasted token), `authorizedAt`,
504
+ `invalidSince` (when Meta first refused the token), `lastError`, `expiresAt`
505
+ (null when the token never expires, or for a pasted token, whose expiry is not
506
+ asked), `expiringSoon` (fewer than 7 days left: the screen asks for a new
507
+ login; a scheduled run sets it, since a query reads no clock), the listed ad
508
+ accounts each with `linkedSiteId` (the site it already feeds, whoever linked
509
+ it: one account feeds one site) and the listed pixels. Never a token.
510
+ - `setSitePixel` takes either `{ ownerKey }`, a pixel from that connection's
511
+ listing that sends with the connection's token (a reconnect carries over), or
512
+ `{ accessToken }` as before. A pixel outside the listing fails with
513
+ `NOT_FOUND`. `metaStatus` reports `pixel.source` (`"connection"` or `"token"`)
514
+ and `pixel.name`. A connection Meta refuses stops its pixels; a token-sourced
515
+ pixel keeps sending. Removing the connection removes its links and pixels, in
516
+ scheduled batches. With `ownerKey`, `metaStatus` says for each link and the
517
+ pixel whether that owner made them (`own`), so a screen can lock the rest.
518
+ - `metaUnlinkPreview({ siteId, adAccountId })` (query) answers what unlinking
519
+ takes out of the site's reports:
520
+ `{ spendCents, currency, days, campaigns, partial }`, for the dialog that asks
521
+ before unlinking. `partial` means a read cap was hit and the figures are a
522
+ lower bound.
523
+
524
+ With `RASTRO_META_FAKE=true` any code logs in without the app env, except one
525
+ containing `invalid`, refused like an expired token. A code containing
526
+ `user-token` yields a short-lived user token that is traded for a 60-day one;
527
+ any other, a system user token that never expires; the sample agency lists the
528
+ pixels of Loja X and Pet Mania.
529
+
530
+ ### Board columns and the event map
531
+
532
+ Conversions follow the host's real board, not Rastro's four stages. The host
533
+ declares its columns; an owner picks which Meta event each one sends.
534
+
535
+ ```ts
536
+ await rastro.setPipeline(ctx, {
537
+ siteId,
538
+ columns: [
539
+ { key: "novo", name: "Novo lead", group: "Prospecção" },
540
+ {
541
+ key: "agendado",
542
+ name: "Agendado",
543
+ group: "Hora do Show",
544
+ color: "#f97316",
545
+ },
546
+ ],
547
+ });
548
+ await rastro.setLeadStage(ctx, {
549
+ siteId,
550
+ leadKey,
551
+ column: "agendado", // where the lead is now; `stage` may be left out
552
+ eventId,
553
+ occurredAt,
554
+ });
555
+ await rastro.setEventMap(ctx, {
556
+ siteId,
557
+ columns: { agendado: "Schedule", novo: null },
558
+ sendLeadEvent: false,
559
+ });
560
+ const map = await rastro.eventMap(ctx, { siteId, now: Date.now() });
561
+ ```
562
+
563
+ - `setPipeline` (mutation): at most 40 columns, in order, with a stable `key`,
564
+ the pt-BR `name` the host shows, and optional `color` and `group` (the block
565
+ the board shows it in). Declaring again replaces names and order; a column
566
+ that is gone leaves the map, one that stays keeps its event. Host-only: the
567
+ host owns its board.
568
+ - `recordLead` and `setLeadStage` take an optional `column`. A key the pipeline
569
+ does not declare is ignored, never an error, and so is a move into the column
570
+ the lead is already in or one older than the lead's last move: a late retry
571
+ never moves a lead back. `setLeadStage` accepts the column alone, for a move
572
+ that changes no stage, idempotent by its `eventId` like a stage change. Only
573
+ an accepted move counts and reaches the event map.
574
+ - `setEventMap` replaces the whole map: each declared column maps to `Lead`,
575
+ `QualifiedLead`, `Schedule` or null. A key the pipeline does not declare is
576
+ `INVALID_ARGUMENT`. `sendLeadEvent` turns `Lead` on for every new lead and is
577
+ kept on the pixel too.
578
+ - `eventMap({ siteId, now? })` answers the columns with `key`, `name`, `color`,
579
+ `group`, `event` and `monthCount`: the distinct leads that entered the column
580
+ in the 30 UTC days up to `now` (a lead that leaves and comes back counts
581
+ once). Pass the caller's clock (queries do not read one), rounded to the start
582
+ of the day as the dashboard does, so the subscription stays cached all day;
583
+ without `now`, or before the host sent any column, the count is null. Then
584
+ `sendLeadEvent`, and `purchase.enabled` (an enabled pixel).
585
+ - **`Purchase` never comes from a column.** It is sent when the host records the
586
+ sale (`setLeadStage` with `won` and its value). Screens show it as a fixed
587
+ row, "Venda registrada (com valor) → Compra".
588
+ - A lead entering a mapped column queues its event once per lead per event
589
+ (`eventId = sha256(leadId + ":" + eventName)`) through the Conversions API
590
+ queue below, when the move carries `meta`.
591
+
592
+ ### Federated Meta functions
593
+
594
+ With `metaAds: true`, the federated surface serves the same screen to the Rastro
595
+ dashboard. Every function acts for the dashboard connection's own owner,
596
+ `rastro:connection:<connectionId>`.
597
+
598
+ | Function | Kind | Needs |
599
+ | --------------------- | -------- | --------- |
600
+ | `metaConnection` | query | reader |
601
+ | `metaEventMap` | query | reader |
602
+ | `metaUnlinkPreview` | query | reader |
603
+ | `connectMeta` | action | configure |
604
+ | `refreshMetaAccounts` | action | configure |
605
+ | `setMetaPixel` | action | configure |
606
+ | `disconnectMeta` | mutation | configure |
607
+ | `linkMetaAdAccount` | mutation | configure |
608
+ | `unlinkMetaAdAccount` | mutation | configure |
609
+ | `removeMetaPixel` | mutation | configure |
610
+ | `setMetaEventMap` | mutation | configure |
611
+ | `syncMetaNow` | mutation | configure |
612
+
613
+ The three actions authorize through `authorizeMetaAction`, an internal query the
614
+ host exports from the same `convex/rastroFederation.ts` (an action has no
615
+ database for `resolveConnection`; the caller's identity travels with it). Add
616
+ all of them to the module's exports:
617
+
618
+ ```ts
619
+ export const {
620
+ // ...
621
+ metaConnection,
622
+ metaEventMap,
623
+ metaUnlinkPreview,
624
+ connectMeta,
625
+ disconnectMeta,
626
+ refreshMetaAccounts,
627
+ linkMetaAdAccount,
628
+ unlinkMetaAdAccount,
629
+ setMetaPixel,
630
+ removeMetaPixel,
631
+ setMetaEventMap,
632
+ syncMetaNow,
633
+ authorizeMetaAction,
634
+ } = federated;
635
+ ```
636
+
637
+ In `metaConnection`, an account linked to a site outside the grant answers
638
+ `linkedSiteId: null` with `linkedOutsideScope: true`, so the screen greys it out
639
+ without learning the site. `setMetaPixel` uses the connection's listing unless
640
+ it is given an `accessToken`, and enables the pixel unless told otherwise. The
641
+ federated `metaStatus` marks each link and the pixel with `own`: whether this
642
+ dashboard connection made them.
643
+
644
+ When the host revokes or deletes a dashboard connection's grant, it forgets the
645
+ Meta owner that connection acted as, in the same mutation:
646
+
647
+ ```ts
648
+ await rastro.removeFederatedMetaConnection(ctx, { connectionId });
649
+ // = rastro.removeMetaConnection(ctx, {
650
+ // ownerKey: federatedMetaOwnerKey(connectionId),
651
+ // })
652
+ ```
653
+
654
+ ### Conversions
655
+
656
+ `recordLead` and `setLeadStage` accept an optional `meta`: what the host knows
657
+ of the contact, every value already a SHA-256 hex digest (`sha256Hex` below is
658
+ the host's own helper).
659
+
660
+ ```ts
661
+ await rastro.setLeadStage(ctx, {
662
+ siteId,
663
+ leadKey: conversation._id,
664
+ stage: "won",
665
+ revenueCents: 45_990,
666
+ eventId: activity._id,
667
+ occurredAt: activity.createdAt,
668
+ meta: {
669
+ ph: [await sha256Hex(phoneE164Digits)], // "5511999991234"
670
+ externalId: await sha256Hex(contact._id),
671
+ },
672
+ });
673
+ ```
674
+
675
+ Hash the phone as Meta normalizes it: digits only, with the country code and no
676
+ `+`. `meta` never fails the call: anything but that shape with 64-character hex
677
+ digests (a plain phone number, more than 10 phones, a wrong type) is ignored as
678
+ a whole, the lead is recorded as usual and no conversion is queued. The
679
+ component never stores it, and the warning it logs never repeats it. The hash of
680
+ an empty string matches nobody and is left out.
681
+
682
+ With an enabled pixel whose token Meta has not refused, a call with `meta`
683
+ queues one conversion:
684
+
685
+ | Event | When |
686
+ | ----------------------------------- | -------------------------------------------------------------------------- |
687
+ | `Purchase` | `setLeadStage` with `won` and a value; `value` and `currency` (the site's) |
688
+ | `Lead` | `recordLead` creating the lead, only with `sendLeadEvent` on the pixel |
689
+ | `Lead`, `QualifiedLead`, `Schedule` | The lead enters a board column the site's event map points at |
690
+
691
+ `Purchase` never comes from a column: only a recorded sale sends it. And
692
+ `setLeadStage` with `qualified` sends nothing by itself: `QualifiedLead` comes
693
+ only from a board column the host's event map points at, because the host's
694
+ columns, not Rastro's four stages, say what "qualified" means for that business.
695
+ Without `meta`, without a pixel, or with the pixel off, nothing is queued and
696
+ the call succeeds as before.
697
+
698
+ Each event goes **once per lead**: its `eventId` is
699
+ `sha256(leadId + ":" + eventName)`, so a lead that comes back to a column, or a
700
+ `Lead` reached both as a new lead and through a column, is not sent twice. This
701
+ holds for `Purchase` too: its value is the lead's sale total as Rastro has it,
702
+ so a corrected value or a second sale of the same lead sent again would count
703
+ the money twice at Meta. A repeat purchase is a new lead for the host to record.
704
+
705
+ "Once" means queued or delivered. An event that **failed** for good is opened
706
+ again, with its attempts back to zero and the new hashes, when the host sends
707
+ `meta` for it again (the next stage change or column move that reaches it).
708
+ Inside the component, the column path is the exported
709
+ `enqueueColumnEvent(ctx, { siteId, leadId, eventName, at, meta })` in
710
+ `metaCapi.ts`.
711
+
712
+ The send starts at once (`POST /{pixelId}/events`, Graph API `v25.0`, the
713
+ pixel's token in the `Authorization` header) with `action_source: "chat"`,
714
+ `event_time` in seconds, `user_data.ph`, `user_data.external_id`, `country` (the
715
+ hash of `br`), `custom_data.value` and `currency` on `Purchase`, and the pixel's
716
+ `test_event_code` when set. Then:
717
+
718
+ - A failure retries after 1 minute and then 10 minutes: three attempts in all. A
719
+ permission error fails at once. Anything else that goes wrong in a send (not
720
+ an answer from Meta) counts as an attempt too, and the last one fails with
721
+ `lastError` starting `UNEXPECTED`.
722
+ - No event stays pending: while any is, a scheduler chain (no cron) checks every
723
+ 15 minutes or so for one whose attempt was due more than 15 minutes ago, a
724
+ send lost on the way, and sends it again, counting the lost attempt. Each send
725
+ takes a 15-minute lease on its attempt, so the chain never counts one that may
726
+ still be in flight. It keeps the limit of three attempts and the 7-day cap,
727
+ reads 25 rows at a time through an index, and stops by itself when nothing is
728
+ pending.
729
+ - A token Meta refuses stops whoever owns it, and only if it is still the stored
730
+ one: a token pasted (or a connection renewed) meanwhile keeps sending.
731
+ - A pixel's own token Meta refuses fails the event and stops the pixel: nothing
732
+ more is queued for the site until `setSitePixel` stores a token again. A
733
+ connection's token refused marks the connection invalid, which stops its
734
+ pixels and ad accounts until it is connected again.
735
+ - An event older than 7 days when its turn comes is not sent: it fails with
736
+ `EVENT_TOO_OLD`. A pixel removed or turned off before the send fails it with
737
+ `PIXEL_UNAVAILABLE`.
738
+ - Sent or failed, the event's hashes are erased; the row stays, without them, as
739
+ the site's conversion history (`meta.conversions`, the counters of
740
+ `meta.status`).
741
+ - `removeLead` deletes the lead's conversions with it.
742
+ - With the pixel's `testEventCode` set, every event carries it and Meta keeps it
743
+ in Test Events only; `meta.status` reports `pixel.testMode`.
744
+
745
+ Under `RASTRO_META_FAKE=true` every send is accepted (a token containing
746
+ `invalid` is refused like an expired one) and nothing leaves the deployment.