@iann29/rastro 0.10.9 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/agent/integration.md +45 -0
- package/agent/manifest.json +4 -4
- package/dist/client/federation.d.ts +82 -9
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +40 -2
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +464 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +259 -7
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +10 -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 +273 -21
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +2 -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 +31 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +31 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +2 -0
- package/dist/component/convex.config.js +7 -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/http.d.ts.map +1 -1
- package/dist/component/http.js +64 -0
- package/dist/component/http.js.map +1 -1
- package/dist/component/leadAdsValidators.d.ts +1092 -0
- package/dist/component/leadAdsValidators.d.ts.map +1 -0
- package/dist/component/leadAdsValidators.js +304 -0
- package/dist/component/leadAdsValidators.js.map +1 -0
- package/dist/component/leadFormRules.d.ts +60 -0
- package/dist/component/leadFormRules.d.ts.map +1 -0
- package/dist/component/leadFormRules.js +140 -0
- package/dist/component/leadFormRules.js.map +1 -0
- package/dist/component/leadFormStore.d.ts +16 -0
- package/dist/component/leadFormStore.d.ts.map +1 -0
- package/dist/component/leadFormStore.js +92 -0
- package/dist/component/leadFormStore.js.map +1 -0
- package/dist/component/leadgenToken.d.ts +56 -0
- package/dist/component/leadgenToken.d.ts.map +1 -0
- package/dist/component/leadgenToken.js +262 -0
- package/dist/component/leadgenToken.js.map +1 -0
- package/dist/component/leads.d.ts +76 -7
- package/dist/component/leads.d.ts.map +1 -1
- package/dist/component/leads.js +112 -78
- package/dist/component/leads.js.map +1 -1
- package/dist/component/meta.d.ts.map +1 -1
- package/dist/component/meta.js +21 -1
- package/dist/component/meta.js.map +1 -1
- package/dist/component/metaFake.d.ts +44 -0
- package/dist/component/metaFake.d.ts.map +1 -1
- package/dist/component/metaFake.js +400 -1
- package/dist/component/metaFake.js.map +1 -1
- package/dist/component/metaGraph.d.ts +91 -0
- package/dist/component/metaGraph.d.ts.map +1 -1
- package/dist/component/metaGraph.js +217 -0
- package/dist/component/metaGraph.js.map +1 -1
- package/dist/component/metaLeads.d.ts +681 -0
- package/dist/component/metaLeads.d.ts.map +1 -0
- package/dist/component/metaLeads.js +2634 -0
- package/dist/component/metaLeads.js.map +1 -0
- package/dist/component/schema.d.ts +414 -5
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +169 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/validators.d.ts +235 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +14 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +1 -1
- package/dist/tracker/generated.js +1 -1
- package/dist/ui/campaigns/CampaignsView.d.ts +5 -1
- package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -1
- package/dist/ui/campaigns/CampaignsView.js.map +1 -1
- package/dist/ui/campaigns/Drawer.d.ts.map +1 -1
- package/dist/ui/campaigns/Drawer.js +8 -26
- package/dist/ui/campaigns/Drawer.js.map +1 -1
- package/dist/ui/campaigns/labels.d.ts +29 -1
- package/dist/ui/campaigns/labels.d.ts.map +1 -1
- package/dist/ui/campaigns/labels.js +77 -2
- package/dist/ui/campaigns/labels.js.map +1 -1
- package/dist/ui/campaigns/model.js +2 -2
- package/dist/ui/campaigns/model.js.map +1 -1
- package/dist/ui/forms/FirstContact.d.ts +19 -0
- package/dist/ui/forms/FirstContact.d.ts.map +1 -0
- package/dist/ui/forms/FirstContact.js +81 -0
- package/dist/ui/forms/FirstContact.js.map +1 -0
- package/dist/ui/forms/FormLead.d.ts +28 -0
- package/dist/ui/forms/FormLead.d.ts.map +1 -0
- package/dist/ui/forms/FormLead.js +199 -0
- package/dist/ui/forms/FormLead.js.map +1 -0
- package/dist/ui/forms/FormRulesDrawer.d.ts +49 -0
- package/dist/ui/forms/FormRulesDrawer.d.ts.map +1 -0
- package/dist/ui/forms/FormRulesDrawer.js +149 -0
- package/dist/ui/forms/FormRulesDrawer.js.map +1 -0
- package/dist/ui/forms/FormsView.d.ts +10 -0
- package/dist/ui/forms/FormsView.d.ts.map +1 -0
- package/dist/ui/forms/FormsView.js +215 -0
- package/dist/ui/forms/FormsView.js.map +1 -0
- package/dist/ui/forms/Icons.d.ts +46 -0
- package/dist/ui/forms/Icons.d.ts.map +1 -0
- package/dist/ui/forms/Icons.js +45 -0
- package/dist/ui/forms/Icons.js.map +1 -0
- package/dist/ui/forms/RuleRow.d.ts +37 -0
- package/dist/ui/forms/RuleRow.d.ts.map +1 -0
- package/dist/ui/forms/RuleRow.js +80 -0
- package/dist/ui/forms/RuleRow.js.map +1 -0
- package/dist/ui/forms/WhatsAppButtonGuide.d.ts +33 -0
- package/dist/ui/forms/WhatsAppButtonGuide.d.ts.map +1 -0
- package/dist/ui/forms/WhatsAppButtonGuide.js +90 -0
- package/dist/ui/forms/WhatsAppButtonGuide.js.map +1 -0
- package/dist/ui/forms/index.d.ts +11 -0
- package/dist/ui/forms/index.d.ts.map +1 -0
- package/dist/ui/forms/index.js +7 -0
- package/dist/ui/forms/index.js.map +1 -0
- package/dist/ui/forms/journey.d.ts +19 -0
- package/dist/ui/forms/journey.d.ts.map +1 -0
- package/dist/ui/forms/journey.js +54 -0
- package/dist/ui/forms/journey.js.map +1 -0
- package/dist/ui/forms/model.d.ts +239 -0
- package/dist/ui/forms/model.d.ts.map +1 -0
- package/dist/ui/forms/model.js +556 -0
- package/dist/ui/forms/model.js.map +1 -0
- package/dist/ui/forms/sample.d.ts +36 -0
- package/dist/ui/forms/sample.d.ts.map +1 -0
- package/dist/ui/forms/sample.js +444 -0
- package/dist/ui/forms/sample.js.map +1 -0
- package/dist/ui/forms/types.d.ts +454 -0
- package/dist/ui/forms/types.d.ts.map +1 -0
- package/dist/ui/forms/types.js +2 -0
- package/dist/ui/forms/types.js.map +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +4 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/integrations/Overlay.d.ts +3 -1
- package/dist/ui/integrations/Overlay.d.ts.map +1 -1
- package/dist/ui/integrations/Overlay.js +2 -2
- package/dist/ui/integrations/Overlay.js.map +1 -1
- package/dist/ui/portfolio/PortfolioView.d.ts +4 -1
- package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -1
- package/dist/ui/portfolio/PortfolioView.js.map +1 -1
- package/dist/ui/sample.d.ts +3 -1
- package/dist/ui/sample.d.ts.map +1 -1
- package/dist/ui/sample.js +2 -0
- package/dist/ui/sample.js.map +1 -1
- package/dist/ui/seed.d.ts +1 -1
- package/dist/ui/ui.css +2301 -0
- package/docs/federation.md +19 -0
- package/docs/meta-ads.md +713 -7
- package/docs/upgrading.md +70 -0
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/component/_generated/api.ts +10 -0
- package/src/component/_generated/component.ts +289 -20
- package/src/component/_generated/server.ts +2 -0
- package/src/component/constants.ts +33 -0
- package/src/component/convex.config.ts +7 -0
- package/src/component/errors.ts +3 -1
- package/src/component/http.ts +75 -0
- package/src/component/leadAdsValidators.ts +365 -0
- package/src/component/leadFormRules.ts +202 -0
- package/src/component/leadFormStore.ts +129 -0
- package/src/component/leadgenToken.ts +348 -0
- package/src/component/leads.ts +136 -84
- package/src/component/meta.ts +21 -1
- package/src/component/metaFake.ts +485 -1
- package/src/component/metaGraph.ts +327 -0
- package/src/component/metaLeads.ts +3101 -0
- package/src/component/schema.ts +187 -1
- package/src/component/validators.ts +17 -1
- package/src/tracker/generated.ts +1 -1
package/docs/meta-ads.md
CHANGED
|
@@ -30,6 +30,12 @@ The Meta connection and the Conversions API sender ship in 0.10.0:
|
|
|
30
30
|
| Spend and catalog sync | One scheduler chain for every linked ad account |
|
|
31
31
|
| `RASTRO_SECRETS_KEY`, `RASTRO_META_FAKE`, `RASTRO_META_APP_ID/SECRET` | Optional env bindings |
|
|
32
32
|
| Conversions sent to the pixel | Sales and new leads from the lead methods; mapped columns |
|
|
33
|
+
| Lead Ads (instant forms) into the host's CRM, `metaLeads` capability | Unreleased; see [Lead Ads](#lead-ads-instant-forms) |
|
|
34
|
+
|
|
35
|
+
Lead Ads (contract §12) is not released yet. Both halves are in this repository:
|
|
36
|
+
the component's Pages, forms, rules and leadgen route, and the control plane's
|
|
37
|
+
webhook and forwarding, which deliver only to hosts that serve the `metaLeads`
|
|
38
|
+
side (§12.4, see [Host side](#host-side)).
|
|
33
39
|
|
|
34
40
|
## Turn on the `metaAds` capability
|
|
35
41
|
|
|
@@ -140,18 +146,26 @@ charts.
|
|
|
140
146
|
|
|
141
147
|
- Arguments: one to ten unique `siteIds`, an inclusive `from`/`to` covering at
|
|
142
148
|
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
|
|
144
|
-
|
|
145
|
-
|
|
149
|
+
`origin` for one origin's (`"ctwa_ad"` for the click-to-WhatsApp ads' leads),
|
|
150
|
+
or `origin: "paid"` for every lead an ad bought (`ctwa_ad` and `lead_form`
|
|
151
|
+
together, the origins `bySite[].paid` counts). Passing both fails with
|
|
152
|
+
`INVALID_ARGUMENT`. Without a filter the series counts every lead. A host on a
|
|
153
|
+
release before Lead Ads counts nothing under `"paid"`; it has no `lead_form`
|
|
154
|
+
leads either, so a dashboard asks such a host for `"ctwa_ad"`. Every host from
|
|
155
|
+
the Lead Ads release advertises the capability `leadsPaid` with `leads` (not
|
|
156
|
+
an opt-in: it only says `leadsDaily` understands `"paid"`), so a dashboard
|
|
157
|
+
asks for `"paid"` whenever the manifest carries it, not only on hosts that
|
|
158
|
+
advertise `metaLeads`.
|
|
146
159
|
- Answer: every UTC day the range touches, oldest first, empty days included
|
|
147
160
|
with zeros: `{ dayStart, leads, qualified, won, revenueCents, spendCents }`.
|
|
148
161
|
- Counting is by cohort, like `leads.report`: a qualification or sale lands on
|
|
149
162
|
the day its lead first arrived.
|
|
150
163
|
- `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
|
|
152
|
-
origin
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
or, with `origin: "ctwa_ad"` or `"paid"`, the ads again. It is `null` for an
|
|
165
|
+
organic origin, for `lead_form` alone (the spend is not split between the two
|
|
166
|
+
kinds of ad) and for sites no linked ad account covers. Spend keeps the ad
|
|
167
|
+
account's local date while cohorts use the UTC day, so a daily chart may show
|
|
168
|
+
a day of skew; totals over a week or more agree.
|
|
155
169
|
|
|
156
170
|
### `meta.status` (`metaStatus`)
|
|
157
171
|
|
|
@@ -793,3 +807,695 @@ outros clientes" to the events sheet. Saving with it checked calls
|
|
|
793
807
|
`onSaveEventMap` for the client, then `onCopyEventMap` with the other clients
|
|
794
808
|
whose board declares the same columns and whose pixel is not another owner's
|
|
795
809
|
(`eventMapCopyTargets` computes them).
|
|
810
|
+
|
|
811
|
+
## Lead Ads (instant forms)
|
|
812
|
+
|
|
813
|
+
A Meta instant form's leads go straight into the host's CRM. Rastro talks to
|
|
814
|
+
Meta; the host owns the person. Rastro lists the owner's Facebook Pages and
|
|
815
|
+
their forms, receives Meta's lead notifications, reads each lead with the Page's
|
|
816
|
+
own token, applies the form's rules and hands the result to the host's **form
|
|
817
|
+
lead handler** in the same transaction. The host creates or updates the contact
|
|
818
|
+
and its card, and owns the first contact.
|
|
819
|
+
|
|
820
|
+
**A lead's personal data is never stored by Rastro.** Name, phone, e-mail and
|
|
821
|
+
every answer typed freely go from Meta to the handler in memory and are not
|
|
822
|
+
written to any table, scheduled job or log. Rastro keeps the origin (Page, form,
|
|
823
|
+
ad, ad set, campaign), the times, the answers to **choice questions** (the ones
|
|
824
|
+
rules read) and what the rules decided.
|
|
825
|
+
|
|
826
|
+
### Turn it on
|
|
827
|
+
|
|
828
|
+
1. Pass the federation audience (and the issuer, when it is not the production
|
|
829
|
+
dashboard) to the component, the same values the host's `auth.config.ts`
|
|
830
|
+
trusts (the leadgen route checks the control plane's token against them):
|
|
831
|
+
|
|
832
|
+
```ts
|
|
833
|
+
app.use(rastro, {
|
|
834
|
+
env: {
|
|
835
|
+
// ...the Meta bindings above
|
|
836
|
+
RASTRO_FEDERATION_ISSUER: app.env.RASTRO_FEDERATION_ISSUER,
|
|
837
|
+
RASTRO_FEDERATION_AUDIENCE: app.env.RASTRO_FEDERATION_AUDIENCE,
|
|
838
|
+
},
|
|
839
|
+
});
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
The component's env is its own: set both on the deployment
|
|
843
|
+
(`RASTRO_FEDERATION_AUDIENCE` is required) and pass them here. The issuer
|
|
844
|
+
defaults to `https://site.api.amagerastro.com/federation`, as in the host's
|
|
845
|
+
federation API. The audience has no default: it is the host's **functions
|
|
846
|
+
URL** (`https://<deployment>.convex.cloud`, or its custom functions domain),
|
|
847
|
+
the same `applicationID` the host's `auth.config.ts` names for the issuer
|
|
848
|
+
([federation-setup.md](federation-setup.md)), not its site URL. Without it
|
|
849
|
+
the route answers `503 FEDERATION_NOT_CONFIGURED` to every forward, and
|
|
850
|
+
`linkPage` refuses with `FEDERATION_NOT_CONFIGURED`, so no Page is linked
|
|
851
|
+
(and routed) to a host that cannot take its leads. A host paired with another
|
|
852
|
+
dashboard (a DEV control plane) sets the issuer to that dashboard's:
|
|
853
|
+
`metaLinkedPages` reports it, and the dashboard routes no lead to a host that
|
|
854
|
+
trusts another issuer. Mount the component with `httpPrefix: "/rastro/"`: the
|
|
855
|
+
dashboard posts each forward to `<site URL>/rastro/meta/leadgen`, and a host
|
|
856
|
+
under another prefix gets its leads only from the reconciliation.
|
|
857
|
+
|
|
858
|
+
2. Write the handler, an internal **mutation** validated by
|
|
859
|
+
`formLeadPayloadValidator` and idempotent on `leadgenId` (Meta's webhook and
|
|
860
|
+
the reconciliation may both bring a lead), and register it once:
|
|
861
|
+
|
|
862
|
+
```ts
|
|
863
|
+
import { createFunctionHandle } from "convex/server";
|
|
864
|
+
import { formLeadPayloadValidator } from "@iann29/rastro";
|
|
865
|
+
|
|
866
|
+
export const formLeadArrived = internalMutation({
|
|
867
|
+
args: formLeadPayloadValidator,
|
|
868
|
+
returns: v.null(),
|
|
869
|
+
handler: async (ctx, lead) => {
|
|
870
|
+
// Find the contact by lead.contact.phone, create or update it and its
|
|
871
|
+
// card in lead.decision's column (or the discard column with
|
|
872
|
+
// lead.decision.discard), attach lead.answers and lead.origin. Later
|
|
873
|
+
// stage changes use lead.leadKey with setLeadStage.
|
|
874
|
+
return null;
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
export const registerFormLeadHandler = internalMutation({
|
|
879
|
+
args: {},
|
|
880
|
+
handler: async (ctx) => {
|
|
881
|
+
await rastro.setFormLeadHandler(ctx, {
|
|
882
|
+
handle: await createFunctionHandle(
|
|
883
|
+
internal.rastroLeads.formLeadArrived,
|
|
884
|
+
),
|
|
885
|
+
});
|
|
886
|
+
},
|
|
887
|
+
});
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
3. Opt in on the federated surface with `metaLeads: true` (beside
|
|
891
|
+
`metaAds: true`, which it needs: the Meta login and the Integrações screen
|
|
892
|
+
come with `metaAds`, so `metaLeads` alone throws at setup) and re-export the
|
|
893
|
+
Lead Ads functions from `convex/rastroFederation.ts` (see
|
|
894
|
+
[Federated Lead Ads functions](#federated-lead-ads-functions)).
|
|
895
|
+
4. Ask the dashboard operator to add the connection's deployment origin
|
|
896
|
+
(`https://<name>.convex.cloud`, the functions URL) to the control plane's
|
|
897
|
+
`RASTRO_META_LEADS_DEPLOYMENT_ORIGINS`, and a custom-domain site URL to
|
|
898
|
+
`RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS` (see
|
|
899
|
+
[Which hosts may receive leads](#lead-ads-webhook-control-plane)). Until then
|
|
900
|
+
no webhook is forwarded to the host, and leads only come in through the
|
|
901
|
+
15-minute reconciliation.
|
|
902
|
+
|
|
903
|
+
Without a handler nothing is lost: a lead is not marked as ingested, the
|
|
904
|
+
reconciliation reads it again once a handler exists (registering one brings the
|
|
905
|
+
waiting Pages' runs forward), and `metaPages`/`metaForms` read
|
|
906
|
+
`handlerReady: false` with `lastError: "HANDLER_MISSING"`.
|
|
907
|
+
|
|
908
|
+
### The handler's payload
|
|
909
|
+
|
|
910
|
+
```ts
|
|
911
|
+
{
|
|
912
|
+
siteId: string;
|
|
913
|
+
leadId: string; // Rastro's lead
|
|
914
|
+
leadKey: string; // `meta_lead:<leadgenId>`: what setLeadStage takes
|
|
915
|
+
leadgenId: string;
|
|
916
|
+
receivedAt: number; // when the lead sent the form
|
|
917
|
+
contact: {
|
|
918
|
+
name: string | null;
|
|
919
|
+
phone: string | null;
|
|
920
|
+
email: string | null;
|
|
921
|
+
};
|
|
922
|
+
freeAnswers: Array<{ questionKey: string; label: string; value: string }>;
|
|
923
|
+
answers: Array<{ questionKey: string; label: string; values: string[] }>;
|
|
924
|
+
decision: { column: string | null } | { discard: string };
|
|
925
|
+
rulePath: number[]; // the rules looked at, in order
|
|
926
|
+
ruleIndex: number | null; // the rule that decided
|
|
927
|
+
origin: {
|
|
928
|
+
pageId: string;
|
|
929
|
+
pageName: string | null;
|
|
930
|
+
formId: string;
|
|
931
|
+
formName: string | null;
|
|
932
|
+
adId: string | null; // with adName, adsetId, adsetName, campaignId,
|
|
933
|
+
// campaignName (null until the catalog sync names them)
|
|
934
|
+
platform: string | null; // "fb" or "ig"
|
|
935
|
+
isOrganic: boolean;
|
|
936
|
+
};
|
|
937
|
+
firstContact: {
|
|
938
|
+
mode: "lead_starts" | "one_click" | "automatic";
|
|
939
|
+
text?: string;
|
|
940
|
+
brakes?: {
|
|
941
|
+
waitMinutes: number;
|
|
942
|
+
maxPerHour: number;
|
|
943
|
+
businessHoursOnly: boolean;
|
|
944
|
+
};
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
The component records the lead before it calls the handler: channel `form`,
|
|
950
|
+
entry point `lead_form`, the ad as its origin, the decided column (when the
|
|
951
|
+
rules named one) and the form. Do not `recordLead` it again under another key;
|
|
952
|
+
move it with `setLeadStage` and `leadKey`, so the sale reaches the reports and
|
|
953
|
+
the pixel. A handler that throws rolls the whole ingestion back (the lead, its
|
|
954
|
+
record, the rules' counts), and the next reconciliation brings it again.
|
|
955
|
+
|
|
956
|
+
### Pages
|
|
957
|
+
|
|
958
|
+
On a host that registered its form lead handler (`setFormLeadHandler`, the
|
|
959
|
+
opt-in), every connect (`setMetaConnection`, `connectMetaWithCode`) and every
|
|
960
|
+
`listMetaAdAccounts` also reads the owner's Pages
|
|
961
|
+
(`GET /me/accounts?fields=id,name,access_token,tasks`) and seals each Page's own
|
|
962
|
+
token with `RASTRO_SECRETS_KEY`; the first registration reads the Pages of the
|
|
963
|
+
owners already connected. A host without the handler makes no Pages read and
|
|
964
|
+
keeps no Page token. A login without the Page permissions lists no Page and
|
|
965
|
+
still connects. `leadsAllowed` says Meta granted `leads_retrieval` for the Page
|
|
966
|
+
(from `debug_token`'s granular scopes when the app env is set, else from the
|
|
967
|
+
token's permissions) and the login has a task that reaches its leads
|
|
968
|
+
(`ADVERTISE` or `MANAGE`).
|
|
969
|
+
|
|
970
|
+
- `metaPages({ ownerKey })` (query): `pageId`, `name`, `tasks`, `leadsAllowed`,
|
|
971
|
+
`linkedSiteId` (whoever linked it: one Page feeds one site), `own`,
|
|
972
|
+
`subscribedAt`, `lastError` and `handlerReady` (the host registered its form
|
|
973
|
+
lead handler). Never a token.
|
|
974
|
+
- `lastError` is a code, never a message (the message stays in the component),
|
|
975
|
+
or `null`. Each says who can fix it:
|
|
976
|
+
|
|
977
|
+
| Code | Cause | Fixed by |
|
|
978
|
+
| ------------------------- | -------------------------------------------------------- | ------------------------------- |
|
|
979
|
+
| `META_TOKEN_INVALID` | Meta refused the Page's (or the connection's) token | reconnecting Meta |
|
|
980
|
+
| `META_PERMISSION_MISSING` | no `leads_retrieval` for the Page, or the login lost it | reconnecting Meta |
|
|
981
|
+
| `HANDLER_MISSING` | the host removed its form lead handler | the host (`setFormLeadHandler`) |
|
|
982
|
+
| `HANDLER_FAILED` | the host's handler threw; the lead is read again | the host |
|
|
983
|
+
| `HANDLER_GAVE_UP` | the handler threw 5 times; the lead was skipped for good | the host |
|
|
984
|
+
| `SECRETS_KEY_MISSING` | the component cannot open the Page token | the host's env |
|
|
985
|
+
| `META_TRUNCATED` | a busy backfill goes on next run | itself |
|
|
986
|
+
| `META_RATE_LIMITED` | Meta's rate limit: the next run waits an hour | itself |
|
|
987
|
+
| `META_UNAVAILABLE` | Meta failed otherwise; retried with backoff | itself |
|
|
988
|
+
| `RUN_TIMEOUT` | the previous reconciliation did not finish | itself |
|
|
989
|
+
|
|
990
|
+
The package exports the list as `META_PAGE_ERROR_CODES`. A Page another owner
|
|
991
|
+
linked reads `null`: its state is that owner's.
|
|
992
|
+
|
|
993
|
+
- `linkPage({ siteId, pageId, ownerKey, backfillDays? })` (action) subscribes
|
|
994
|
+
the app to the Page's `leadgen` webhook
|
|
995
|
+
(`POST /{page}/subscribed_apps?subscribed_fields=leadgen` with the Page token;
|
|
996
|
+
Meta must answer `success: true`), reads its forms, records the link and
|
|
997
|
+
starts the reconciliation. A Page another site has is `CONFLICT`; one without
|
|
998
|
+
lead access is `META_PERMISSION_MISSING`; on a host without
|
|
999
|
+
`RASTRO_FEDERATION_AUDIENCE` every link is `FEDERATION_NOT_CONFIGURED`.
|
|
1000
|
+
`backfillDays` (0–30, default 0) also brings the leads of the days before. At
|
|
1001
|
+
most 20 Pages a site.
|
|
1002
|
+
- `unlinkPage({ siteId, pageId, ownerKey? })` (action) forgets the link and its
|
|
1003
|
+
reconciliation. It leaves the app's `leadgen` subscription on the Page: the
|
|
1004
|
+
subscription belongs to the Page and the one app every host shares, so taking
|
|
1005
|
+
it off would silence another host that links the Page now (the control plane
|
|
1006
|
+
drops notifications for a Page no host claims). Every 6 hours the
|
|
1007
|
+
reconciliation of a linked Page subscribes it again (idempotent), so a host
|
|
1008
|
+
keeps its notifications even after an older host's unlink took them off. The
|
|
1009
|
+
site's form rules and the leads already in stay.
|
|
1010
|
+
- A Page the login no longer lists, or one without lead access, stops with
|
|
1011
|
+
`META_PERMISSION_MISSING`; one whose token Meta refuses with
|
|
1012
|
+
`META_TOKEN_INVALID`; a connect or refresh that lists it again resumes it. A
|
|
1013
|
+
connection whose token Meta refuses stops its Pages too. Removing the
|
|
1014
|
+
connection removes its Pages and links.
|
|
1015
|
+
- `metaLinkedPages({ siteIds })` (query):
|
|
1016
|
+
`{ pages: Array<{ pageId, siteId }>, leadgenReady, issuer, audience, handlerReady }`,
|
|
1017
|
+
what the dashboard's control plane reads to route Meta's webhooks to this
|
|
1018
|
+
host. `handlerReady` says the host registered its form lead handler: the
|
|
1019
|
+
handler is the Lead Ads opt-in, and without it no Page is read, so Formulários
|
|
1020
|
+
with no Pages says the host is not ready instead of asking to reconnect Meta.
|
|
1021
|
+
`leadgenReady` says the component has `RASTRO_FEDERATION_AUDIENCE` (its
|
|
1022
|
+
leadgen route can verify a forward); `issuer` is the issuer that route checks
|
|
1023
|
+
forwards against (`RASTRO_FEDERATION_ISSUER`, trailing slash dropped, or the
|
|
1024
|
+
production default); `audience` is the `RASTRO_FEDERATION_AUDIENCE` it expects
|
|
1025
|
+
(trailing slash dropped), or `null`. The control plane routes the Pages only
|
|
1026
|
+
when `leadgenReady` is true, `issuer` is its own and `audience` is the
|
|
1027
|
+
connection's deployment URL.
|
|
1028
|
+
|
|
1029
|
+
### Forms and their rules
|
|
1030
|
+
|
|
1031
|
+
- `metaForms({ siteId, now?, ownerKey? })` (query): the Pages linked to the
|
|
1032
|
+
site, each with `leadsAllowed`, `subscribedAt`, `lastReconcileAt`,
|
|
1033
|
+
`lastLeadAt`, `lastError` (the same codes as `metaPages`), `handlerReady` and
|
|
1034
|
+
its forms: `formId`, `name`, `status` (`ACTIVE`, `ARCHIVED`…), `questions`
|
|
1035
|
+
(choice questions carry their `options`), `whatsappButton` (`true`/`false`
|
|
1036
|
+
from the end screen's button type, `null` when Meta does not say),
|
|
1037
|
+
`whatsappButtonConfirmed` (the host ticked "Já liguei"), `createdTime`,
|
|
1038
|
+
`leads30d` and `daily` (the 30 UTC days up to `now`, null without it),
|
|
1039
|
+
`lastLeadAt` and a `rules` summary. At most 500 forms a site in all, every
|
|
1040
|
+
Page's `ACTIVE` forms first, then the others while room is left (Meta keeps
|
|
1041
|
+
archived forms forever). New forms show up by themselves on every
|
|
1042
|
+
reconciliation run; `refreshForms({ siteId, pageId? })` (action) reads them at
|
|
1043
|
+
once.
|
|
1044
|
+
- `setFormRules({ siteId, formId, entryColumn, rules, firstContact, whatsappButtonConfirmed?, ownerKey?, onlyOwn? })`
|
|
1045
|
+
(mutation) replaces the form's rules. Each rule is
|
|
1046
|
+
`{ questionKey, answers, to }`: `questionKey` is a choice question of the
|
|
1047
|
+
form, `answers` name its options (by key or shown value; stored by key) and
|
|
1048
|
+
`to` is `{ column }` or `{ discard: reason }`. The first matching rule
|
|
1049
|
+
decides; with none, the lead goes to `entryColumn` (`null`: the host's own
|
|
1050
|
+
entry). Columns must be on the site's pipeline (`setPipeline`).
|
|
1051
|
+
`firstContact.mode` is `lead_starts`, `one_click` (needs `text`;
|
|
1052
|
+
`{primeiro_nome}` is the host's to fill) or `automatic` (needs `brakes`: wait
|
|
1053
|
+
15–1440 minutes, 1–120 an hour, business hours or not). Editing decides the
|
|
1054
|
+
next leads only. At most 20 rules.
|
|
1055
|
+
- `formRules({ siteId, formId, now?, ownerKey? })` (query): the rules, each with
|
|
1056
|
+
`monthCount` (the leads it decided in the 30 days up to `now`; a rule's count
|
|
1057
|
+
follows its question, answers and target, so reordering keeps it and editing
|
|
1058
|
+
starts a new one), `entryColumn`, `firstContact`, `configured` and, with an
|
|
1059
|
+
owner, `own`.
|
|
1060
|
+
- `simulateFormRules({ siteId, formId, entryColumn, rules, now })` (query): the
|
|
1061
|
+
last 30 days' leads under a draft: per rule, per column, per discard reason,
|
|
1062
|
+
and those left to the entry. It reads the choice answers Rastro keeps, never
|
|
1063
|
+
Meta. A rule still being written (no answer yet) counts zero. `entry` counts
|
|
1064
|
+
the leads no rule matched; when the draft names an `entryColumn`, `columns`
|
|
1065
|
+
counts them under that column too, so a screen adds `entry` only when no
|
|
1066
|
+
`columns` row names the entry (unlike `formsReport`, whose `entry` is only the
|
|
1067
|
+
leads sent to the host's own entry).
|
|
1068
|
+
- `returnFormLead({ siteId, leadId, ownerKey? })` (mutation) sends a discarded
|
|
1069
|
+
lead back to the funnel: its decision becomes the host's entry and the reason
|
|
1070
|
+
stays as history (`returned`). The host moves its card itself.
|
|
1071
|
+
- `formsReport({ siteIds, from, to })` (query): per form, over the cohort days
|
|
1072
|
+
in the range, `leads`, `qualified`, `won`, `revenueCents` (from the lead
|
|
1073
|
+
rollups), the leads sent to each column, to the `entry`, `discarded` per
|
|
1074
|
+
reason, and `returned`. `partial` when a read cap was hit. There is one row
|
|
1075
|
+
per `(siteId, formId)`: a Page moved from one site to another inside the range
|
|
1076
|
+
has a row under each, so key rows by both and read the one of the site the
|
|
1077
|
+
Page feeds now. The range is whole UTC days: `from` floors to its day and `to`
|
|
1078
|
+
takes in the whole UTC day it falls on, so a 30-day window is
|
|
1079
|
+
`to = end of a UTC day`, `from = to + 1 − 30 days`.
|
|
1080
|
+
|
|
1081
|
+
### How a lead comes in
|
|
1082
|
+
|
|
1083
|
+
1. Meta posts the `leadgen` webhook to the Rastro dashboard's control plane,
|
|
1084
|
+
which knows which host links the Page (`metaLinkedPages`) and forwards
|
|
1085
|
+
`{ pageId, leadgenId }` to `POST <host site>/rastro/meta/leadgen`.
|
|
1086
|
+
2. The component's route checks the control plane's token: RS256 under the
|
|
1087
|
+
issuer's published keys (`<issuer>/.well-known/openid-configuration`), issuer
|
|
1088
|
+
and audience as the host trusts them, subject `rastro:meta-webhook`, at most
|
|
1089
|
+
10 minutes of lifetime (the control plane signs 2), purpose `meta_leadgen`,
|
|
1090
|
+
naming its connection, organization and connection version, and bound to
|
|
1091
|
+
exactly that Page and leadgen id. The names come from `META_LEADGEN_TOKEN`
|
|
1092
|
+
(exported by the package; the control plane signs with the same object,
|
|
1093
|
+
contract §12.3). Then it schedules the ingestion and answers `202`
|
|
1094
|
+
`{ accepted: true }`; a Page this host does not link answers `200`
|
|
1095
|
+
`{ accepted: false, reason: "PAGE_NOT_LINKED" }`, a bad token `401`
|
|
1096
|
+
`{ error: { code: "UNAUTHENTICATED" } }`, a host without the audience env
|
|
1097
|
+
`503` `{ error: { code: "FEDERATION_NOT_CONFIGURED" } }`, and an issuer whose
|
|
1098
|
+
keys cannot be read right now (unreachable, `5xx`, `429`, not JSON), or whose
|
|
1099
|
+
keys were read less than a minute ago and lack the token's key id (a key
|
|
1100
|
+
rotated meanwhile), `503` `{ error: { code: "ISSUER_UNREACHABLE" } }`, which
|
|
1101
|
+
is retried. The control plane reads these bodies (see
|
|
1102
|
+
[Forwarding](#lead-ads-webhook-control-plane)).
|
|
1103
|
+
3. The ingestion reads the lead from Meta with the Page token
|
|
1104
|
+
(`GET /{leadgen}?fields=created_time,field_data,ad_id,adset_id,campaign_id,form_id,platform,is_organic`),
|
|
1105
|
+
splits its answers (identity and free ones in memory only), applies the
|
|
1106
|
+
rules, records the lead and calls the handler. Idempotent on the leadgen id.
|
|
1107
|
+
4. A scheduler chain (no cron) reads every linked Page every 15 minutes: the
|
|
1108
|
+
Page's forms, then each active form's leads since its cursor
|
|
1109
|
+
(`GET /{form}/leads` filtered on `time_created`), oldest first, through the
|
|
1110
|
+
same ingestion. Meta lists a form's leads newest first and one listing stops
|
|
1111
|
+
at 2,000, so a capped listing is never trusted: the run halves the window
|
|
1112
|
+
(`time_created` between the cursor and an end) until Meta lists it whole,
|
|
1113
|
+
ingests it and moves the cursor to its end, then reads the next window (12
|
|
1114
|
+
listings a form a run at most; a busy backfill goes on next run, with
|
|
1115
|
+
`META_TRUNCATED` shown meanwhile). Rate limits push the next run back an
|
|
1116
|
+
hour; other failures retry after 15 minutes, doubling up to 6 hours. It never
|
|
1117
|
+
uses `paginate()`.
|
|
1118
|
+
5. A lead that cannot come in holds its form's cursor there (the handler
|
|
1119
|
+
missing, or throwing); the leads after it are still ingested and skipped by
|
|
1120
|
+
their leadgen id when read again. Each failed commit is counted by leadgen id
|
|
1121
|
+
in `formLeadFailures` (ids, attempts and the error's code, never its
|
|
1122
|
+
message). A lead that failed 5 times while later leads of its form came in is
|
|
1123
|
+
given up on: the cursor moves past it, the link shows
|
|
1124
|
+
`HANDLER_GAVE_UP: … skipped after 5 failed attempts (first: <leadgenId>)` for
|
|
1125
|
+
that run and the row keeps `skippedAt`. While every lead fails (the host's
|
|
1126
|
+
CRM is down) none is given up on. The host's error message is never logged
|
|
1127
|
+
(it may carry the contact): only a `ConvexError`'s `data.code` or the error's
|
|
1128
|
+
class name.
|
|
1129
|
+
6. A lead the host removes (`leads.remove`) leaves its leadgen id in
|
|
1130
|
+
`formLeadTombstones` (nothing else), so no later read (the webhook retried,
|
|
1131
|
+
the reconciliation, a new link's backfill) brings it back.
|
|
1132
|
+
|
|
1133
|
+
### Reports
|
|
1134
|
+
|
|
1135
|
+
A form lead's origin is `lead_form` when an ad brought it and
|
|
1136
|
+
`lead_form_organic` when the form was filled without one. `lead_form` counts as
|
|
1137
|
+
paid: it is in `bySite[].paid` beside `ctwa_ad`, and its ad's row in the
|
|
1138
|
+
campaign tree counts it. Spend stays on the `ctwa_ad` origin row, as before;
|
|
1139
|
+
cost per lead and return come from `bySite[].paid`. `leads.daily` takes
|
|
1140
|
+
`origin: "lead_form"` (no spend) and `origin: "paid"` (`ctwa_ad` and `lead_form`
|
|
1141
|
+
together, with the spend that bought them): a sales × spend line asks for
|
|
1142
|
+
`"paid"`. A lead's journey (`leads.journey`) carries `form`: its choice answers
|
|
1143
|
+
with their labels, the origin, the decision, `rulePath` and `returned`.
|
|
1144
|
+
|
|
1145
|
+
### Federated Lead Ads functions
|
|
1146
|
+
|
|
1147
|
+
With `metaLeads: true` the federated surface serves the forms screen to the
|
|
1148
|
+
Rastro dashboard, acting for the dashboard connection's own Meta owner like the
|
|
1149
|
+
rest of the Meta surface.
|
|
1150
|
+
|
|
1151
|
+
| Function | Kind | Needs |
|
|
1152
|
+
| ----------------------- | -------- | --------- |
|
|
1153
|
+
| `metaPages` | query | reader |
|
|
1154
|
+
| `metaForms` | query | reader |
|
|
1155
|
+
| `metaFormRules` | query | reader |
|
|
1156
|
+
| `simulateMetaFormRules` | query | reader |
|
|
1157
|
+
| `metaFormsReport` | query | reader |
|
|
1158
|
+
| `metaLinkedPages` | query | reader |
|
|
1159
|
+
| `linkMetaPage` | action | configure |
|
|
1160
|
+
| `unlinkMetaPage` | action | configure |
|
|
1161
|
+
| `refreshMetaForms` | action | configure |
|
|
1162
|
+
| `setMetaFormRules` | mutation | configure |
|
|
1163
|
+
| `returnMetaFormLead` | mutation | configure |
|
|
1164
|
+
|
|
1165
|
+
A Page linked to the site by another owner of the host, and the rules of its
|
|
1166
|
+
forms, are locked to the dashboard (`CONFLICT`: "Ligado pelo uze.ai"); a Page
|
|
1167
|
+
linked to a site outside the grant reads `linkedOutsideScope: true` without its
|
|
1168
|
+
site. The three actions authorize through `authorizeMetaAction`, which the host
|
|
1169
|
+
already exports. Without `metaLeads` the reads refuse with
|
|
1170
|
+
`FEDERATION_INVALID_SCOPE` ("This host does not bring Meta lead forms").
|
|
1171
|
+
`metaLeads` needs `metaAds`: `exposeFederatedAnalyticsApi` throws at setup when
|
|
1172
|
+
it gets `metaLeads: true` alone, since the dashboard shows the Formulários
|
|
1173
|
+
section inside Integrações and links Pages with the Meta login, both of
|
|
1174
|
+
`metaAds`.
|
|
1175
|
+
|
|
1176
|
+
```ts
|
|
1177
|
+
export const {
|
|
1178
|
+
// ...
|
|
1179
|
+
metaPages,
|
|
1180
|
+
metaForms,
|
|
1181
|
+
metaFormRules,
|
|
1182
|
+
simulateMetaFormRules,
|
|
1183
|
+
metaFormsReport,
|
|
1184
|
+
metaLinkedPages,
|
|
1185
|
+
linkMetaPage,
|
|
1186
|
+
unlinkMetaPage,
|
|
1187
|
+
refreshMetaForms,
|
|
1188
|
+
setMetaFormRules,
|
|
1189
|
+
returnMetaFormLead,
|
|
1190
|
+
} = federated;
|
|
1191
|
+
```
|
|
1192
|
+
|
|
1193
|
+
### Lead Ads in the demo
|
|
1194
|
+
|
|
1195
|
+
With `RASTRO_META_FAKE=true` every login lists a Facebook Page per sample client
|
|
1196
|
+
(Loja X, Clínica Sorriso, Pet Mania, Casa Aurora) plus "Sorriso Kids", whose
|
|
1197
|
+
leads Meta did not grant. Clínica Sorriso has the four forms of the approved
|
|
1198
|
+
screens ("Avaliação gratuita", "Clareamento — orçamento", "Lente de contato
|
|
1199
|
+
dental", archived "Promo setembro"); the others one each. Every active form
|
|
1200
|
+
brings made-up leads at a steady pace (Avaliação gratuita one every 11h15), so a
|
|
1201
|
+
linked Page keeps receiving them as time passes. The example host registers its
|
|
1202
|
+
handler in the demo's Meta step; `demoAgency:seedAgencyForms` links each
|
|
1203
|
+
client's Page with 30 days of leads (never part of `seedDemo`, whose numbers are
|
|
1204
|
+
the screens' sample).
|
|
1205
|
+
|
|
1206
|
+
## Lead Ads webhook (control plane)
|
|
1207
|
+
|
|
1208
|
+
Meta sends the lead notifications of every Page to one callback per app. For the
|
|
1209
|
+
"Amage Rastro" app that callback is the Rastro control plane, which only routes:
|
|
1210
|
+
it checks Meta's signature, finds which connected host linked the Page, and
|
|
1211
|
+
hands that host the Page id and the lead id. It never sees the lead itself; the
|
|
1212
|
+
host reads it from Meta with its own Page token. What a host must serve for it
|
|
1213
|
+
is under [Host side](#host-side) below.
|
|
1214
|
+
|
|
1215
|
+
**The callback.** `GET` and `POST /meta/webhook` on the control plane's
|
|
1216
|
+
HTTP-actions origin, in production
|
|
1217
|
+
`https://site.api.amagerastro.com/meta/webhook`. Two control-plane env vars:
|
|
1218
|
+
|
|
1219
|
+
- `RASTRO_META_APP_SECRET`: the app secret. Every `POST` must carry
|
|
1220
|
+
`X-Hub-Signature-256: sha256=<hex>`, the HMAC-SHA256 of the raw body under
|
|
1221
|
+
this secret, compared in constant time; a missing or wrong signature is `401`
|
|
1222
|
+
and nothing in the body is read. Without the variable, `POST` answers `503`,
|
|
1223
|
+
so Meta keeps the notifications and retries.
|
|
1224
|
+
- `RASTRO_META_WEBHOOK_VERIFY_TOKEN`: a random value typed once in the app's
|
|
1225
|
+
Webhooks setup. `GET` echoes `hub.challenge` only for `hub.mode=subscribe` and
|
|
1226
|
+
this token (constant-time compare); anything else is `403`.
|
|
1227
|
+
|
|
1228
|
+
In the app dashboard: Webhooks, object **Page**, callback URL above, the verify
|
|
1229
|
+
token, field **`leadgen`**. Each host subscribes its Pages to the app when a
|
|
1230
|
+
site links them. A signed `POST` answers `200` as soon as its lead ids are
|
|
1231
|
+
recorded; forwarding runs scheduled, so a slow host never makes Meta retry or
|
|
1232
|
+
disable the subscription. A signed body that is not JSON, or changes other than
|
|
1233
|
+
`leadgen`, are acknowledged and ignored.
|
|
1234
|
+
|
|
1235
|
+
**Which hosts may receive leads.** A host's Page list is its own word, and Page
|
|
1236
|
+
ids are public, so the control plane only takes it from deployments the operator
|
|
1237
|
+
lists in `RASTRO_META_LEADS_DEPLOYMENT_ORIGINS` (exact origins of the
|
|
1238
|
+
connections' deployment URLs, comma-separated). Unset, no host receives leads
|
|
1239
|
+
and an unknown-Page drop logs `META_LEADS_DEPLOYMENT_ORIGINS_UNSET`. A
|
|
1240
|
+
connection off the list is never read, and the routes it held are ignored and
|
|
1241
|
+
then dropped, so it can neither take a Page nor block another host's.
|
|
1242
|
+
|
|
1243
|
+
**Which host owns a Page.** Federation only goes from the dashboard to the host,
|
|
1244
|
+
so the control plane reads the hosts: on a listed connection advertising
|
|
1245
|
+
`metaLeads`, it calls the federated query `metaLinkedPages({ siteIds })` →
|
|
1246
|
+
`{ pages: Array<{ pageId; siteId }>; leadgenReady; issuer; audience }` (ten
|
|
1247
|
+
sites per call, at most the `maxSitesPerConnection` first sites of `listSites`,
|
|
1248
|
+
within 45 seconds per host) with a two-minute token of its own:
|
|
1249
|
+
`analytics:read`, subject `rastro:meta-page-routes`. The answer becomes
|
|
1250
|
+
`metaPageRoutes` rows (one per Page and connection), each keeping the host's
|
|
1251
|
+
`leadgenReady`, `issuer` and `audience`. A Page is routed only when
|
|
1252
|
+
`leadgenReady` is true and `issuer` equals the control plane's own federation
|
|
1253
|
+
issuer; otherwise its route row records why (`HOST_NOT_READY`: the host has no
|
|
1254
|
+
`RASTRO_FEDERATION_AUDIENCE`; `ISSUER_MISMATCH`: the host trusts another
|
|
1255
|
+
dashboard; `AUDIENCE_MISMATCH`: the host's `audience` is not the connection's
|
|
1256
|
+
deployment URL; `HOST_SITE_URL_MISSING` / `HOST_SITE_URL_UNTRUSTED`: the
|
|
1257
|
+
connection has no site origin a forward may be posted to), the reason is logged
|
|
1258
|
+
once per refresh, and no forward is signed for it, so a lead never waits 24
|
|
1259
|
+
hours on a host that would refuse it. The rows are refreshed:
|
|
1260
|
+
|
|
1261
|
+
1. when the connection is verified ("Verificar novamente"), and when a transfer
|
|
1262
|
+
of it settles;
|
|
1263
|
+
2. right after the dashboard links or unlinks a Page (`linkMetaPage`,
|
|
1264
|
+
`unlinkMetaPage`): it calls the control plane's
|
|
1265
|
+
`metaPageRoutes.refreshConnection({ connectionId })` (a member of the
|
|
1266
|
+
connection's organization only), which schedules that connection's refresh;
|
|
1267
|
+
3. every 30 minutes for every listed connection advertising `metaLeads`;
|
|
1268
|
+
4. when a notification names a Page no route knows: every such host is read by a
|
|
1269
|
+
refresh that started after the notification arrived, so a Page linked seconds
|
|
1270
|
+
before its first lead is found. Notifications arriving while a refresh runs
|
|
1271
|
+
wait for it (as long as it may run: five minutes), refreshes keep 10 seconds
|
|
1272
|
+
between them, and a burst shares one. A notification that still matches
|
|
1273
|
+
nothing is dropped, logged as `META_WEBHOOK_UNKNOWN_PAGE` with the Page id
|
|
1274
|
+
alone; it is routed or dropped even when the refresh fails. A Page that
|
|
1275
|
+
refresh found no host claiming is remembered for ten minutes
|
|
1276
|
+
(`metaUnknownPages`): its next leads are dropped at once instead of reading
|
|
1277
|
+
every host again. A host listing the Page clears it.
|
|
1278
|
+
5. when a Page two deployments claim has a claim read more than a minute ago:
|
|
1279
|
+
that connection is read again (it may be stale: the Page unlinked on its host
|
|
1280
|
+
without the dashboard hearing of it), and the lead is routed once more after
|
|
1281
|
+
that read.
|
|
1282
|
+
|
|
1283
|
+
A host that cannot be read keeps the routes it had, and so does a connection
|
|
1284
|
+
whose transfer is pending: its leads are recorded and retried
|
|
1285
|
+
(`CONNECTION_TRANSFER_PENDING`) until the transfer settles. A host re-verified
|
|
1286
|
+
without `metaLeads`, taken off the list, revoked or deleted loses them. Several
|
|
1287
|
+
organizations may connect the same host deployment; their routes for one Page
|
|
1288
|
+
are one host, and the oldest connection carries the lead. A Page that two
|
|
1289
|
+
different deployments claim routes to neither and logs `PAGE_ROUTE_CONFLICT` as
|
|
1290
|
+
an error (the host itself already refuses one Page on two sites); the leads stay
|
|
1291
|
+
with Meta, where each host's reconciliation can still read them.
|
|
1292
|
+
|
|
1293
|
+
**What the dashboard shows.**
|
|
1294
|
+
`metaPageRoutes.connectionRouting({ connectionId })` (a query, members of the
|
|
1295
|
+
connection's organization only) answers
|
|
1296
|
+
`{ allowed, pages: Array<{ pageId, issue }> }`: `allowed` is false when the
|
|
1297
|
+
control plane forwards nothing of that host (off the operator's list, without
|
|
1298
|
+
`metaLeads`, not active), and each routed Page's `issue` is its block reason as
|
|
1299
|
+
read now, `CONFLICT` when another deployment claims it too, `HOST_REFUSED` when
|
|
1300
|
+
the host answered the last forward to this connection with `401` although it
|
|
1301
|
+
reports itself ready (a key or `kid` mismatch, a `jwks_uri` that is not the
|
|
1302
|
+
issuer's own; kept in `metaLeadgenRefusals` until a forward is delivered again),
|
|
1303
|
+
or `null`. Formulários puts it on each linked Page (`routeIssue`, `NOT_ROUTED`
|
|
1304
|
+
for a host not forwarded to): the Page card says why its leads arrive every 15
|
|
1305
|
+
minutes instead of at once, and "Leads chegam na hora" is not shown.
|
|
1306
|
+
|
|
1307
|
+
**Forwarding.** Each routed notification is one `metaLeadgenForwards` row (Page
|
|
1308
|
+
id, lead id, connection, site, status; never lead data) and one scheduled
|
|
1309
|
+
attempt:
|
|
1310
|
+
|
|
1311
|
+
```http
|
|
1312
|
+
POST <host site URL>/rastro/meta/leadgen
|
|
1313
|
+
Authorization: Bearer <token>
|
|
1314
|
+
Content-Type: application/json
|
|
1315
|
+
|
|
1316
|
+
{ "pageId": "<page id>", "leadgenId": "<leadgen id>" }
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
The host site URL is the connection's HTTP-actions URL, and `/rastro/` is the
|
|
1320
|
+
component's `httpPrefix`: the control plane cannot learn a host's mount point,
|
|
1321
|
+
so Lead Ads forwarding requires the component mounted under `/rastro/` (a host
|
|
1322
|
+
under another prefix answers `404` to every forward, retried for 24 hours, and
|
|
1323
|
+
its leads arrive only with the reconciliation). An organization admin types that
|
|
1324
|
+
URL, so before each attempt it must be `https` on the default port and on
|
|
1325
|
+
`.convex.site` or `.synapsepanel.com`, or an origin listed in
|
|
1326
|
+
`RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS` (a custom domain); otherwise the
|
|
1327
|
+
forward fails as `HOST_SITE_URL_UNTRUSTED` and nothing is signed. A token the
|
|
1328
|
+
control plane cannot sign (keys missing or not matching) is retried as
|
|
1329
|
+
`ISSUER_MISCONFIGURED`, not blamed on the host. The control plane reads the
|
|
1330
|
+
host's small JSON answer:
|
|
1331
|
+
|
|
1332
|
+
- `202 { accepted: true }` (any `2xx` that does not say otherwise) is delivered.
|
|
1333
|
+
- `200 { accepted: false, reason: "PAGE_NOT_LINKED" }` is `not_linked`, not
|
|
1334
|
+
delivered: the host no longer links the Page (unlinked, or moved to another
|
|
1335
|
+
host). The control plane refreshes that connection's routes at once and
|
|
1336
|
+
accepts the notification again, once, as for an unknown Page, so a Page moved
|
|
1337
|
+
to another host is forwarded there.
|
|
1338
|
+
- `503 { error: { code: "FEDERATION_NOT_CONFIGURED" } }` and `401` are final for
|
|
1339
|
+
that forward (no 24-hour retry) and recorded with their code: the host lacks
|
|
1340
|
+
its audience, or does not trust this issuer, and a retry would not change it.
|
|
1341
|
+
`503 { error: { code: "ISSUER_UNREACHABLE" } }` is retried: the host could not
|
|
1342
|
+
read this issuer's keys for a moment.
|
|
1343
|
+
- `400`, `403`, `410`, `413` and `422` are final (the host read the request and
|
|
1344
|
+
said no).
|
|
1345
|
+
|
|
1346
|
+
Anything else, no answer included, is retried after 30 s, 1, 2, 5, 10, 20 and 40
|
|
1347
|
+
minutes, 1 hour, then every 2 hours, until 24 hours after the notification. A
|
|
1348
|
+
lead Meta delivers twice is forwarded once. Finished rows are kept a week, then
|
|
1349
|
+
deleted.
|
|
1350
|
+
|
|
1351
|
+
The token is signed by the federation issuer with the same keys and audience as
|
|
1352
|
+
a connection token, so the host verifies it with the trust it already has
|
|
1353
|
+
(`RASTRO_FEDERATION_ISSUER`, `RASTRO_FEDERATION_AUDIENCE`). Its names are
|
|
1354
|
+
exported by the package as `META_LEADGEN_TOKEN` (from `@iann29/rastro`); the
|
|
1355
|
+
control plane signs with them and the host's route checks them, so the two
|
|
1356
|
+
cannot drift apart. Its claims:
|
|
1357
|
+
|
|
1358
|
+
| Claim | Value |
|
|
1359
|
+
| --------------------------- | -------------------------------------------------- |
|
|
1360
|
+
| `iss` | the control plane's federation issuer |
|
|
1361
|
+
| `aud` | the host's deployment URL (the audience) |
|
|
1362
|
+
| `sub` | `rastro:meta-webhook` |
|
|
1363
|
+
| `iat`, `exp`, `jti` | two minutes of life (a host refuses more than ten) |
|
|
1364
|
+
| `rastro_purpose` | `meta_leadgen` |
|
|
1365
|
+
| `rastro_connection_id` | the dashboard connection the Page is routed to |
|
|
1366
|
+
| `rastro_organization_id` | that connection's organization |
|
|
1367
|
+
| `rastro_connection_version` | that connection's version |
|
|
1368
|
+
| `rastro_page_id` | the Page, equal to the body's `pageId` |
|
|
1369
|
+
| `rastro_leadgen_id` | the lead, equal to the body's `leadgenId` |
|
|
1370
|
+
|
|
1371
|
+
It has no `rastro_permissions`, so the federation authorizer refuses it
|
|
1372
|
+
(`FEDERATION_INVALID_CLAIMS`) and it can never be replayed as a read token.
|
|
1373
|
+
|
|
1374
|
+
### Host side
|
|
1375
|
+
|
|
1376
|
+
The component serves this half; a host turns it on as [Turn it on](#turn-it-on)
|
|
1377
|
+
says, and [How a lead comes in](#how-a-lead-comes-in) walks the route. What this
|
|
1378
|
+
control plane depends on:
|
|
1379
|
+
|
|
1380
|
+
- The `metaLeads` capability and `metaLinkedPages` as a manifest `functions`
|
|
1381
|
+
key: a host that records its functions without it is never read.
|
|
1382
|
+
- `metaLinkedPages({ siteIds })` answers
|
|
1383
|
+
`{ pages: Array<{ pageId; siteId }>; leadgenReady; issuer; audience; handlerReady }`
|
|
1384
|
+
for the Pages linked to those sites only, one site per Page, to any reader
|
|
1385
|
+
token. The control plane requires `audience` to equal the connection's
|
|
1386
|
+
deployment URL.
|
|
1387
|
+
- `POST /rastro/meta/leadgen` (the component mounted with `httpPrefix`
|
|
1388
|
+
`/rastro/`, the only prefix the control plane posts to) verifies the bearer
|
|
1389
|
+
token against the published keys of the issuer `metaLinkedPages` reports and
|
|
1390
|
+
its own audience, with a lifetime of at most
|
|
1391
|
+
`META_LEADGEN_TOKEN.maxLifetimeSeconds`; requires
|
|
1392
|
+
`rastro_purpose === "meta_leadgen"`, because a dashboard member's connection
|
|
1393
|
+
token is a valid JWT of the same issuer and audience and must not be able to
|
|
1394
|
+
ask a host to ingest a lead; requires `rastro_page_id` and `rastro_leadgen_id`
|
|
1395
|
+
to equal the body's `pageId` and `leadgenId`; then schedules the ingestion and
|
|
1396
|
+
answers `202`, or `200 PAGE_NOT_LINKED`, `401`,
|
|
1397
|
+
`503 FEDERATION_NOT_CONFIGURED` (final) or `503 ISSUER_UNREACHABLE` (retried)
|
|
1398
|
+
as [Forwarding](#lead-ads-webhook-control-plane) reads them.
|
|
1399
|
+
- The connection's deployment origin is in
|
|
1400
|
+
`RASTRO_META_LEADS_DEPLOYMENT_ORIGINS`, and a custom-domain site URL in
|
|
1401
|
+
`RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS`.
|
|
1402
|
+
|
|
1403
|
+
## The Formulários screens (`FormsView`, Lead Ads)
|
|
1404
|
+
|
|
1405
|
+
Lead Ads (instant forms, contract §12) get their own screens in
|
|
1406
|
+
`@iann29/rastro/ui`, pure like `IntegrationsView` and with the same props
|
|
1407
|
+
conventions: `audience` (`"agency"` or `"business"`, the first site only),
|
|
1408
|
+
`productName`, `boardLabel`, `partner`, `otherOwnerLabel` and `permitted`.
|
|
1409
|
+
`@iann29/rastro/ui/sample` has the approved demo in `sampleForms` (Clínica
|
|
1410
|
+
Sorriso, the form "Avaliação gratuita", the leads Mariana Costa and Pedro
|
|
1411
|
+
Alves).
|
|
1412
|
+
|
|
1413
|
+
- `FormsView` lists the connection's Pages (`pages`, from `metaPages`) and the
|
|
1414
|
+
forms of the linked ones (`forms`: `metaForms` answers a site's Pages with
|
|
1415
|
+
their forms nested; `metaFormsRows` flattens the sites' answers into the rows
|
|
1416
|
+
the screen reads, with each form's `leads30d`, `daily` and `lastLeadAt`).
|
|
1417
|
+
`report` is `formsReport` as the host answers it (`{ forms, partial }`, the
|
|
1418
|
+
columns and discards as arrays, one row per `(siteId, formId)`, read for the
|
|
1419
|
+
site the Page feeds now), for the summary and the drawer. `rules` is
|
|
1420
|
+
`formRules` per form id: `configured: false` is a form that never had rules
|
|
1421
|
+
("Novo · sem regra"), `undefined` is still loading, and `null` means the host
|
|
1422
|
+
could not answer, which shows the rules as unreadable and blocks saving so an
|
|
1423
|
+
empty draft never overwrites them. A Page without `leadsAllowed` shows
|
|
1424
|
+
"Reconectar a Meta" (`onReconnect`); an unlinked Page offers
|
|
1425
|
+
`onLinkPage(siteId, pageId)`, a linked one `onUnlinkPage` after a
|
|
1426
|
+
confirmation. A linked Page's `lastError` code gets its own short text: only
|
|
1427
|
+
`META_TOKEN_INVALID` and `META_PERMISSION_MISSING` ask to reconnect Meta;
|
|
1428
|
+
`HANDLER_MISSING` (or `handlerReady: false`) says the host still needs its
|
|
1429
|
+
form lead handler; the codes that pass by themselves (`META_TRUNCATED`,
|
|
1430
|
+
`META_RATE_LIMITED`, `META_UNAVAILABLE`, `RUN_TIMEOUT`) never say something is
|
|
1431
|
+
broken. A Page linked to a site outside the reader's grant
|
|
1432
|
+
(`linkedOutsideScope`, the site not named) shows "Ligada a outro cliente/site"
|
|
1433
|
+
and is never offered. A Page or rules another owner set (`own: false`) show
|
|
1434
|
+
"Ligado pelo <otherOwnerLabel>" and stay read only.
|
|
1435
|
+
- `FormRulesDrawer` (a row opens it) edits one form: the entry column (null is
|
|
1436
|
+
the board's own door), the ordered rules (first match wins; drag or arrow keys
|
|
1437
|
+
reorder; `monthCount` per stored rule; a discard keeps its reason on the
|
|
1438
|
+
card), the last 30 days simulated under the draft
|
|
1439
|
+
(`onSimulate(siteId, formId, draft)` asks, debounced, with the entry and the
|
|
1440
|
+
complete rules only, so editing the first message never asks again;
|
|
1441
|
+
`simulation` is `simulateFormRules`' answer, whose `columns` already hold the
|
|
1442
|
+
entry column's leads when the draft names one) and how the first contact
|
|
1443
|
+
happens: the lead writes first (recommended, the button's state), one click by
|
|
1444
|
+
a person (always on, the suggested text with `{primeiro_nome}`) or the
|
|
1445
|
+
automatic send, which turns on only behind the risk checkbox and keeps its
|
|
1446
|
+
brakes (wait at least 15 min, a maximum per hour, business hours; the random
|
|
1447
|
+
interval between sends is fixed). Saving calls
|
|
1448
|
+
`onSaveRules(siteId, formId, rules)` with `setFormRules`'s shape; the drawer
|
|
1449
|
+
closes when that resolves to anything but `false`, and a refusal keeps the
|
|
1450
|
+
draft open beside `error`. It changes only the next leads. When Meta does not
|
|
1451
|
+
say whether the button is there (`whatsappButton: null`), the company marks
|
|
1452
|
+
"Já liguei": that is `whatsappButtonConfirmed`, saved on its own and read back
|
|
1453
|
+
from `formRules`; without the automatic send, who writes first follows it
|
|
1454
|
+
(`lead_starts` with the button, `one_click` without).
|
|
1455
|
+
- `WhatsAppButtonGuide` (the drawer's "Ver como ligar") walks the four steps in
|
|
1456
|
+
Meta's form editor and lists what can be checked: the published copy and its
|
|
1457
|
+
button from the Page's forms, plus the host's own `guideChecks`.
|
|
1458
|
+
`onRefreshForms(siteId)` is its "Conferir de novo".
|
|
1459
|
+
- `FormLeadBadge` ("Formulário · Avaliação gratuita", or "veio do formulário")
|
|
1460
|
+
and `FormLeadAnswers` (answers, origin and the path the lead followed, as a
|
|
1461
|
+
`card`, a `panel` or the phone's `compact`) are for the host's card and
|
|
1462
|
+
contact panel. The host passes what it holds: Rastro never stores a form
|
|
1463
|
+
lead's name, phone, e-mail or free-text answers.
|
|
1464
|
+
`formLeadFromJourney(journey)` turns a lead journey's `form` (`leads.journey`:
|
|
1465
|
+
origin, choice answers, `rulePath`, decision) into their `FormLead`;
|
|
1466
|
+
Campanhas' lead drawer uses it to show the badge and the answers whenever
|
|
1467
|
+
`journey.form` is there.
|
|
1468
|
+
|
|
1469
|
+
Each piece reads its own width with container queries: the list turns into one
|
|
1470
|
+
button per form under 860px, and the drawer into the phone layout under 640px.
|
|
1471
|
+
|
|
1472
|
+
The dashboard shows a "Formulários" tab in Integrações only for a host that
|
|
1473
|
+
advertises `metaLeads` and lists `metaPages`. It calls the federated functions
|
|
1474
|
+
by the names of `FEDERATED_ANALYTICS_META_LEAD_FUNCTIONS`: `metaPages`,
|
|
1475
|
+
`metaForms` (one call per site, with `now`), `metaFormRules`,
|
|
1476
|
+
`simulateMetaFormRules` (the entry, the rules and `now`; no first contact),
|
|
1477
|
+
`metaFormsReport` (reads) and `linkMetaPage`, `unlinkMetaPage`,
|
|
1478
|
+
`setMetaFormRules` (with `whatsappButtonConfirmed`), `refreshMetaForms` (writes,
|
|
1479
|
+
behind `analytics:configure`); the report, the simulation and "Conferir de novo"
|
|
1480
|
+
each wait for the host to list their function. The host's 30-day reads take the
|
|
1481
|
+
end of the current UTC day as `now`, and the report a 30-day window, both
|
|
1482
|
+
following the dashboard's clock; the host counts the report in whole UTC days
|
|
1483
|
+
(see `formsReport` above), so a window aligned on UTC days matches the rows'
|
|
1484
|
+
`leads30d`. Right after a Page is linked or unlinked, the dashboard asks its
|
|
1485
|
+
control plane to refresh that connection's Page routes
|
|
1486
|
+
(`metaPageRoutes.refreshConnection`), so the first lead of a new link is routed
|
|
1487
|
+
without waiting. A form whose rules the host could not read stays unreadable
|
|
1488
|
+
rather than empty. Each site's board comes from the columns its host declared
|
|
1489
|
+
with `setPipeline`. `returnMetaFormLead` (sending a discarded lead back) has no
|
|
1490
|
+
button on the dashboard yet.
|
|
1491
|
+
|
|
1492
|
+
In Campanhas, a form an ad ran arrives with origin `lead_form`, which counts as
|
|
1493
|
+
paid next to `ctwa_ad` (it is never listed among the organic origins), and its
|
|
1494
|
+
line reads "Formulário · <ad>"; a form filled without an ad arrives as
|
|
1495
|
+
`lead_form_organic`, "Formulário instantâneo". Both enter through the
|
|
1496
|
+
`lead_form` entry point, and the journey starts with "Enviou o formulário do
|
|
1497
|
+
anúncio" (or "Enviou um formulário instantâneo"). Meta's short platform keys
|
|
1498
|
+
(`ig`, `fb`) read as the app's name. The Vendas line, and the portfolio's
|
|
1499
|
+
sparklines and week alerts, read `leadsDaily` with `origin: "paid"` (every lead
|
|
1500
|
+
an ad bought, against the same spend) on a host whose manifest carries
|
|
1501
|
+
`leadsPaid` (or `metaLeads`), and `"ctwa_ad"` on a host of an older release.
|