@iann29/rastro 0.10.8 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +8 -0
  2. package/agent/integration.md +45 -0
  3. package/agent/manifest.json +4 -4
  4. package/dist/client/federation.d.ts +82 -9
  5. package/dist/client/federation.d.ts.map +1 -1
  6. package/dist/client/federation.js +40 -2
  7. package/dist/client/federation.js.map +1 -1
  8. package/dist/client/index.d.ts +464 -8
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +259 -7
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +10 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -1
  14. package/dist/component/_generated/api.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +273 -21
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/_generated/server.d.ts +2 -0
  18. package/dist/component/_generated/server.d.ts.map +1 -1
  19. package/dist/component/_generated/server.js.map +1 -1
  20. package/dist/component/constants.d.ts +31 -0
  21. package/dist/component/constants.d.ts.map +1 -1
  22. package/dist/component/constants.js +31 -0
  23. package/dist/component/constants.js.map +1 -1
  24. package/dist/component/convex.config.d.ts +2 -0
  25. package/dist/component/convex.config.js +7 -0
  26. package/dist/component/convex.config.js.map +1 -1
  27. package/dist/component/errors.d.ts +1 -1
  28. package/dist/component/errors.d.ts.map +1 -1
  29. package/dist/component/errors.js.map +1 -1
  30. package/dist/component/http.d.ts.map +1 -1
  31. package/dist/component/http.js +64 -0
  32. package/dist/component/http.js.map +1 -1
  33. package/dist/component/leadAdsValidators.d.ts +1092 -0
  34. package/dist/component/leadAdsValidators.d.ts.map +1 -0
  35. package/dist/component/leadAdsValidators.js +304 -0
  36. package/dist/component/leadAdsValidators.js.map +1 -0
  37. package/dist/component/leadFormRules.d.ts +60 -0
  38. package/dist/component/leadFormRules.d.ts.map +1 -0
  39. package/dist/component/leadFormRules.js +140 -0
  40. package/dist/component/leadFormRules.js.map +1 -0
  41. package/dist/component/leadFormStore.d.ts +16 -0
  42. package/dist/component/leadFormStore.d.ts.map +1 -0
  43. package/dist/component/leadFormStore.js +92 -0
  44. package/dist/component/leadFormStore.js.map +1 -0
  45. package/dist/component/leadgenToken.d.ts +56 -0
  46. package/dist/component/leadgenToken.d.ts.map +1 -0
  47. package/dist/component/leadgenToken.js +262 -0
  48. package/dist/component/leadgenToken.js.map +1 -0
  49. package/dist/component/leads.d.ts +76 -7
  50. package/dist/component/leads.d.ts.map +1 -1
  51. package/dist/component/leads.js +112 -78
  52. package/dist/component/leads.js.map +1 -1
  53. package/dist/component/meta.d.ts.map +1 -1
  54. package/dist/component/meta.js +21 -1
  55. package/dist/component/meta.js.map +1 -1
  56. package/dist/component/metaCapi.d.ts.map +1 -1
  57. package/dist/component/metaCapi.js +10 -0
  58. package/dist/component/metaCapi.js.map +1 -1
  59. package/dist/component/metaFake.d.ts +44 -0
  60. package/dist/component/metaFake.d.ts.map +1 -1
  61. package/dist/component/metaFake.js +400 -1
  62. package/dist/component/metaFake.js.map +1 -1
  63. package/dist/component/metaGraph.d.ts +91 -0
  64. package/dist/component/metaGraph.d.ts.map +1 -1
  65. package/dist/component/metaGraph.js +217 -0
  66. package/dist/component/metaGraph.js.map +1 -1
  67. package/dist/component/metaLeads.d.ts +681 -0
  68. package/dist/component/metaLeads.d.ts.map +1 -0
  69. package/dist/component/metaLeads.js +2634 -0
  70. package/dist/component/metaLeads.js.map +1 -0
  71. package/dist/component/schema.d.ts +414 -5
  72. package/dist/component/schema.d.ts.map +1 -1
  73. package/dist/component/schema.js +169 -1
  74. package/dist/component/schema.js.map +1 -1
  75. package/dist/component/validators.d.ts +235 -11
  76. package/dist/component/validators.d.ts.map +1 -1
  77. package/dist/component/validators.js +14 -1
  78. package/dist/component/validators.js.map +1 -1
  79. package/dist/tracker/generated.d.ts +1 -1
  80. package/dist/tracker/generated.js +1 -1
  81. package/dist/ui/campaigns/CampaignsView.d.ts +5 -1
  82. package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -1
  83. package/dist/ui/campaigns/CampaignsView.js.map +1 -1
  84. package/dist/ui/campaigns/Drawer.d.ts.map +1 -1
  85. package/dist/ui/campaigns/Drawer.js +8 -26
  86. package/dist/ui/campaigns/Drawer.js.map +1 -1
  87. package/dist/ui/campaigns/labels.d.ts +29 -1
  88. package/dist/ui/campaigns/labels.d.ts.map +1 -1
  89. package/dist/ui/campaigns/labels.js +77 -2
  90. package/dist/ui/campaigns/labels.js.map +1 -1
  91. package/dist/ui/campaigns/model.js +2 -2
  92. package/dist/ui/campaigns/model.js.map +1 -1
  93. package/dist/ui/forms/FirstContact.d.ts +19 -0
  94. package/dist/ui/forms/FirstContact.d.ts.map +1 -0
  95. package/dist/ui/forms/FirstContact.js +81 -0
  96. package/dist/ui/forms/FirstContact.js.map +1 -0
  97. package/dist/ui/forms/FormLead.d.ts +28 -0
  98. package/dist/ui/forms/FormLead.d.ts.map +1 -0
  99. package/dist/ui/forms/FormLead.js +199 -0
  100. package/dist/ui/forms/FormLead.js.map +1 -0
  101. package/dist/ui/forms/FormRulesDrawer.d.ts +49 -0
  102. package/dist/ui/forms/FormRulesDrawer.d.ts.map +1 -0
  103. package/dist/ui/forms/FormRulesDrawer.js +149 -0
  104. package/dist/ui/forms/FormRulesDrawer.js.map +1 -0
  105. package/dist/ui/forms/FormsView.d.ts +10 -0
  106. package/dist/ui/forms/FormsView.d.ts.map +1 -0
  107. package/dist/ui/forms/FormsView.js +215 -0
  108. package/dist/ui/forms/FormsView.js.map +1 -0
  109. package/dist/ui/forms/Icons.d.ts +46 -0
  110. package/dist/ui/forms/Icons.d.ts.map +1 -0
  111. package/dist/ui/forms/Icons.js +45 -0
  112. package/dist/ui/forms/Icons.js.map +1 -0
  113. package/dist/ui/forms/RuleRow.d.ts +37 -0
  114. package/dist/ui/forms/RuleRow.d.ts.map +1 -0
  115. package/dist/ui/forms/RuleRow.js +80 -0
  116. package/dist/ui/forms/RuleRow.js.map +1 -0
  117. package/dist/ui/forms/WhatsAppButtonGuide.d.ts +33 -0
  118. package/dist/ui/forms/WhatsAppButtonGuide.d.ts.map +1 -0
  119. package/dist/ui/forms/WhatsAppButtonGuide.js +90 -0
  120. package/dist/ui/forms/WhatsAppButtonGuide.js.map +1 -0
  121. package/dist/ui/forms/index.d.ts +11 -0
  122. package/dist/ui/forms/index.d.ts.map +1 -0
  123. package/dist/ui/forms/index.js +7 -0
  124. package/dist/ui/forms/index.js.map +1 -0
  125. package/dist/ui/forms/journey.d.ts +19 -0
  126. package/dist/ui/forms/journey.d.ts.map +1 -0
  127. package/dist/ui/forms/journey.js +54 -0
  128. package/dist/ui/forms/journey.js.map +1 -0
  129. package/dist/ui/forms/model.d.ts +239 -0
  130. package/dist/ui/forms/model.d.ts.map +1 -0
  131. package/dist/ui/forms/model.js +556 -0
  132. package/dist/ui/forms/model.js.map +1 -0
  133. package/dist/ui/forms/sample.d.ts +36 -0
  134. package/dist/ui/forms/sample.d.ts.map +1 -0
  135. package/dist/ui/forms/sample.js +444 -0
  136. package/dist/ui/forms/sample.js.map +1 -0
  137. package/dist/ui/forms/types.d.ts +454 -0
  138. package/dist/ui/forms/types.d.ts.map +1 -0
  139. package/dist/ui/forms/types.js +2 -0
  140. package/dist/ui/forms/types.js.map +1 -0
  141. package/dist/ui/index.d.ts +2 -0
  142. package/dist/ui/index.d.ts.map +1 -1
  143. package/dist/ui/index.js +4 -0
  144. package/dist/ui/index.js.map +1 -1
  145. package/dist/ui/integrations/Overlay.d.ts +3 -1
  146. package/dist/ui/integrations/Overlay.d.ts.map +1 -1
  147. package/dist/ui/integrations/Overlay.js +2 -2
  148. package/dist/ui/integrations/Overlay.js.map +1 -1
  149. package/dist/ui/portfolio/PortfolioView.d.ts +4 -1
  150. package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -1
  151. package/dist/ui/portfolio/PortfolioView.js.map +1 -1
  152. package/dist/ui/sample.d.ts +3 -1
  153. package/dist/ui/sample.d.ts.map +1 -1
  154. package/dist/ui/sample.js +2 -0
  155. package/dist/ui/sample.js.map +1 -1
  156. package/dist/ui/seed.d.ts +1 -1
  157. package/dist/ui/ui.css +2301 -0
  158. package/docs/federation.md +19 -0
  159. package/docs/meta-ads.md +713 -7
  160. package/docs/upgrading.md +75 -0
  161. package/llms.txt +1 -0
  162. package/package.json +1 -1
  163. package/src/component/_generated/api.ts +10 -0
  164. package/src/component/_generated/component.ts +289 -20
  165. package/src/component/_generated/server.ts +2 -0
  166. package/src/component/constants.ts +33 -0
  167. package/src/component/convex.config.ts +7 -0
  168. package/src/component/errors.ts +3 -1
  169. package/src/component/http.ts +75 -0
  170. package/src/component/leadAdsValidators.ts +365 -0
  171. package/src/component/leadFormRules.ts +202 -0
  172. package/src/component/leadFormStore.ts +129 -0
  173. package/src/component/leadgenToken.ts +348 -0
  174. package/src/component/leads.ts +136 -84
  175. package/src/component/meta.ts +21 -1
  176. package/src/component/metaCapi.ts +13 -1
  177. package/src/component/metaFake.ts +485 -1
  178. package/src/component/metaGraph.ts +327 -0
  179. package/src/component/metaLeads.ts +3101 -0
  180. package/src/component/schema.ts +187 -1
  181. package/src/component/validators.ts +17 -1
  182. package/src/tracker/generated.ts +1 -1
@@ -0,0 +1,365 @@
1
+ import { v, type Infer } from "convex/values";
2
+
3
+ /*
4
+ * Lead Ads: Pages, instant forms, their rules and the leads they bring
5
+ * (metaLeads.ts, since 0.11.0). A lead's personal data (name, phone, e-mail,
6
+ * any answer typed freely) never reaches a table: it goes from Meta to the
7
+ * host's handler in memory. Only the answers to choice questions are kept.
8
+ */
9
+
10
+ const countersFields = {
11
+ leads: v.number(),
12
+ qualified: v.number(),
13
+ won: v.number(),
14
+ revenueCents: v.number(),
15
+ };
16
+
17
+ /** One option of a choice question, as Meta lists it. */
18
+ export const metaFormOptionValidator = v.object({
19
+ key: v.string(),
20
+ value: v.string(),
21
+ });
22
+
23
+ /** One question of an instant form. `options` only on choice questions. */
24
+ export const metaFormQuestionValidator = v.object({
25
+ key: v.string(),
26
+ label: v.string(),
27
+ /** Meta's type: `FULL_NAME`, `EMAIL`, `PHONE`, `CUSTOM`… */
28
+ type: v.string(),
29
+ options: v.optional(v.array(metaFormOptionValidator)),
30
+ });
31
+
32
+ /** Where a rule sends a lead: a board column, or the discard with a reason. */
33
+ export const formRuleTargetValidator = v.union(
34
+ v.object({ column: v.string() }),
35
+ v.object({ discard: v.string() }),
36
+ );
37
+
38
+ /** One rule: an answer to a choice question decides where the lead goes. */
39
+ export const formRuleValidator = v.object({
40
+ questionKey: v.string(),
41
+ /** Option keys (or values) of the question; any of them matches. */
42
+ answers: v.array(v.string()),
43
+ to: formRuleTargetValidator,
44
+ });
45
+
46
+ /** The brakes on an automatic first message (the host's own sender). */
47
+ export const firstContactBrakesValidator = v.object({
48
+ waitMinutes: v.number(),
49
+ maxPerHour: v.number(),
50
+ businessHoursOnly: v.boolean(),
51
+ });
52
+
53
+ /**
54
+ * How the host makes the first contact. `lead_starts`: the form's end screen
55
+ * has the WhatsApp button and the lead writes first (recommended);
56
+ * `one_click`: a person sends the suggested `text`; `automatic`: a scheduled
57
+ * send under `brakes`, which the host keeps off unless the business accepted
58
+ * the risk.
59
+ */
60
+ export const firstContactValidator = v.object({
61
+ mode: v.union(
62
+ v.literal("lead_starts"),
63
+ v.literal("one_click"),
64
+ v.literal("automatic"),
65
+ ),
66
+ text: v.optional(v.string()),
67
+ brakes: v.optional(firstContactBrakesValidator),
68
+ });
69
+
70
+ /**
71
+ * What the rules decided for a lead: a column (`null` is the host's own
72
+ * entry, when no rule matched and the form names no entry column), or the
73
+ * discard with its reason.
74
+ */
75
+ export const formLeadDecisionValidator = v.union(
76
+ v.object({ column: v.union(v.string(), v.null()) }),
77
+ v.object({ discard: v.string() }),
78
+ );
79
+
80
+ /** A choice answer as stored: the question and the option keys picked. */
81
+ export const formChoiceAnswerValidator = v.object({
82
+ questionKey: v.string(),
83
+ answers: v.array(v.string()),
84
+ });
85
+
86
+ /** Where a form lead came from: the Page, the form and the ad. */
87
+ export const formLeadOriginValidator = v.object({
88
+ pageId: v.string(),
89
+ pageName: v.union(v.string(), v.null()),
90
+ formId: v.string(),
91
+ formName: v.union(v.string(), v.null()),
92
+ adId: v.union(v.string(), v.null()),
93
+ adName: v.union(v.string(), v.null()),
94
+ adsetId: v.union(v.string(), v.null()),
95
+ adsetName: v.union(v.string(), v.null()),
96
+ campaignId: v.union(v.string(), v.null()),
97
+ campaignName: v.union(v.string(), v.null()),
98
+ /** `fb` or `ig`, as Meta says where the form was filled. */
99
+ platform: v.union(v.string(), v.null()),
100
+ isOrganic: v.boolean(),
101
+ });
102
+
103
+ /** A choice answer with the labels the form shows. */
104
+ export const formLabeledAnswerValidator = v.object({
105
+ questionKey: v.string(),
106
+ label: v.string(),
107
+ values: v.array(v.string()),
108
+ });
109
+
110
+ /**
111
+ * What the component hands the host's form lead handler (an internal
112
+ * mutation registered with `setFormLeadHandler`). `contact` and
113
+ * `freeAnswers` come from Meta in memory and are never stored by Rastro; the
114
+ * host owns the person from here. The handler must be idempotent on
115
+ * `leadgenId`.
116
+ */
117
+ export const formLeadPayloadValidator = v.object({
118
+ siteId: v.string(),
119
+ /** Rastro's lead; `leadKey` is what `setLeadStage` takes for it. */
120
+ leadId: v.string(),
121
+ leadKey: v.string(),
122
+ leadgenId: v.string(),
123
+ /** When the lead submitted the form (Meta's `created_time`). */
124
+ receivedAt: v.number(),
125
+ contact: v.object({
126
+ name: v.union(v.string(), v.null()),
127
+ /** As the lead typed it, usually with the country code (`+55…`). */
128
+ phone: v.union(v.string(), v.null()),
129
+ email: v.union(v.string(), v.null()),
130
+ }),
131
+ /** Answers typed freely (and any personal field besides the contact). */
132
+ freeAnswers: v.array(
133
+ v.object({ questionKey: v.string(), label: v.string(), value: v.string() }),
134
+ ),
135
+ /** The choice answers, with the labels the form shows. */
136
+ answers: v.array(formLabeledAnswerValidator),
137
+ decision: formLeadDecisionValidator,
138
+ /** The rule indexes evaluated, in order; the last one decided if any did. */
139
+ rulePath: v.array(v.number()),
140
+ /** The rule that decided, or null when none matched. */
141
+ ruleIndex: v.union(v.number(), v.null()),
142
+ origin: formLeadOriginValidator,
143
+ firstContact: firstContactValidator,
144
+ });
145
+
146
+ /**
147
+ * What stops or slows a linked Page, as a code a screen maps to its own text
148
+ * (decision D6). Only `META_TOKEN_INVALID` and `META_PERMISSION_MISSING` are
149
+ * fixed by reconnecting Meta (the second also covers a Page the login no
150
+ * longer lists). `HANDLER_MISSING`, `HANDLER_FAILED` and `HANDLER_GAVE_UP`
151
+ * are the host's (no form lead handler; it throws and the lead is read again;
152
+ * it threw too often and the lead was skipped for good); `SECRETS_KEY_MISSING`
153
+ * is the host's env.
154
+ * `META_TRUNCATED`, `META_RATE_LIMITED`, `META_UNAVAILABLE` and `RUN_TIMEOUT`
155
+ * pass by themselves: the next run goes on.
156
+ */
157
+ export const META_PAGE_ERROR_CODES = [
158
+ "META_TOKEN_INVALID",
159
+ "META_PERMISSION_MISSING",
160
+ "HANDLER_MISSING",
161
+ "HANDLER_FAILED",
162
+ "HANDLER_GAVE_UP",
163
+ "SECRETS_KEY_MISSING",
164
+ "META_TRUNCATED",
165
+ "META_RATE_LIMITED",
166
+ "META_UNAVAILABLE",
167
+ "RUN_TIMEOUT",
168
+ ] as const;
169
+ export type MetaPageErrorCode = (typeof META_PAGE_ERROR_CODES)[number];
170
+
171
+ export const metaPageErrorCodeValidator = v.union(
172
+ v.literal("META_TOKEN_INVALID"),
173
+ v.literal("META_PERMISSION_MISSING"),
174
+ v.literal("HANDLER_MISSING"),
175
+ v.literal("HANDLER_FAILED"),
176
+ v.literal("HANDLER_GAVE_UP"),
177
+ v.literal("SECRETS_KEY_MISSING"),
178
+ v.literal("META_TRUNCATED"),
179
+ v.literal("META_RATE_LIMITED"),
180
+ v.literal("META_UNAVAILABLE"),
181
+ v.literal("RUN_TIMEOUT"),
182
+ );
183
+
184
+ /** What `metaLinkedPages` answers the control plane (decision D1). */
185
+ export const linkedPagesValidator = v.object({
186
+ pages: v.array(v.object({ pageId: v.string(), siteId: v.string() })),
187
+ /**
188
+ * The component has `RASTRO_FEDERATION_AUDIENCE`: its leadgen route can
189
+ * verify a forward. False, every forward would answer `503`.
190
+ */
191
+ leadgenReady: v.boolean(),
192
+ /** The issuer the leadgen route verifies forwards against. */
193
+ issuer: v.string(),
194
+ /**
195
+ * The audience the leadgen route expects (`RASTRO_FEDERATION_AUDIENCE`,
196
+ * without a trailing slash), or null when it has none.
197
+ */
198
+ audience: v.union(v.string(), v.null()),
199
+ /**
200
+ * The host registered its form lead handler (the Lead Ads opt-in). False,
201
+ * the component reads no Pages: the list stays empty until it does, and
202
+ * reconnecting Meta changes nothing.
203
+ */
204
+ handlerReady: v.boolean(),
205
+ });
206
+
207
+ /** A Page an owner's Meta connection lists, as a screen shows it. */
208
+ export const metaPageValidator = v.object({
209
+ pageId: v.string(),
210
+ name: v.string(),
211
+ tasks: v.array(v.string()),
212
+ /** Meta granted `leads_retrieval` for the Page to this login. */
213
+ leadsAllowed: v.boolean(),
214
+ /** The site the Page feeds, whoever linked it; null when free. */
215
+ linkedSiteId: v.union(v.string(), v.null()),
216
+ /** Federated reads only: linked to a site outside the caller's grant. */
217
+ linkedOutsideScope: v.optional(v.boolean()),
218
+ /** Whether this owner linked it; null while the Page is free. */
219
+ own: v.union(v.boolean(), v.null()),
220
+ subscribedAt: v.union(v.number(), v.null()),
221
+ /**
222
+ * What stops or slows the Page, as a code (`META_PAGE_ERROR_CODES`): Meta
223
+ * refused its token or lead access, or, for a Page this owner linked, the
224
+ * host's handler or the last reconciliation. Null when nothing does.
225
+ */
226
+ lastError: v.union(metaPageErrorCodeValidator, v.null()),
227
+ /** Whether the host registered its form lead handler (no lead comes in without it). */
228
+ handlerReady: v.boolean(),
229
+ });
230
+
231
+ const formRulesSummaryValidator = v.object({
232
+ configured: v.boolean(),
233
+ count: v.number(),
234
+ entryColumn: v.union(v.string(), v.null()),
235
+ /** With an owner: whether that owner may edit them (not another's). */
236
+ own: v.optional(v.boolean()),
237
+ });
238
+
239
+ /** One instant form of a Page linked to the site, for the forms screen. */
240
+ export const metaFormRowValidator = v.object({
241
+ formId: v.string(),
242
+ name: v.string(),
243
+ /** Meta's status: `ACTIVE`, `ARCHIVED`, `DELETED`, `DRAFT`. */
244
+ status: v.string(),
245
+ questions: v.array(metaFormQuestionValidator),
246
+ /** The end screen's WhatsApp button; null when Meta does not say. */
247
+ whatsappButton: v.union(v.boolean(), v.null()),
248
+ /** The host marked the button as set by hand ("Já liguei"). */
249
+ whatsappButtonConfirmed: v.boolean(),
250
+ createdTime: v.union(v.number(), v.null()),
251
+ syncedAt: v.number(),
252
+ /** Leads in the 30 UTC days up to `now`; null without `now`. */
253
+ leads30d: v.union(v.number(), v.null()),
254
+ /** Those leads per UTC day, oldest first (30 entries); null without `now`. */
255
+ daily: v.union(v.array(v.number()), v.null()),
256
+ lastLeadAt: v.union(v.number(), v.null()),
257
+ rules: formRulesSummaryValidator,
258
+ });
259
+
260
+ /** The Pages linked to a site, each with its forms. */
261
+ export const metaFormsValidator = v.array(
262
+ v.object({
263
+ pageId: v.string(),
264
+ pageName: v.string(),
265
+ leadsAllowed: v.boolean(),
266
+ subscribedAt: v.union(v.number(), v.null()),
267
+ lastReconcileAt: v.union(v.number(), v.null()),
268
+ lastLeadAt: v.union(v.number(), v.null()),
269
+ /** As on `metaPages`: a code of `META_PAGE_ERROR_CODES`, or null. */
270
+ lastError: v.union(metaPageErrorCodeValidator, v.null()),
271
+ /** With an owner: whether that owner linked the Page. */
272
+ own: v.optional(v.boolean()),
273
+ /** Whether the host registered its form lead handler. */
274
+ handlerReady: v.boolean(),
275
+ forms: v.array(metaFormRowValidator),
276
+ }),
277
+ );
278
+
279
+ /** A form's rules as the drawer edits them, each with its monthly count. */
280
+ export const formRulesValidator = v.object({
281
+ siteId: v.string(),
282
+ formId: v.string(),
283
+ configured: v.boolean(),
284
+ entryColumn: v.union(v.string(), v.null()),
285
+ rules: v.array(
286
+ v.object({
287
+ ...formRuleValidator.fields,
288
+ /** Leads this rule decided in the 30 days up to `now`; null without. */
289
+ monthCount: v.union(v.number(), v.null()),
290
+ }),
291
+ ),
292
+ firstContact: firstContactValidator,
293
+ whatsappButtonConfirmed: v.boolean(),
294
+ updatedAt: v.union(v.number(), v.null()),
295
+ /** With an owner: whether that owner may edit them (not another's). */
296
+ own: v.optional(v.boolean()),
297
+ });
298
+
299
+ /** The last 30 days' leads of a form under a draft of its rules. */
300
+ export const formRulesSimulationValidator = v.object({
301
+ total: v.number(),
302
+ /**
303
+ * Leads no rule matched: they go to the entry. Unlike `formsReport`'s
304
+ * `entry`, this counts them whether or not the draft names an entry column.
305
+ */
306
+ entry: v.number(),
307
+ /** Per draft rule, in order: the leads it would decide. */
308
+ rules: v.array(v.number()),
309
+ /**
310
+ * Leads per column the draft sends them to. With an `entryColumn`, its row
311
+ * already holds the `entry` leads: add `entry` only when no row names it.
312
+ */
313
+ columns: v.array(v.object({ column: v.string(), leads: v.number() })),
314
+ discarded: v.array(v.object({ reason: v.string(), leads: v.number() })),
315
+ /** A read cap was hit: the figures cover the newest leads only. */
316
+ partial: v.boolean(),
317
+ });
318
+
319
+ /**
320
+ * Per form: its leads in the range and where the rules sent them. One row per
321
+ * (siteId, formId): a Page moved from one site to another inside the range
322
+ * has a row for each, so key rows by both. The range is whole UTC days:
323
+ * `from` floors to its day and `to` takes in the UTC day it falls on.
324
+ */
325
+ export const formsReportValidator = v.object({
326
+ forms: v.array(
327
+ v.object({
328
+ siteId: v.string(),
329
+ pageId: v.string(),
330
+ formId: v.string(),
331
+ name: v.union(v.string(), v.null()),
332
+ ...countersFields,
333
+ /** Leads a rule (or the entry column) sent to each column. */
334
+ columns: v.array(v.object({ column: v.string(), leads: v.number() })),
335
+ /** Leads that went to the host's own entry (no column named). */
336
+ entry: v.number(),
337
+ discarded: v.array(v.object({ reason: v.string(), leads: v.number() })),
338
+ /** Discarded leads someone sent back to the funnel. */
339
+ returned: v.number(),
340
+ }),
341
+ ),
342
+ /** A read cap was hit: the decision counts are a lower bound. */
343
+ partial: v.boolean(),
344
+ });
345
+
346
+ /** A form lead as the journey shows it: answers, origin and the path. */
347
+ export const formLeadDetailValidator = v.object({
348
+ leadgenId: v.string(),
349
+ receivedAt: v.number(),
350
+ origin: formLeadOriginValidator,
351
+ answers: v.array(formLabeledAnswerValidator),
352
+ decision: formLeadDecisionValidator,
353
+ rulePath: v.array(v.number()),
354
+ ruleIndex: v.union(v.number(), v.null()),
355
+ /** When a discarded lead was sent back, and the reason it had. */
356
+ returned: v.union(v.object({ at: v.number(), reason: v.string() }), v.null()),
357
+ });
358
+
359
+ export type FormRule = Infer<typeof formRuleValidator>;
360
+ export type FormRuleTarget = Infer<typeof formRuleTargetValidator>;
361
+ export type FirstContact = Infer<typeof firstContactValidator>;
362
+ export type FormLeadDecision = Infer<typeof formLeadDecisionValidator>;
363
+ export type FormLeadPayload = Infer<typeof formLeadPayloadValidator>;
364
+ export type MetaFormQuestion = Infer<typeof metaFormQuestionValidator>;
365
+ export type FormChoiceAnswer = Infer<typeof formChoiceAnswerValidator>;
@@ -0,0 +1,202 @@
1
+ import type {
2
+ FormChoiceAnswer,
3
+ FormLeadDecision,
4
+ FormRule,
5
+ MetaFormQuestion,
6
+ } from "./leadAdsValidators.js";
7
+
8
+ /*
9
+ * The pure half of Lead Ads: how a Meta lead's `field_data` splits into the
10
+ * contact (kept in memory only), free answers (in memory only) and choice
11
+ * answers (the only answers Rastro stores), and how a form's rules decide
12
+ * where the lead goes. No database, no clock: the ingestion, the drawer's
13
+ * simulation and the tests share it.
14
+ */
15
+
16
+ /** One `field_data` entry as Meta returns it: the question key, the values. */
17
+ export interface LeadField {
18
+ name: string;
19
+ values: string[];
20
+ }
21
+
22
+ export interface SplitLead {
23
+ contact: { name: string | null; phone: string | null; email: string | null };
24
+ freeAnswers: Array<{ questionKey: string; label: string; value: string }>;
25
+ choices: FormChoiceAnswer[];
26
+ }
27
+
28
+ const MAX_VALUE = 500;
29
+ const NAME_FIELDS = new Set(["full_name"]);
30
+ const FIRST_NAME_FIELDS = new Set(["first_name"]);
31
+ const LAST_NAME_FIELDS = new Set(["last_name"]);
32
+ const PHONE_FIELDS = new Set(["phone_number", "phone"]);
33
+ const EMAIL_FIELDS = new Set(["email"]);
34
+
35
+ const normalize = (value: string) =>
36
+ value.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " ");
37
+
38
+ const clip = (value: string) => value.trim().slice(0, MAX_VALUE);
39
+
40
+ /** The question's options, when it is a choice question. */
41
+ export function choiceOptions(
42
+ question: MetaFormQuestion | undefined,
43
+ ): Array<{ key: string; value: string }> | null {
44
+ return question?.options && question.options.length > 0
45
+ ? question.options
46
+ : null;
47
+ }
48
+
49
+ /**
50
+ * The option an answer names, by its key or its shown value (Meta returns
51
+ * either, depending on the form), or null when it names none.
52
+ */
53
+ export function optionKeyOf(
54
+ question: MetaFormQuestion | undefined,
55
+ answer: string,
56
+ ): string | null {
57
+ const options = choiceOptions(question);
58
+ if (!options) return null;
59
+ const wanted = normalize(answer);
60
+ const found =
61
+ options.find((option) => normalize(option.key) === wanted) ??
62
+ options.find((option) => normalize(option.value) === wanted);
63
+ return found?.key ?? null;
64
+ }
65
+
66
+ /**
67
+ * Splits a lead's answers. The identity fields become `contact`; an answer
68
+ * to a choice question that names its options becomes a choice; anything
69
+ * else, including any field the form does not describe, is a free answer.
70
+ * Only `choices` may ever be stored.
71
+ */
72
+ export function splitLeadFields(
73
+ questions: readonly MetaFormQuestion[],
74
+ fields: readonly LeadField[],
75
+ ): SplitLead {
76
+ const byKey = new Map(questions.map((question) => [question.key, question]));
77
+ let fullName: string | null = null;
78
+ let firstName: string | null = null;
79
+ let lastName: string | null = null;
80
+ let phone: string | null = null;
81
+ let email: string | null = null;
82
+ const freeAnswers: SplitLead["freeAnswers"] = [];
83
+ const choices: FormChoiceAnswer[] = [];
84
+ for (const field of fields) {
85
+ const values = field.values.map(clip).filter(Boolean);
86
+ if (values.length === 0) continue;
87
+ const key = field.name;
88
+ const question = byKey.get(key);
89
+ const type = question?.type.toUpperCase() ?? "";
90
+ if (NAME_FIELDS.has(key) || type === "FULL_NAME") {
91
+ fullName ??= values.join(" ");
92
+ continue;
93
+ }
94
+ if (FIRST_NAME_FIELDS.has(key) || type === "FIRST_NAME") {
95
+ firstName ??= values.join(" ");
96
+ continue;
97
+ }
98
+ if (LAST_NAME_FIELDS.has(key) || type === "LAST_NAME") {
99
+ lastName ??= values.join(" ");
100
+ continue;
101
+ }
102
+ if (PHONE_FIELDS.has(key) || type === "PHONE") {
103
+ phone ??= values[0]!;
104
+ continue;
105
+ }
106
+ if (EMAIL_FIELDS.has(key) || type === "EMAIL") {
107
+ email ??= values[0]!;
108
+ continue;
109
+ }
110
+ if (choiceOptions(question)) {
111
+ const keys = values.map((value) => optionKeyOf(question, value));
112
+ // Every value must name an option: a stray value may be something
113
+ // the lead typed, so the whole answer stays in memory.
114
+ if (keys.every((option): option is string => option !== null)) {
115
+ choices.push({ questionKey: key, answers: [...new Set(keys)] });
116
+ continue;
117
+ }
118
+ }
119
+ freeAnswers.push({
120
+ questionKey: key,
121
+ label: question?.label ?? key,
122
+ value: values.join(", "),
123
+ });
124
+ }
125
+ const name =
126
+ fullName ?? ([firstName, lastName].filter(Boolean).join(" ") || null);
127
+ return { contact: { name, phone, email }, freeAnswers, choices };
128
+ }
129
+
130
+ export interface RuleOutcome {
131
+ decision: FormLeadDecision;
132
+ rulePath: number[];
133
+ ruleIndex: number | null;
134
+ }
135
+
136
+ /**
137
+ * The first rule whose question the lead answered with one of its answers
138
+ * decides; with none, the lead goes to `entryColumn` (null: the host's own
139
+ * entry). `rulePath` lists every rule looked at, in order.
140
+ */
141
+ export function evaluateRules(
142
+ rules: readonly FormRule[],
143
+ entryColumn: string | null,
144
+ choices: readonly FormChoiceAnswer[],
145
+ ): RuleOutcome {
146
+ const answered = new Map(
147
+ choices.map((choice) => [choice.questionKey, new Set(choice.answers)]),
148
+ );
149
+ const rulePath: number[] = [];
150
+ for (const [index, rule] of rules.entries()) {
151
+ rulePath.push(index);
152
+ const picked = answered.get(rule.questionKey);
153
+ if (!picked || !rule.answers.some((answer) => picked.has(answer))) {
154
+ continue;
155
+ }
156
+ return {
157
+ decision:
158
+ "column" in rule.to
159
+ ? { column: rule.to.column }
160
+ : { discard: rule.to.discard },
161
+ rulePath,
162
+ ruleIndex: index,
163
+ };
164
+ }
165
+ return { decision: { column: entryColumn }, rulePath, ruleIndex: null };
166
+ }
167
+
168
+ /**
169
+ * A rule's identity for its monthly count: the question, the answers and the
170
+ * target. Reordering keeps a rule's count; changing it starts a new one, as
171
+ * an edited rule only decides the leads after it.
172
+ */
173
+ export function ruleSignature(rule: FormRule): string {
174
+ const target =
175
+ "column" in rule.to
176
+ ? `column:${rule.to.column}`
177
+ : `discard:${rule.to.discard}`;
178
+ return JSON.stringify([
179
+ rule.questionKey,
180
+ [...new Set(rule.answers)].sort(),
181
+ target,
182
+ ]);
183
+ }
184
+
185
+ /** The choice answers with the labels the form shows, for the host. */
186
+ export function labelChoices(
187
+ questions: readonly MetaFormQuestion[],
188
+ choices: readonly FormChoiceAnswer[],
189
+ ): Array<{ questionKey: string; label: string; values: string[] }> {
190
+ const byKey = new Map(questions.map((question) => [question.key, question]));
191
+ return choices.map((choice) => {
192
+ const question = byKey.get(choice.questionKey);
193
+ const options = choiceOptions(question) ?? [];
194
+ return {
195
+ questionKey: choice.questionKey,
196
+ label: question?.label ?? choice.questionKey,
197
+ values: choice.answers.map(
198
+ (key) => options.find((option) => option.key === key)?.value ?? key,
199
+ ),
200
+ };
201
+ });
202
+ }
@@ -0,0 +1,129 @@
1
+ import type { Infer } from "convex/values";
2
+ import type { Doc, Id } from "./_generated/dataModel.js";
3
+ import type { MutationCtx, QueryCtx } from "./_generated/server.js";
4
+ import type {
5
+ formLeadDetailValidator,
6
+ formLeadOriginValidator,
7
+ } from "./leadAdsValidators.js";
8
+ import { labelChoices } from "./leadFormRules.js";
9
+
10
+ /*
11
+ * Reads and clean-up of a form lead's record (`formLeads`), shared by the
12
+ * lead journey (leads.ts) and the Lead Ads module (metaLeads.ts).
13
+ */
14
+
15
+ /** Where a form lead came from, with the names Rastro knows. */
16
+ export async function formLeadOrigin(
17
+ ctx: QueryCtx,
18
+ row: Pick<
19
+ Doc<"formLeads">,
20
+ | "siteId"
21
+ | "pageId"
22
+ | "formId"
23
+ | "adId"
24
+ | "adsetId"
25
+ | "campaignId"
26
+ | "platform"
27
+ | "isOrganic"
28
+ >,
29
+ ): Promise<Infer<typeof formLeadOriginValidator>> {
30
+ const link = await ctx.db
31
+ .query("sitePages")
32
+ .withIndex("by_pageId", (range) => range.eq("pageId", row.pageId))
33
+ .first();
34
+ const page =
35
+ link ??
36
+ (await ctx.db
37
+ .query("metaPages")
38
+ .withIndex("by_pageId", (range) => range.eq("pageId", row.pageId))
39
+ .first());
40
+ const form = await ctx.db
41
+ .query("metaForms")
42
+ .withIndex("by_formId", (range) => range.eq("formId", row.formId))
43
+ .first();
44
+ const catalog = row.adId
45
+ ? await ctx.db
46
+ .query("adCatalog")
47
+ .withIndex("by_siteId_and_adId", (range) =>
48
+ range.eq("siteId", row.siteId).eq("adId", row.adId!),
49
+ )
50
+ .unique()
51
+ : null;
52
+ return {
53
+ pageId: row.pageId,
54
+ pageName: page?.name ?? null,
55
+ formId: row.formId,
56
+ formName: form?.name ?? null,
57
+ adId: row.adId ?? null,
58
+ adName: catalog?.adName ?? null,
59
+ adsetId: row.adsetId ?? catalog?.adsetId ?? null,
60
+ adsetName: catalog?.adsetName ?? null,
61
+ campaignId: row.campaignId ?? catalog?.campaignId ?? null,
62
+ campaignName: catalog?.campaignName ?? null,
63
+ platform: row.platform ?? null,
64
+ isOrganic: row.isOrganic,
65
+ };
66
+ }
67
+
68
+ /** A form lead's answers, origin and path, for the lead journey. */
69
+ export async function formLeadDetail(
70
+ ctx: QueryCtx,
71
+ lead: Pick<Doc<"leads">, "_id">,
72
+ ): Promise<Infer<typeof formLeadDetailValidator> | null> {
73
+ const row = await ctx.db
74
+ .query("formLeads")
75
+ .withIndex("by_leadId", (range) => range.eq("leadId", lead._id))
76
+ .first();
77
+ if (!row) return null;
78
+ const form = await ctx.db
79
+ .query("metaForms")
80
+ .withIndex("by_formId", (range) => range.eq("formId", row.formId))
81
+ .first();
82
+ return {
83
+ leadgenId: row.leadgenId,
84
+ receivedAt: row.receivedAt,
85
+ origin: await formLeadOrigin(ctx, row),
86
+ answers: labelChoices(form?.questions ?? [], row.answers),
87
+ decision: row.decision,
88
+ rulePath: row.rulePath,
89
+ ruleIndex: row.ruleIndex ?? null,
90
+ returned: row.returned ?? null,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Deletes a removed lead's ingestion record and leaves its leadgen id behind
96
+ * (nothing else), so the same Meta lead is never ingested again.
97
+ */
98
+ export async function forgetFormLead(
99
+ ctx: MutationCtx,
100
+ leadId: Id<"leads">,
101
+ ): Promise<void> {
102
+ const rows = await ctx.db
103
+ .query("formLeads")
104
+ .withIndex("by_leadId", (range) => range.eq("leadId", leadId))
105
+ .take(10);
106
+ const now = Date.now();
107
+ for (const row of rows) {
108
+ await ctx.db.delete("formLeads", row._id);
109
+ if (!(await isForgottenFormLead(ctx, row.leadgenId))) {
110
+ await ctx.db.insert("formLeadTombstones", {
111
+ leadgenId: row.leadgenId,
112
+ removedAt: now,
113
+ });
114
+ }
115
+ }
116
+ }
117
+
118
+ /** Whether the host removed the lead Meta knows by this leadgen id. */
119
+ export async function isForgottenFormLead(
120
+ ctx: QueryCtx,
121
+ leadgenId: string,
122
+ ): Promise<boolean> {
123
+ return (
124
+ (await ctx.db
125
+ .query("formLeadTombstones")
126
+ .withIndex("by_leadgenId", (range) => range.eq("leadgenId", leadgenId))
127
+ .first()) !== null
128
+ );
129
+ }