@iann29/rastro 0.9.0 → 0.10.1
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.
- package/README.md +9 -1
- package/agent/integration.md +23 -1
- package/agent/manifest.json +4 -4
- package/dist/client/federation.d.ts +114 -9
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +54 -2
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +642 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +391 -9
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +14 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +278 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +4 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/constants.d.ts +5 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +10 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +4 -0
- package/dist/component/convex.config.js +9 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/errors.d.ts +1 -1
- package/dist/component/errors.d.ts.map +1 -1
- package/dist/component/errors.js.map +1 -1
- package/dist/component/leads.d.ts +69 -3
- package/dist/component/leads.d.ts.map +1 -1
- package/dist/component/leads.js +419 -16
- package/dist/component/leads.js.map +1 -1
- package/dist/component/meta.d.ts +358 -0
- package/dist/component/meta.d.ts.map +1 -0
- package/dist/component/meta.js +1295 -0
- package/dist/component/meta.js.map +1 -0
- package/dist/component/metaCapi.d.ts +162 -0
- package/dist/component/metaCapi.d.ts.map +1 -0
- package/dist/component/metaCapi.js +754 -0
- package/dist/component/metaCapi.js.map +1 -0
- package/dist/component/metaFake.d.ts +5 -0
- package/dist/component/metaFake.d.ts.map +1 -0
- package/dist/component/metaFake.js +225 -0
- package/dist/component/metaFake.js.map +1 -0
- package/dist/component/metaGraph.d.ts +165 -0
- package/dist/component/metaGraph.d.ts.map +1 -0
- package/dist/component/metaGraph.js +507 -0
- package/dist/component/metaGraph.js.map +1 -0
- package/dist/component/metaSync.d.ts +186 -0
- package/dist/component/metaSync.d.ts.map +1 -0
- package/dist/component/metaSync.js +961 -0
- package/dist/component/metaSync.js.map +1 -0
- package/dist/component/pipeline.d.ts +99 -0
- package/dist/component/pipeline.d.ts.map +1 -0
- package/dist/component/pipeline.js +337 -0
- package/dist/component/pipeline.js.map +1 -0
- package/dist/component/schema.d.ts +404 -2
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +168 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/component/secrets.d.ts +15 -0
- package/dist/component/secrets.d.ts.map +1 -0
- package/dist/component/secrets.js +67 -0
- package/dist/component/secrets.js.map +1 -0
- package/dist/component/validators.d.ts +1052 -15
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +307 -3
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +1 -1
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +1 -1
- package/dist/tracker/generated.js.map +1 -1
- package/dist/ui/campaigns/CampaignsView.d.ts +48 -0
- package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -0
- package/dist/ui/campaigns/CampaignsView.js +47 -0
- package/dist/ui/campaigns/CampaignsView.js.map +1 -0
- package/dist/ui/campaigns/Conversions.d.ts +16 -0
- package/dist/ui/campaigns/Conversions.d.ts.map +1 -0
- package/dist/ui/campaigns/Conversions.js +40 -0
- package/dist/ui/campaigns/Conversions.js.map +1 -0
- package/dist/ui/campaigns/Drawer.d.ts +24 -0
- package/dist/ui/campaigns/Drawer.d.ts.map +1 -0
- package/dist/ui/campaigns/Drawer.js +120 -0
- package/dist/ui/campaigns/Drawer.js.map +1 -0
- package/dist/ui/campaigns/Icons.d.ts +35 -0
- package/dist/ui/campaigns/Icons.d.ts.map +1 -0
- package/dist/ui/campaigns/Icons.js +41 -0
- package/dist/ui/campaigns/Icons.js.map +1 -0
- package/dist/ui/campaigns/Summary.d.ts +21 -0
- package/dist/ui/campaigns/Summary.d.ts.map +1 -0
- package/dist/ui/campaigns/Summary.js +56 -0
- package/dist/ui/campaigns/Summary.js.map +1 -0
- package/dist/ui/campaigns/Thumbnail.d.ts +11 -0
- package/dist/ui/campaigns/Thumbnail.d.ts.map +1 -0
- package/dist/ui/campaigns/Thumbnail.js +23 -0
- package/dist/ui/campaigns/Thumbnail.js.map +1 -0
- package/dist/ui/campaigns/Tree.d.ts +12 -0
- package/dist/ui/campaigns/Tree.d.ts.map +1 -0
- package/dist/ui/campaigns/Tree.js +105 -0
- package/dist/ui/campaigns/Tree.js.map +1 -0
- package/dist/ui/campaigns/glyphs.d.ts +20 -0
- package/dist/ui/campaigns/glyphs.d.ts.map +1 -0
- package/dist/ui/campaigns/glyphs.js +19 -0
- package/dist/ui/campaigns/glyphs.js.map +1 -0
- package/dist/ui/campaigns/index.d.ts +4 -0
- package/dist/ui/campaigns/index.d.ts.map +1 -0
- package/dist/ui/campaigns/index.js +3 -0
- package/dist/ui/campaigns/index.js.map +1 -0
- package/dist/ui/campaigns/labels.d.ts +53 -0
- package/dist/ui/campaigns/labels.d.ts.map +1 -0
- package/dist/ui/campaigns/labels.js +169 -0
- package/dist/ui/campaigns/labels.js.map +1 -0
- package/dist/ui/campaigns/model.d.ts +99 -0
- package/dist/ui/campaigns/model.d.ts.map +1 -0
- package/dist/ui/campaigns/model.js +230 -0
- package/dist/ui/campaigns/model.js.map +1 -0
- package/dist/ui/campaigns/modes.d.ts +18 -0
- package/dist/ui/campaigns/modes.d.ts.map +1 -0
- package/dist/ui/campaigns/modes.js +7 -0
- package/dist/ui/campaigns/modes.js.map +1 -0
- package/dist/ui/index.d.ts +21 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +20 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/integrations/AccountPicker.d.ts +26 -0
- package/dist/ui/integrations/AccountPicker.d.ts.map +1 -0
- package/dist/ui/integrations/AccountPicker.js +101 -0
- package/dist/ui/integrations/AccountPicker.js.map +1 -0
- package/dist/ui/integrations/ClientCards.d.ts +17 -0
- package/dist/ui/integrations/ClientCards.d.ts.map +1 -0
- package/dist/ui/integrations/ClientCards.js +42 -0
- package/dist/ui/integrations/ClientCards.js.map +1 -0
- package/dist/ui/integrations/ClientsTable.d.ts +36 -0
- package/dist/ui/integrations/ClientsTable.d.ts.map +1 -0
- package/dist/ui/integrations/ClientsTable.js +143 -0
- package/dist/ui/integrations/ClientsTable.js.map +1 -0
- package/dist/ui/integrations/Connect.d.ts +35 -0
- package/dist/ui/integrations/Connect.d.ts.map +1 -0
- package/dist/ui/integrations/Connect.js +62 -0
- package/dist/ui/integrations/Connect.js.map +1 -0
- package/dist/ui/integrations/Connection.d.ts +37 -0
- package/dist/ui/integrations/Connection.d.ts.map +1 -0
- package/dist/ui/integrations/Connection.js +108 -0
- package/dist/ui/integrations/Connection.js.map +1 -0
- package/dist/ui/integrations/Dialogs.d.ts +21 -0
- package/dist/ui/integrations/Dialogs.d.ts.map +1 -0
- package/dist/ui/integrations/Dialogs.js +34 -0
- package/dist/ui/integrations/Dialogs.js.map +1 -0
- package/dist/ui/integrations/EventsFeed.d.ts +15 -0
- package/dist/ui/integrations/EventsFeed.d.ts.map +1 -0
- package/dist/ui/integrations/EventsFeed.js +61 -0
- package/dist/ui/integrations/EventsFeed.js.map +1 -0
- package/dist/ui/integrations/EventsSheet.d.ts +34 -0
- package/dist/ui/integrations/EventsSheet.d.ts.map +1 -0
- package/dist/ui/integrations/EventsSheet.js +136 -0
- package/dist/ui/integrations/EventsSheet.js.map +1 -0
- package/dist/ui/integrations/Icons.d.ts +38 -0
- package/dist/ui/integrations/Icons.d.ts.map +1 -0
- package/dist/ui/integrations/Icons.js +40 -0
- package/dist/ui/integrations/Icons.js.map +1 -0
- package/dist/ui/integrations/IntegrationsView.d.ts +15 -0
- package/dist/ui/integrations/IntegrationsView.d.ts.map +1 -0
- package/dist/ui/integrations/IntegrationsView.js +141 -0
- package/dist/ui/integrations/IntegrationsView.js.map +1 -0
- package/dist/ui/integrations/Overlay.d.ts +18 -0
- package/dist/ui/integrations/Overlay.d.ts.map +1 -0
- package/dist/ui/integrations/Overlay.js +36 -0
- package/dist/ui/integrations/Overlay.js.map +1 -0
- package/dist/ui/integrations/Partner.d.ts +14 -0
- package/dist/ui/integrations/Partner.d.ts.map +1 -0
- package/dist/ui/integrations/Partner.js +38 -0
- package/dist/ui/integrations/Partner.js.map +1 -0
- package/dist/ui/integrations/RowMenu.d.ts +15 -0
- package/dist/ui/integrations/RowMenu.d.ts.map +1 -0
- package/dist/ui/integrations/RowMenu.js +47 -0
- package/dist/ui/integrations/RowMenu.js.map +1 -0
- package/dist/ui/integrations/UnlinkDialog.d.ts +18 -0
- package/dist/ui/integrations/UnlinkDialog.d.ts.map +1 -0
- package/dist/ui/integrations/UnlinkDialog.js +29 -0
- package/dist/ui/integrations/UnlinkDialog.js.map +1 -0
- package/dist/ui/integrations/audience.d.ts +12 -0
- package/dist/ui/integrations/audience.d.ts.map +1 -0
- package/dist/ui/integrations/audience.js +12 -0
- package/dist/ui/integrations/audience.js.map +1 -0
- package/dist/ui/integrations/format.d.ts +28 -0
- package/dist/ui/integrations/format.d.ts.map +1 -0
- package/dist/ui/integrations/format.js +101 -0
- package/dist/ui/integrations/format.js.map +1 -0
- package/dist/ui/integrations/index.d.ts +5 -0
- package/dist/ui/integrations/index.d.ts.map +1 -0
- package/dist/ui/integrations/index.js +3 -0
- package/dist/ui/integrations/index.js.map +1 -0
- package/dist/ui/integrations/modal.d.ts +10 -0
- package/dist/ui/integrations/modal.d.ts.map +1 -0
- package/dist/ui/integrations/modal.js +77 -0
- package/dist/ui/integrations/modal.js.map +1 -0
- package/dist/ui/integrations/model.d.ts +99 -0
- package/dist/ui/integrations/model.d.ts.map +1 -0
- package/dist/ui/integrations/model.js +164 -0
- package/dist/ui/integrations/model.js.map +1 -0
- package/dist/ui/integrations/parts.d.ts +36 -0
- package/dist/ui/integrations/parts.d.ts.map +1 -0
- package/dist/ui/integrations/parts.js +85 -0
- package/dist/ui/integrations/parts.js.map +1 -0
- package/dist/ui/integrations/types.d.ts +146 -0
- package/dist/ui/integrations/types.d.ts.map +1 -0
- package/dist/ui/integrations/types.js +2 -0
- package/dist/ui/integrations/types.js.map +1 -0
- package/dist/ui/metrics.d.ts +106 -0
- package/dist/ui/metrics.d.ts.map +1 -0
- package/dist/ui/metrics.js +166 -0
- package/dist/ui/metrics.js.map +1 -0
- package/dist/ui/portfolio/PortfolioView.d.ts +26 -0
- package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -0
- package/dist/ui/portfolio/PortfolioView.js +216 -0
- package/dist/ui/portfolio/PortfolioView.js.map +1 -0
- package/dist/ui/portfolio/SpendChart.d.ts +10 -0
- package/dist/ui/portfolio/SpendChart.d.ts.map +1 -0
- package/dist/ui/portfolio/SpendChart.js +196 -0
- package/dist/ui/portfolio/SpendChart.js.map +1 -0
- package/dist/ui/portfolio/alerts.d.ts +74 -0
- package/dist/ui/portfolio/alerts.d.ts.map +1 -0
- package/dist/ui/portfolio/alerts.js +291 -0
- package/dist/ui/portfolio/alerts.js.map +1 -0
- package/dist/ui/portfolio/figures.d.ts +79 -0
- package/dist/ui/portfolio/figures.d.ts.map +1 -0
- package/dist/ui/portfolio/figures.js +103 -0
- package/dist/ui/portfolio/figures.js.map +1 -0
- package/dist/ui/portfolio/format.d.ts +8 -0
- package/dist/ui/portfolio/format.d.ts.map +1 -0
- package/dist/ui/portfolio/format.js +28 -0
- package/dist/ui/portfolio/format.js.map +1 -0
- package/dist/ui/sample.d.ts +536 -0
- package/dist/ui/sample.d.ts.map +1 -0
- package/dist/ui/sample.js +574 -0
- package/dist/ui/sample.js.map +1 -0
- package/dist/ui/seed.d.ts +188 -0
- package/dist/ui/seed.d.ts.map +1 -0
- package/dist/ui/seed.js +563 -0
- package/dist/ui/seed.js.map +1 -0
- package/dist/ui/ui.css +3424 -0
- package/docs/federation.md +46 -1
- package/docs/meta-ads.md +795 -0
- package/docs/upgrading.md +116 -0
- package/llms.txt +3 -0
- package/package.json +15 -3
- package/src/component/_generated/api.ts +14 -0
- package/src/component/_generated/component.ts +324 -1
- package/src/component/_generated/server.ts +4 -0
- package/src/component/constants.ts +10 -0
- package/src/component/convex.config.ts +9 -0
- package/src/component/errors.ts +5 -1
- package/src/component/leads.ts +517 -17
- package/src/component/meta.ts +1525 -0
- package/src/component/metaCapi.ts +921 -0
- package/src/component/metaFake.ts +277 -0
- package/src/component/metaGraph.ts +722 -0
- package/src/component/metaSync.ts +1164 -0
- package/src/component/pipeline.ts +410 -0
- package/src/component/schema.ts +197 -1
- package/src/component/secrets.ts +98 -0
- package/src/component/validators.ts +376 -3
- package/src/tracker/generated.ts +1 -1
- package/src/ui/seed.ts +755 -0
package/docs/meta-ads.md
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
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, replacing or removing another owner's pixel, and saving
|
|
352
|
+
the event map of a site whose pixel another owner set are each `CONFLICT`.
|
|
353
|
+
Whoever set the pixel decides which columns reach it. The host's own `Rastro`
|
|
354
|
+
methods can do all of it, or refuse the same way when asked: `onlyOwn` on
|
|
355
|
+
`linkSiteAdAccount` and `setSitePixel`, an `ownerKey` on
|
|
356
|
+
`unlinkSiteAdAccount`, `removeSitePixel` and `setEventMap`.
|
|
357
|
+
- A lead's ad click id stays in the host's deployment, as before; no report
|
|
358
|
+
returns it.
|
|
359
|
+
- Each conversion carries an `eventId` derived from the lead and the event, so a
|
|
360
|
+
retry never counts twice at Meta.
|
|
361
|
+
|
|
362
|
+
## The Meta connection
|
|
363
|
+
|
|
364
|
+
Host-side methods of the `Rastro` class. With `metaAds: true` the federated
|
|
365
|
+
surface serves its own versions to the Rastro dashboard, acting for the
|
|
366
|
+
dashboard connection's owner (see
|
|
367
|
+
[Federated Meta functions](#federated-meta-functions)). Every call to Meta pins
|
|
368
|
+
the Graph API version `v25.0` and sends the token in a header, never in a URL.
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
// An action: validates the token and stores it sealed.
|
|
372
|
+
const { adAccounts } = await rastro.setMetaConnection(ctx, {
|
|
373
|
+
ownerKey: `uzeai:affiliate:${affiliateId}`,
|
|
374
|
+
accessToken, // a system user token with ads_read
|
|
375
|
+
});
|
|
376
|
+
// A mutation: the account must be in that listing.
|
|
377
|
+
await rastro.linkSiteAdAccount(ctx, {
|
|
378
|
+
siteId,
|
|
379
|
+
ownerKey: `uzeai:affiliate:${affiliateId}`,
|
|
380
|
+
adAccountId: "act_1029384756",
|
|
381
|
+
});
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
- `setMetaConnection` (action) validates a Meta token for an opaque `ownerKey`
|
|
385
|
+
of the host's choosing (`GET /me`), lists its ad accounts
|
|
386
|
+
(`GET /me/adaccounts`) and stores the token encrypted. The first release takes
|
|
387
|
+
a system user token with `ads_read` from the owner's own Meta app. Pasting a
|
|
388
|
+
new token for the same owner replaces the old one and resumes the accounts a
|
|
389
|
+
dead token had stopped. It fails with `SECRETS_KEY_MISSING` without the key
|
|
390
|
+
and `META_TOKEN_INVALID` when Meta refuses the token.
|
|
391
|
+
- `listMetaAdAccounts` (action) reads the accounts from Meta again and refreshes
|
|
392
|
+
the listing a link is checked against. `removeMetaConnection` forgets the
|
|
393
|
+
owner: its token, every account it fed, and every pixel it set, the ones with
|
|
394
|
+
their own token (plan B, `setBy`) included. An owner with only plan B pixels
|
|
395
|
+
and no connection loses those pixels all the same; `removed` says whether
|
|
396
|
+
anything went.
|
|
397
|
+
- `linkSiteAdAccount` (mutation) links a site to one of the listed accounts, at
|
|
398
|
+
most 20 per site and 500 per connection. **An ad account feeds one site**: its
|
|
399
|
+
spend is that client's, so linking it to a second site fails with `CONFLICT`
|
|
400
|
+
(unlink it first). `unlinkSiteAdAccount` stops the account feeding the site
|
|
401
|
+
and deletes its spend there in scheduled batches, so its spend leaves the
|
|
402
|
+
reports; a sync in flight writes nothing more once the link is gone.
|
|
403
|
+
`syncMetaNow` asks for a sync of the site's accounts at once, or right after
|
|
404
|
+
the one running.
|
|
405
|
+
- `setSitePixel` (action) checks the Conversions API token reaches the pixel
|
|
406
|
+
(dataset) and stores it sealed; `removeSitePixel` forgets it. `sendLeadEvent`
|
|
407
|
+
(off by default) decides whether a new lead is sent as `Lead`.
|
|
408
|
+
|
|
409
|
+
### The sync
|
|
410
|
+
|
|
411
|
+
Components have no crons, so a single scheduler chain syncs every linked
|
|
412
|
+
account. It takes the accounts whose next sync has come, four at a time, and
|
|
413
|
+
stops by itself when no account is linked; a link, a new token or `syncMetaNow`
|
|
414
|
+
starts it again. Per account, in the account's own timezone:
|
|
415
|
+
|
|
416
|
+
| When | Spend read | Catalog |
|
|
417
|
+
| ----------------------------- | ------------------------------- | --------------------------- |
|
|
418
|
+
| Right after linking | 90 days, one 30-day block a run | Every ad, with the last one |
|
|
419
|
+
| Every 45 minutes | Today and yesterday | Only ads with leads unnamed |
|
|
420
|
+
| Once a day, from 04:00 | The last 7 days | Every ad |
|
|
421
|
+
| Once a week, at the daily run | The last 28 days (Meta settles) | Every ad |
|
|
422
|
+
|
|
423
|
+
- Spend comes from `GET /act_…/insights?level=ad&time_increment=1` and is stored
|
|
424
|
+
per ad and account-local date, in integer cents. A resynced range replaces
|
|
425
|
+
what Meta no longer lists, so a day corrected to zero disappears.
|
|
426
|
+
- The catalog (`GET /act_…/ads`) fills names, `adStatus` and `creativeType`, and
|
|
427
|
+
copies each creative's thumbnail (320 px) into the host's storage once.
|
|
428
|
+
- The backfill resumes from its last finished block after a failure, and an
|
|
429
|
+
account back from a pause of more than 27 days backfills the gap first. Until
|
|
430
|
+
its first backfill ends, an account's spend reads as unknown.
|
|
431
|
+
- Rate limits: when any of Meta's usage headers passes 75%, or Meta throttles a
|
|
432
|
+
call, the account's next sync waits at least an hour (longer if Meta says so).
|
|
433
|
+
Other failures retry after 15 minutes, doubling up to 6 hours.
|
|
434
|
+
- A token Meta refuses stops every account it fed, unless the host pasted a new
|
|
435
|
+
one meanwhile. A missing `RASTRO_SECRETS_KEY` only delays the sync.
|
|
436
|
+
- A day with more rows than one call reads is read in smaller ranges, down to
|
|
437
|
+
single days; a day still over the cap keeps what came and the account shows
|
|
438
|
+
`META_TRUNCATED` in `lastError`.
|
|
439
|
+
- Each run holds its account under its own id: a run that outlives its 15-minute
|
|
440
|
+
lease is replaced (`RUN_TIMEOUT`) and its late writes are dropped.
|
|
441
|
+
|
|
442
|
+
### Demos and tests
|
|
443
|
+
|
|
444
|
+
With `RASTRO_META_FAKE=true` the component answers from the sample agency of
|
|
445
|
+
`@iann29/rastro/ui/sample` instead of calling Meta: four ad accounts
|
|
446
|
+
(`act_1029384756` and three more), their catalog and the spend of every ad day
|
|
447
|
+
by day. Any token works except one containing `invalid`, which is refused like
|
|
448
|
+
an expired token. A token `rastro-fake-sample-token:<days>` moves every date by
|
|
449
|
+
that many whole days. The example host's `seedDemo` moves the whole sample so
|
|
450
|
+
its last day is yesterday (or the day before the `now` it is given), writes the
|
|
451
|
+
leads, and connects, links and syncs the agency through these same methods with
|
|
452
|
+
the matching token, so its demo dashboard shows the sample's numbers as its last
|
|
453
|
+
30 days, sync after sync. `seedDemo({ agency: false })` seeds the web demo
|
|
454
|
+
alone, for tests that do not need the agency.
|
|
455
|
+
|
|
456
|
+
## Facebook Login for Business
|
|
457
|
+
|
|
458
|
+
The way in for a manager: a Meta window asks them to choose the business, ad
|
|
459
|
+
accounts and pixels to share with the "Amage Rastro" app, and the screen gets
|
|
460
|
+
the connection without anyone copying a token. The pasted token
|
|
461
|
+
(`setMetaConnection`) stays as the fallback, and a pixel's own Conversions API
|
|
462
|
+
token as plan B.
|
|
463
|
+
|
|
464
|
+
1. The frontend opens a popup on
|
|
465
|
+
`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>`.
|
|
466
|
+
2. The callback page posts `{ code, state }` to the opener and closes. The
|
|
467
|
+
opener checks `state` against the one it drew.
|
|
468
|
+
3. The opener sends `code` and the same `redirectUri` to the host, which calls
|
|
469
|
+
`connectMetaWithCode` (or, from the Rastro dashboard, the federated
|
|
470
|
+
`connectMeta`).
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
// An action on the host: the owner is the host's to choose.
|
|
474
|
+
const { adAccounts, pixels } = await rastro.connectMetaWithCode(ctx, {
|
|
475
|
+
ownerKey: `uzeai:affiliate:${affiliateId}`,
|
|
476
|
+
code,
|
|
477
|
+
redirectUri: "https://app.example.com/meta/retorno",
|
|
478
|
+
});
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
- `connectMetaWithCode` (action) trades the code for a token
|
|
482
|
+
(`POST /oauth/access_token`, the app id and secret in the form body). How long
|
|
483
|
+
the token lives comes from the answer (`expires_in`) or, when it says nothing,
|
|
484
|
+
from Meta (`GET /debug_token`). A **system user token** (Login for Business
|
|
485
|
+
with a system user configuration) never expires and is kept as it is. A token
|
|
486
|
+
with **under 7 days** left (a user token from a login without a system user
|
|
487
|
+
configuration lives for hours) is traded at once for a long-lived one
|
|
488
|
+
(`grant_type=fb_exchange_token`, about 60 days). If that trade fails, the
|
|
489
|
+
login still succeeds: the token that came is kept with its expiry, so
|
|
490
|
+
`expiringSoon` is up at once and the screen asks for a new login in time, and
|
|
491
|
+
the connection's `lastError` says `TOKEN_EXCHANGE_FAILED` (with nothing of
|
|
492
|
+
Meta's answer in it). Meta refusing the app's id or secret is
|
|
493
|
+
`META_LOGIN_UNAVAILABLE`, like a missing env. Then it takes the same path as a
|
|
494
|
+
pasted token: `/me`, the ad accounts, sealed with `RASTRO_SECRETS_KEY`. It
|
|
495
|
+
records who authorized (`/me`'s id and name), when, and when the token
|
|
496
|
+
expires. A code Meta refuses (used, expired, another redirect) fails with
|
|
497
|
+
`META_TOKEN_INVALID`; without the app env, `META_LOGIN_UNAVAILABLE`. The
|
|
498
|
+
redirect must be https (http only on `localhost`).
|
|
499
|
+
- Every connect and `listMetaAdAccounts` also lists the **pixels** the accounts
|
|
500
|
+
own (`GET /act_…/adspixels?fields=id,name`, for the first 100 accounts,
|
|
501
|
+
deduplicated). An account the token cannot read pixels for is skipped; when
|
|
502
|
+
Meta throttles or fails on one, the pixels already known are kept.
|
|
503
|
+
- Reconnecting, possibly with another business's token, lines the links and
|
|
504
|
+
pixels up with what the new token lists: a link to an account it does not list
|
|
505
|
+
stops with `AD_ACCOUNT_NOT_GRANTED` in `lastError`, a pixel it does not list
|
|
506
|
+
says `PIXEL_NOT_GRANTED` in `metaStatus` and sends nothing, and both resume as
|
|
507
|
+
soon as a connect or a refresh lists them again. Only the links of listed
|
|
508
|
+
accounts resume after a dead token.
|
|
509
|
+
- `metaConnection({ ownerKey })` (query) is what a screen reads: `null`, or the
|
|
510
|
+
status, `authorizedBy: { name }` (null for a pasted token), `authorizedAt`,
|
|
511
|
+
`invalidSince` (when Meta first refused the token), `lastError`, `expiresAt`
|
|
512
|
+
(null when the token never expires, or for a pasted token, whose expiry is not
|
|
513
|
+
asked), `expiringSoon` (fewer than 7 days left: the screen asks for a new
|
|
514
|
+
login; a scheduled run sets it, since a query reads no clock), the listed ad
|
|
515
|
+
accounts each with `linkedSiteId` (the site it already feeds, whoever linked
|
|
516
|
+
it: one account feeds one site) and the listed pixels. Never a token.
|
|
517
|
+
- `setSitePixel` takes either `{ ownerKey }`, a pixel from that connection's
|
|
518
|
+
listing that sends with the connection's token (a reconnect carries over), or
|
|
519
|
+
`{ accessToken }` as before. A pixel outside the listing fails with
|
|
520
|
+
`NOT_FOUND`. `metaStatus` reports `pixel.source` (`"connection"` or `"token"`)
|
|
521
|
+
and `pixel.name`. A connection Meta refuses stops its pixels; a token-sourced
|
|
522
|
+
pixel keeps sending. Removing the connection removes its links and pixels, in
|
|
523
|
+
scheduled batches. With `ownerKey`, `metaStatus` says for each link and the
|
|
524
|
+
pixel whether that owner made them (`own`), so a screen can lock the rest.
|
|
525
|
+
- `metaUnlinkPreview({ siteId, adAccountId })` (query) answers what unlinking
|
|
526
|
+
takes out of the site's reports:
|
|
527
|
+
`{ spendCents, currency, days, campaigns, partial }`, for the dialog that asks
|
|
528
|
+
before unlinking. `partial` means a read cap was hit and the figures are a
|
|
529
|
+
lower bound.
|
|
530
|
+
|
|
531
|
+
With `RASTRO_META_FAKE=true` any code logs in without the app env, except one
|
|
532
|
+
containing `invalid`, refused like an expired token. A code containing
|
|
533
|
+
`user-token` yields a short-lived user token that is traded for a 60-day one;
|
|
534
|
+
any other, a system user token that never expires; the sample agency lists the
|
|
535
|
+
pixels of Loja X and Pet Mania.
|
|
536
|
+
|
|
537
|
+
### Board columns and the event map
|
|
538
|
+
|
|
539
|
+
Conversions follow the host's real board, not Rastro's four stages. The host
|
|
540
|
+
declares its columns; an owner picks which Meta event each one sends.
|
|
541
|
+
|
|
542
|
+
```ts
|
|
543
|
+
await rastro.setPipeline(ctx, {
|
|
544
|
+
siteId,
|
|
545
|
+
columns: [
|
|
546
|
+
{ key: "novo", name: "Novo lead", group: "Prospecção" },
|
|
547
|
+
{
|
|
548
|
+
key: "agendado",
|
|
549
|
+
name: "Agendado",
|
|
550
|
+
group: "Hora do Show",
|
|
551
|
+
color: "#f97316",
|
|
552
|
+
},
|
|
553
|
+
],
|
|
554
|
+
});
|
|
555
|
+
await rastro.setLeadStage(ctx, {
|
|
556
|
+
siteId,
|
|
557
|
+
leadKey,
|
|
558
|
+
column: "agendado", // where the lead is now; `stage` may be left out
|
|
559
|
+
eventId,
|
|
560
|
+
occurredAt,
|
|
561
|
+
});
|
|
562
|
+
await rastro.setEventMap(ctx, {
|
|
563
|
+
siteId,
|
|
564
|
+
columns: { agendado: "Schedule", novo: null },
|
|
565
|
+
sendLeadEvent: false,
|
|
566
|
+
});
|
|
567
|
+
const map = await rastro.eventMap(ctx, { siteId, now: Date.now() });
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
- `setPipeline` (mutation): at most 40 columns, in order, with a stable `key`,
|
|
571
|
+
the pt-BR `name` the host shows, and optional `color` and `group` (the block
|
|
572
|
+
the board shows it in). Declaring again replaces names and order; a column
|
|
573
|
+
that is gone leaves the map, one that stays keeps its event. Host-only: the
|
|
574
|
+
host owns its board.
|
|
575
|
+
- `recordLead` and `setLeadStage` take an optional `column`. A key the pipeline
|
|
576
|
+
does not declare is ignored, never an error, and so is a move into the column
|
|
577
|
+
the lead is already in or one older than the lead's last move: a late retry
|
|
578
|
+
never moves a lead back. `setLeadStage` accepts the column alone, for a move
|
|
579
|
+
that changes no stage, idempotent by its `eventId` like a stage change. Only
|
|
580
|
+
an accepted move counts and reaches the event map.
|
|
581
|
+
- `setEventMap` replaces the whole map: each declared column maps to `Lead`,
|
|
582
|
+
`QualifiedLead`, `Schedule` or null. A key the pipeline does not declare is
|
|
583
|
+
`INVALID_ARGUMENT`. `sendLeadEvent` turns `Lead` on for every new lead and is
|
|
584
|
+
kept on the pixel too. With `ownerKey`, a site whose pixel another owner (or
|
|
585
|
+
the host, without an owner) set is a `CONFLICT`: the pixel's owner decides its
|
|
586
|
+
events. A site without a pixel takes any owner's map. The federated
|
|
587
|
+
`setMetaEventMap` always passes the dashboard connection's owner.
|
|
588
|
+
- `eventMap({ siteId, now? })` answers the columns with `key`, `name`, `color`,
|
|
589
|
+
`group`, `event` and `monthCount`: the distinct leads that entered the column
|
|
590
|
+
in the 30 UTC days up to `now` (a lead that leaves and comes back counts
|
|
591
|
+
once). Pass the caller's clock (queries do not read one), rounded to the start
|
|
592
|
+
of the day as the dashboard does, so the subscription stays cached all day;
|
|
593
|
+
without `now`, or before the host sent any column, the count is null. Then
|
|
594
|
+
`sendLeadEvent`, and `purchase.enabled` (an enabled pixel).
|
|
595
|
+
- **`Purchase` never comes from a column.** It is sent when the host records the
|
|
596
|
+
sale (`setLeadStage` with `won` and its value). Screens show it as a fixed
|
|
597
|
+
row, "Venda registrada (com valor) → Compra".
|
|
598
|
+
- A lead entering a mapped column queues its event once per lead per event
|
|
599
|
+
(`eventId = sha256(leadId + ":" + eventName)`) through the Conversions API
|
|
600
|
+
queue below, when the move carries `meta`.
|
|
601
|
+
|
|
602
|
+
### Federated Meta functions
|
|
603
|
+
|
|
604
|
+
With `metaAds: true`, the federated surface serves the same screen to the Rastro
|
|
605
|
+
dashboard. Every function acts for the dashboard connection's own owner,
|
|
606
|
+
`rastro:connection:<connectionId>`.
|
|
607
|
+
|
|
608
|
+
| Function | Kind | Needs |
|
|
609
|
+
| --------------------- | -------- | --------- |
|
|
610
|
+
| `metaConnection` | query | reader |
|
|
611
|
+
| `metaEventMap` | query | reader |
|
|
612
|
+
| `metaUnlinkPreview` | query | reader |
|
|
613
|
+
| `connectMeta` | action | configure |
|
|
614
|
+
| `refreshMetaAccounts` | action | configure |
|
|
615
|
+
| `setMetaPixel` | action | configure |
|
|
616
|
+
| `disconnectMeta` | mutation | configure |
|
|
617
|
+
| `linkMetaAdAccount` | mutation | configure |
|
|
618
|
+
| `unlinkMetaAdAccount` | mutation | configure |
|
|
619
|
+
| `removeMetaPixel` | mutation | configure |
|
|
620
|
+
| `setMetaEventMap` | mutation | configure |
|
|
621
|
+
| `syncMetaNow` | mutation | configure |
|
|
622
|
+
|
|
623
|
+
The three actions authorize through `authorizeMetaAction`, an internal query the
|
|
624
|
+
host exports from the same `convex/rastroFederation.ts` (an action has no
|
|
625
|
+
database for `resolveConnection`; the caller's identity travels with it). Add
|
|
626
|
+
all of them to the module's exports:
|
|
627
|
+
|
|
628
|
+
```ts
|
|
629
|
+
export const {
|
|
630
|
+
// ...
|
|
631
|
+
metaConnection,
|
|
632
|
+
metaEventMap,
|
|
633
|
+
metaUnlinkPreview,
|
|
634
|
+
connectMeta,
|
|
635
|
+
disconnectMeta,
|
|
636
|
+
refreshMetaAccounts,
|
|
637
|
+
linkMetaAdAccount,
|
|
638
|
+
unlinkMetaAdAccount,
|
|
639
|
+
setMetaPixel,
|
|
640
|
+
removeMetaPixel,
|
|
641
|
+
setMetaEventMap,
|
|
642
|
+
syncMetaNow,
|
|
643
|
+
authorizeMetaAction,
|
|
644
|
+
} = federated;
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
In `metaConnection`, an account linked to a site outside the grant answers
|
|
648
|
+
`linkedSiteId: null` with `linkedOutsideScope: true`, so the screen greys it out
|
|
649
|
+
without learning the site. `setMetaPixel` uses the connection's listing unless
|
|
650
|
+
it is given an `accessToken`, and enables the pixel unless told otherwise. The
|
|
651
|
+
federated `metaStatus` marks each link and the pixel with `own`: whether this
|
|
652
|
+
dashboard connection made them.
|
|
653
|
+
|
|
654
|
+
When the host revokes or deletes a dashboard connection's grant, it forgets the
|
|
655
|
+
Meta owner that connection acted as, in the same mutation:
|
|
656
|
+
|
|
657
|
+
```ts
|
|
658
|
+
await rastro.removeFederatedMetaConnection(ctx, { connectionId });
|
|
659
|
+
// = rastro.removeMetaConnection(ctx, {
|
|
660
|
+
// ownerKey: federatedMetaOwnerKey(connectionId),
|
|
661
|
+
// })
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
### Conversions
|
|
665
|
+
|
|
666
|
+
`recordLead` and `setLeadStage` accept an optional `meta`: what the host knows
|
|
667
|
+
of the contact, every value already a SHA-256 hex digest (`sha256Hex` below is
|
|
668
|
+
the host's own helper).
|
|
669
|
+
|
|
670
|
+
```ts
|
|
671
|
+
await rastro.setLeadStage(ctx, {
|
|
672
|
+
siteId,
|
|
673
|
+
leadKey: conversation._id,
|
|
674
|
+
stage: "won",
|
|
675
|
+
revenueCents: 45_990,
|
|
676
|
+
eventId: activity._id,
|
|
677
|
+
occurredAt: activity.createdAt,
|
|
678
|
+
meta: {
|
|
679
|
+
ph: [await sha256Hex(phoneE164Digits)], // "5511999991234"
|
|
680
|
+
externalId: await sha256Hex(contact._id),
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
Hash the phone as Meta normalizes it: digits only, with the country code and no
|
|
686
|
+
`+`. `meta` never fails the call: anything but that shape with 64-character hex
|
|
687
|
+
digests (a plain phone number, more than 10 phones, a wrong type) is ignored as
|
|
688
|
+
a whole, the lead is recorded as usual and no conversion is queued. The
|
|
689
|
+
component never stores it, and the warning it logs never repeats it. The hash of
|
|
690
|
+
an empty string matches nobody and is left out.
|
|
691
|
+
|
|
692
|
+
With an enabled pixel whose token Meta has not refused, a call with `meta`
|
|
693
|
+
queues one conversion:
|
|
694
|
+
|
|
695
|
+
| Event | When |
|
|
696
|
+
| ----------------------------------- | -------------------------------------------------------------------------- |
|
|
697
|
+
| `Purchase` | `setLeadStage` with `won` and a value; `value` and `currency` (the site's) |
|
|
698
|
+
| `Lead` | `recordLead` creating the lead, only with `sendLeadEvent` on the pixel |
|
|
699
|
+
| `Lead`, `QualifiedLead`, `Schedule` | The lead enters a board column the site's event map points at |
|
|
700
|
+
|
|
701
|
+
`Purchase` never comes from a column: only a recorded sale sends it. And
|
|
702
|
+
`setLeadStage` with `qualified` sends nothing by itself: `QualifiedLead` comes
|
|
703
|
+
only from a board column the host's event map points at, because the host's
|
|
704
|
+
columns, not Rastro's four stages, say what "qualified" means for that business.
|
|
705
|
+
Without `meta`, without a pixel, or with the pixel off, nothing is queued and
|
|
706
|
+
the call succeeds as before.
|
|
707
|
+
|
|
708
|
+
Each event goes **once per lead**: its `eventId` is
|
|
709
|
+
`sha256(leadId + ":" + eventName)`, so a lead that comes back to a column, or a
|
|
710
|
+
`Lead` reached both as a new lead and through a column, is not sent twice. This
|
|
711
|
+
holds for `Purchase` too: its value is the lead's sale total as Rastro has it,
|
|
712
|
+
so a corrected value or a second sale of the same lead sent again would count
|
|
713
|
+
the money twice at Meta. A repeat purchase is a new lead for the host to record.
|
|
714
|
+
|
|
715
|
+
"Once" means queued or delivered. An event that **failed** for good is opened
|
|
716
|
+
again, with its attempts back to zero and the new hashes, when the host sends
|
|
717
|
+
`meta` for it again (the next stage change or column move that reaches it).
|
|
718
|
+
Inside the component, the column path is the exported
|
|
719
|
+
`enqueueColumnEvent(ctx, { siteId, leadId, eventName, at, meta })` in
|
|
720
|
+
`metaCapi.ts`.
|
|
721
|
+
|
|
722
|
+
The send starts at once (`POST /{pixelId}/events`, Graph API `v25.0`, the
|
|
723
|
+
pixel's token in the `Authorization` header) with `action_source: "chat"`,
|
|
724
|
+
`event_time` in seconds, `user_data.ph`, `user_data.external_id`, `country` (the
|
|
725
|
+
hash of `br`), `custom_data.value` and `currency` on `Purchase`, and the pixel's
|
|
726
|
+
`test_event_code` when set. Then:
|
|
727
|
+
|
|
728
|
+
- A failure retries after 1 minute and then 10 minutes: three attempts in all. A
|
|
729
|
+
permission error fails at once. Anything else that goes wrong in a send (not
|
|
730
|
+
an answer from Meta) counts as an attempt too, and the last one fails with
|
|
731
|
+
`lastError` starting `UNEXPECTED`.
|
|
732
|
+
- No event stays pending: while any is, a scheduler chain (no cron) checks every
|
|
733
|
+
15 minutes or so for one whose attempt was due more than 15 minutes ago, a
|
|
734
|
+
send lost on the way, and sends it again, counting the lost attempt. Each send
|
|
735
|
+
takes a 15-minute lease on its attempt, so the chain never counts one that may
|
|
736
|
+
still be in flight. It keeps the limit of three attempts and the 7-day cap,
|
|
737
|
+
reads 25 rows at a time through an index, and stops by itself when nothing is
|
|
738
|
+
pending.
|
|
739
|
+
- A token Meta refuses stops whoever owns it, and only if it is still the stored
|
|
740
|
+
one: a token pasted (or a connection renewed) meanwhile keeps sending.
|
|
741
|
+
- A pixel's own token Meta refuses fails the event and stops the pixel: nothing
|
|
742
|
+
more is queued for the site until `setSitePixel` stores a token again. A
|
|
743
|
+
connection's token refused marks the connection invalid, which stops its
|
|
744
|
+
pixels and ad accounts until it is connected again.
|
|
745
|
+
- An event older than 7 days when its turn comes is not sent: it fails with
|
|
746
|
+
`EVENT_TOO_OLD`. A pixel removed or turned off before the send fails it with
|
|
747
|
+
`PIXEL_UNAVAILABLE`.
|
|
748
|
+
- Sent or failed, the event's hashes are erased; the row stays, without them, as
|
|
749
|
+
the site's conversion history (`meta.conversions`, the counters of
|
|
750
|
+
`meta.status`).
|
|
751
|
+
- `removeLead` deletes the lead's conversions with it.
|
|
752
|
+
- With the pixel's `testEventCode` set, every event carries it and Meta keeps it
|
|
753
|
+
in Test Events only; `meta.status` reports `pixel.testMode`.
|
|
754
|
+
|
|
755
|
+
Under `RASTRO_META_FAKE=true` every send is accepted (a token containing
|
|
756
|
+
`invalid` is refused like an expired one) and nothing leaves the deployment.
|
|
757
|
+
|
|
758
|
+
## The Integrações screen (`IntegrationsView`)
|
|
759
|
+
|
|
760
|
+
`@iann29/rastro/ui` ships the screen every surface embeds: the Meta login, each
|
|
761
|
+
site's ad account and pixel, and the events sheet that maps board columns to
|
|
762
|
+
Meta events. It is pure, like the other views: the host passes `connection`
|
|
763
|
+
(`metaConnection`), `sites`, `status` (`meta.status` with `ownerKey`, so each
|
|
764
|
+
link and the pixel carry `own`), `eventMaps` and `conversions`, and turns every
|
|
765
|
+
callback (`onConnect`, `onLinkAccount`, `onSetPixel`, `onSaveEventMap`, …) into
|
|
766
|
+
its own call. `@iann29/rastro/ui/sample` has every state in
|
|
767
|
+
`sampleIntegrations`.
|
|
768
|
+
|
|
769
|
+
It speaks to two readers through `audience`:
|
|
770
|
+
|
|
771
|
+
- `"agency"` (the default): one Meta login for many clients, a table of clients
|
|
772
|
+
with a filter, the feed across every client's pixel. The Rastro dashboard and
|
|
773
|
+
uze.ai's partner portal render it.
|
|
774
|
+
- `"business"`: one company connecting its own Meta ("Conexão Meta"). It shows
|
|
775
|
+
the first site only, as a one-row "Sua conta de anúncio" (no filter, no
|
|
776
|
+
currency column), and its copy names the company and never an agency or
|
|
777
|
+
clients. `productName` says who reads the spend and warns the pixel ("o uze lê
|
|
778
|
+
o gasto"; "Rastro" by default). With no site yet it shows the login alone, for
|
|
779
|
+
a host that makes the company's site on connecting.
|
|
780
|
+
|
|
781
|
+
Whatever another owner of the site linked (`own: false`) shows a lock, "Ligado
|
|
782
|
+
pelo <otherOwnerLabel>", and its row offers no unlink or pixel swap; its events
|
|
783
|
+
sheet reads "Ver eventos" and is read only, since the pixel's owner decides them
|
|
784
|
+
(`setMetaEventMap` refuses with `CONFLICT`). In business mode a `partner`
|
|
785
|
+
(`{ name, allowed, onAllowedChange?, busy? }`) draws the partner's card on top,
|
|
786
|
+
"O parceiro Gio cuida dos seus anúncios na Meta", with the company's checkbox
|
|
787
|
+
"Deixar Gio ver as campanhas" when `onAllowedChange` is given, and, unless the
|
|
788
|
+
host passes its own `otherOwnerLabel`, the locks read "Ligado pelo parceiro
|
|
789
|
+
Gio".
|
|
790
|
+
|
|
791
|
+
In agency mode, `onCopyEventMap(siteIds, map)` adds "Usar o mesmo mapa nos
|
|
792
|
+
outros clientes" to the events sheet. Saving with it checked calls
|
|
793
|
+
`onSaveEventMap` for the client, then `onCopyEventMap` with the other clients
|
|
794
|
+
whose board declares the same columns and whose pixel is not another owner's
|
|
795
|
+
(`eventMapCopyTargets` computes them).
|