@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
|
@@ -0,0 +1,2634 @@
|
|
|
1
|
+
import { ConvexError, v } from "convex/values";
|
|
2
|
+
import { internal } from "./_generated/api.js";
|
|
3
|
+
import { action, env, internalAction, internalMutation, internalQuery, mutation, query, } from "./_generated/server.js";
|
|
4
|
+
import { DAY_MS, MAX_LEAD_REPORT_ROWS, MAX_REPORT_RANGE_DAYS, MAX_REPORT_SITES, } from "./constants.js";
|
|
5
|
+
import { fail } from "./errors.js";
|
|
6
|
+
import { isPlainRecord } from "./guards.js";
|
|
7
|
+
import { firstContactValidator, formChoiceAnswerValidator, formRuleValidator, formRulesSimulationValidator, formRulesValidator, formsReportValidator, metaFormQuestionValidator, metaFormsValidator, linkedPagesValidator, META_PAGE_ERROR_CODES, metaPageValidator, } from "./leadAdsValidators.js";
|
|
8
|
+
import { choiceOptions, evaluateRules, labelChoices, optionKeyOf, ruleSignature, splitLeadFields, } from "./leadFormRules.js";
|
|
9
|
+
import { formLeadOrigin, isForgottenFormLead } from "./leadFormStore.js";
|
|
10
|
+
import { LEAD_FORM_ORIGIN, recordLeadCore } from "./leads.js";
|
|
11
|
+
import { ownerKeyOf } from "./meta.js";
|
|
12
|
+
import { metaGraph, MetaGraphError, } from "./metaGraph.js";
|
|
13
|
+
import { sha256Hex } from "./metaCapi.js";
|
|
14
|
+
import { leadgenTrust } from "./leadgenToken.js";
|
|
15
|
+
import { PAUSED_AT, retryDelay, USAGE_BACKOFF_PCT } from "./metaSync.js";
|
|
16
|
+
import { cleanString } from "./sanitize.js";
|
|
17
|
+
import { decryptSecret, encryptSecret, hasSecretsKey } from "./secrets.js";
|
|
18
|
+
/** Leads, forms and Pages one call or one read handles at most. */
|
|
19
|
+
export const MAX_PAGES_PER_CONNECTION = 200;
|
|
20
|
+
export const MAX_SITE_PAGES = 20;
|
|
21
|
+
const MAX_LINKS_PER_CONNECTION = 500;
|
|
22
|
+
const MAX_FORMS_PER_PAGE = 500;
|
|
23
|
+
/** Forms the forms screen lists for a site, every Page together. */
|
|
24
|
+
export const MAX_SITE_FORMS = 500;
|
|
25
|
+
const MAX_RULES = 20;
|
|
26
|
+
const MAX_RULE_ANSWERS = 30;
|
|
27
|
+
const MAX_REASON = 80;
|
|
28
|
+
const MAX_FIRST_CONTACT_TEXT = 600;
|
|
29
|
+
const MAX_QUESTIONS = 60;
|
|
30
|
+
const MAX_MONTH_LEADS = 5_000;
|
|
31
|
+
const MAX_BACKFILL_DAYS = 30;
|
|
32
|
+
const MONTH_DAYS = 30;
|
|
33
|
+
const RECONCILE_INTERVAL_MS = 15 * 60_000;
|
|
34
|
+
const RECONCILE_LEASE_MS = 10 * 60_000;
|
|
35
|
+
const THROTTLE_BACKOFF_MS = 60 * 60_000;
|
|
36
|
+
const PERMISSION_RETRY_MS = 6 * 60 * 60_000;
|
|
37
|
+
/**
|
|
38
|
+
* How often the reconciliation makes sure the app is still subscribed to a
|
|
39
|
+
* linked Page: the subscription is the Page's and the app's, shared by every
|
|
40
|
+
* host, so another host unlinking the Page (releases before this one did
|
|
41
|
+
* unsubscribe) or a Page admin may have taken it off.
|
|
42
|
+
*/
|
|
43
|
+
export const SUBSCRIPTION_CHECK_MS = 6 * 60 * 60_000;
|
|
44
|
+
const LOOP_BATCH = 4;
|
|
45
|
+
/**
|
|
46
|
+
* A lead whose commit failed this many times, while later leads of its form
|
|
47
|
+
* came in, is skipped by the reconciliation (kept in `formLeadFailures`).
|
|
48
|
+
*/
|
|
49
|
+
export const MAX_LEAD_ATTEMPTS = 5;
|
|
50
|
+
const MAX_SKIPPED_PER_RUN = 50;
|
|
51
|
+
/** Graph listings one run makes per form, halving windows included. */
|
|
52
|
+
const MAX_FORM_FETCHES = 12;
|
|
53
|
+
const LOOP_STAGGER_MS = 20_000;
|
|
54
|
+
const MAX_ERROR = 300;
|
|
55
|
+
const GRAPH_ID = /^\d{1,32}$/u;
|
|
56
|
+
const HANDLE = /^function:\/\/./u;
|
|
57
|
+
/** The tasks that let a Page's token read its leads (with the permission). */
|
|
58
|
+
const LEAD_TASKS = new Set(["ADVERTISE", "MANAGE"]);
|
|
59
|
+
const PAGE_NOT_GRANTED = "PAGE_NOT_GRANTED: the Meta login no longer lists this Page; grant it in Meta and connect again";
|
|
60
|
+
const LEADS_NOT_ALLOWED = "META_PERMISSION_MISSING: Meta did not grant leads_retrieval for this Page; reconnect Meta and pick the Page";
|
|
61
|
+
const HANDLER_MISSING = "HANDLER_MISSING: the host has not registered its form lead handler (setFormLeadHandler)";
|
|
62
|
+
const PAGE_ERROR_CODES = new Set(META_PAGE_ERROR_CODES);
|
|
63
|
+
/** Codes stored before they were settled, read as today's. */
|
|
64
|
+
const LEGACY_PAGE_ERROR_CODES = {
|
|
65
|
+
PAGE_NOT_GRANTED: "META_PERMISSION_MISSING",
|
|
66
|
+
RATE_LIMITED: "META_RATE_LIMITED",
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* A link's stored `lastError` (`CODE: message`) as the code a screen reads;
|
|
70
|
+
* the message stays in the component. Anything unknown reads as Meta being
|
|
71
|
+
* unavailable, which passes by itself.
|
|
72
|
+
*/
|
|
73
|
+
export function pageErrorCode(lastError) {
|
|
74
|
+
if (!lastError)
|
|
75
|
+
return null;
|
|
76
|
+
const prefix = /^([A-Z][A-Z_]*)(?::|$)/u.exec(lastError)?.[1] ?? "";
|
|
77
|
+
if (PAGE_ERROR_CODES.has(prefix))
|
|
78
|
+
return prefix;
|
|
79
|
+
return LEGACY_PAGE_ERROR_CODES[prefix] ?? "META_UNAVAILABLE";
|
|
80
|
+
}
|
|
81
|
+
const graph = () => metaGraph({ fake: env.RASTRO_META_FAKE === "true" });
|
|
82
|
+
function optionalLoginApp() {
|
|
83
|
+
const appId = env.RASTRO_META_APP_ID?.trim() ?? "";
|
|
84
|
+
const appSecret = env.RASTRO_META_APP_SECRET?.trim() ?? "";
|
|
85
|
+
return /^\d{5,20}$/.test(appId) && appSecret ? { appId, appSecret } : null;
|
|
86
|
+
}
|
|
87
|
+
function graphIdOf(value, field) {
|
|
88
|
+
const id = value.trim();
|
|
89
|
+
if (!GRAPH_ID.test(id))
|
|
90
|
+
fail("INVALID_ARGUMENT", `${field} must be Meta's id`);
|
|
91
|
+
return id;
|
|
92
|
+
}
|
|
93
|
+
const dayOf = (at) => Math.floor(at / DAY_MS) * DAY_MS;
|
|
94
|
+
function nowOf(value) {
|
|
95
|
+
if (value === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
98
|
+
fail("INVALID_ARGUMENT", "now must be a Unix time in milliseconds");
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
async function requireSite(ctx, siteId) {
|
|
103
|
+
if (!(await ctx.db.get("sites", siteId)))
|
|
104
|
+
fail("NOT_FOUND", "site not found");
|
|
105
|
+
}
|
|
106
|
+
function requireKey() {
|
|
107
|
+
if (!hasSecretsKey(env.RASTRO_SECRETS_KEY)) {
|
|
108
|
+
fail("SECRETS_KEY_MISSING", "set RASTRO_SECRETS_KEY (32 random bytes, base64) and pass it to the component");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A Page linked here is routed here: refused while the leadgen route would
|
|
113
|
+
* answer every forward `503`, so no lead waits 24 hours on a host that cannot
|
|
114
|
+
* take it (decision D1).
|
|
115
|
+
*/
|
|
116
|
+
function requireLeadgenRoute() {
|
|
117
|
+
if (leadgenTrust(env).audience === null) {
|
|
118
|
+
fail("FEDERATION_NOT_CONFIGURED", "set RASTRO_FEDERATION_AUDIENCE (this host's functions URL) and pass it to the component before linking a Page");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Meta's failure on a Page call as the component's own error. */
|
|
122
|
+
function pageFailure(error) {
|
|
123
|
+
if (error instanceof MetaGraphError) {
|
|
124
|
+
if (error.kind === "token") {
|
|
125
|
+
fail("META_TOKEN_INVALID", `Meta refused the Page token: ${error.message}`);
|
|
126
|
+
}
|
|
127
|
+
if (error.kind === "permission") {
|
|
128
|
+
fail("META_PERMISSION_MISSING", `Meta refused: ${error.message}`);
|
|
129
|
+
}
|
|
130
|
+
fail("META_UNAVAILABLE", `Meta answered: ${error.message}`, {
|
|
131
|
+
kind: error.kind,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Pages, as the Meta login lists them.
|
|
138
|
+
const sealedPageValidator = v.object({
|
|
139
|
+
pageId: v.string(),
|
|
140
|
+
name: v.string(),
|
|
141
|
+
tasks: v.array(v.string()),
|
|
142
|
+
leadsAllowed: v.boolean(),
|
|
143
|
+
tokenCipher: v.string(),
|
|
144
|
+
tokenIv: v.string(),
|
|
145
|
+
});
|
|
146
|
+
/**
|
|
147
|
+
* The Pages a token acts for, each with its token sealed, for `savePages`.
|
|
148
|
+
* Never fails a login: a token without the Page permissions (a login with the
|
|
149
|
+
* old configuration) lists none, and anything Meta refuses here leaves the
|
|
150
|
+
* Pages already known (`complete: false`).
|
|
151
|
+
*/
|
|
152
|
+
export async function readPages(client, token) {
|
|
153
|
+
let listed;
|
|
154
|
+
try {
|
|
155
|
+
listed = await client.pages(token);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error instanceof MetaGraphError) {
|
|
159
|
+
console.warn(`[rastro] Meta refused the Pages read: ${error.message}`);
|
|
160
|
+
return { pages: [], complete: false };
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
let access = null;
|
|
165
|
+
try {
|
|
166
|
+
access = await client.leadAccess(token, optionalLoginApp());
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (!(error instanceof MetaGraphError))
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
const pages = [];
|
|
173
|
+
for (const page of listed.pages.slice(0, MAX_PAGES_PER_CONNECTION)) {
|
|
174
|
+
if (!page.accessToken || !GRAPH_ID.test(page.pageId))
|
|
175
|
+
continue;
|
|
176
|
+
const tasks = page.tasks.slice(0, 20).map((task) => task.slice(0, 40));
|
|
177
|
+
// The permission (from the login's scopes when Meta says) and a task
|
|
178
|
+
// that reaches the Page's leads.
|
|
179
|
+
const granted = access === null
|
|
180
|
+
? true
|
|
181
|
+
: access.all || access.pageIds.includes(page.pageId);
|
|
182
|
+
const leadsAllowed = granted &&
|
|
183
|
+
(tasks.length === 0 || tasks.some((task) => LEAD_TASKS.has(task)));
|
|
184
|
+
const sealed = await encryptSecret(env.RASTRO_SECRETS_KEY, page.accessToken);
|
|
185
|
+
pages.push({
|
|
186
|
+
pageId: page.pageId,
|
|
187
|
+
name: page.name.slice(0, 200),
|
|
188
|
+
tasks,
|
|
189
|
+
leadsAllowed,
|
|
190
|
+
tokenCipher: sealed.cipher,
|
|
191
|
+
tokenIv: sealed.iv,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return { pages, complete: listed.complete };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Stores the Pages a connection's token listed, with their sealed tokens. A
|
|
198
|
+
* linked Page the listing leaves out (with `complete`) stops, saying why; a
|
|
199
|
+
* linked Page listed again (or with a fresh token) resumes. Ignored when the
|
|
200
|
+
* connection has another token by now (`tokenIv`).
|
|
201
|
+
*/
|
|
202
|
+
export const savePages = internalMutation({
|
|
203
|
+
args: {
|
|
204
|
+
connectionId: v.id("metaConnections"),
|
|
205
|
+
connectionTokenIv: v.string(),
|
|
206
|
+
pages: v.array(sealedPageValidator),
|
|
207
|
+
complete: v.boolean(),
|
|
208
|
+
},
|
|
209
|
+
returns: v.null(),
|
|
210
|
+
handler: async (ctx, args) => {
|
|
211
|
+
const connection = await ctx.db.get("metaConnections", args.connectionId);
|
|
212
|
+
if (connection?.tokenIv !== args.connectionTokenIv)
|
|
213
|
+
return null;
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
const rows = await ctx.db
|
|
216
|
+
.query("metaPages")
|
|
217
|
+
.withIndex("by_connectionId_and_pageId", (range) => range.eq("connectionId", args.connectionId))
|
|
218
|
+
.take(MAX_PAGES_PER_CONNECTION * 2);
|
|
219
|
+
const byPage = new Map(rows.map((row) => [row.pageId, row]));
|
|
220
|
+
const listed = new Set();
|
|
221
|
+
for (const page of args.pages.slice(0, MAX_PAGES_PER_CONNECTION)) {
|
|
222
|
+
listed.add(page.pageId);
|
|
223
|
+
const fields = {
|
|
224
|
+
ownerKey: connection.ownerKey,
|
|
225
|
+
name: page.name,
|
|
226
|
+
tasks: page.tasks,
|
|
227
|
+
leadsAllowed: page.leadsAllowed,
|
|
228
|
+
tokenCipher: page.tokenCipher,
|
|
229
|
+
tokenIv: page.tokenIv,
|
|
230
|
+
listed: true,
|
|
231
|
+
invalidSince: undefined,
|
|
232
|
+
updatedAt: now,
|
|
233
|
+
};
|
|
234
|
+
const row = byPage.get(page.pageId);
|
|
235
|
+
if (row)
|
|
236
|
+
await ctx.db.patch("metaPages", row._id, fields);
|
|
237
|
+
else {
|
|
238
|
+
await ctx.db.insert("metaPages", {
|
|
239
|
+
connectionId: args.connectionId,
|
|
240
|
+
pageId: page.pageId,
|
|
241
|
+
...fields,
|
|
242
|
+
createdAt: now,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const links = await ctx.db
|
|
247
|
+
.query("sitePages")
|
|
248
|
+
.withIndex("by_connectionId", (range) => range.eq("connectionId", args.connectionId))
|
|
249
|
+
.take(MAX_LINKS_PER_CONNECTION);
|
|
250
|
+
const linked = new Set(links.map((link) => link.pageId));
|
|
251
|
+
if (args.complete) {
|
|
252
|
+
for (const row of rows) {
|
|
253
|
+
if (listed.has(row.pageId))
|
|
254
|
+
continue;
|
|
255
|
+
if (linked.has(row.pageId)) {
|
|
256
|
+
await ctx.db.patch("metaPages", row._id, {
|
|
257
|
+
listed: false,
|
|
258
|
+
updatedAt: now,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
await ctx.db.delete("metaPages", row._id);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
let resumed = false;
|
|
267
|
+
for (const link of links) {
|
|
268
|
+
const page = args.pages.find((row) => row.pageId === link.pageId);
|
|
269
|
+
if (!page) {
|
|
270
|
+
if (args.complete && link.lastError !== PAGE_NOT_GRANTED) {
|
|
271
|
+
await pauseLink(ctx, link, PAGE_NOT_GRANTED, now);
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (!page.leadsAllowed) {
|
|
276
|
+
if (link.lastError !== LEADS_NOT_ALLOWED) {
|
|
277
|
+
await pauseLink(ctx, link, LEADS_NOT_ALLOWED, now);
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
// A fresh token resumes a Page a dead token (or a missing grant) had
|
|
282
|
+
// stopped.
|
|
283
|
+
if (link.nextReconcileAt === PAUSED_AT) {
|
|
284
|
+
await ctx.db.patch("sitePages", link._id, {
|
|
285
|
+
lastError: undefined,
|
|
286
|
+
failures: 0,
|
|
287
|
+
nextReconcileAt: now,
|
|
288
|
+
updatedAt: now,
|
|
289
|
+
});
|
|
290
|
+
resumed = true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (resumed)
|
|
294
|
+
await kickLeadsLoop(ctx, now);
|
|
295
|
+
return null;
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
export const handlerRegistered = internalQuery({
|
|
299
|
+
args: {},
|
|
300
|
+
returns: v.boolean(),
|
|
301
|
+
handler: async (ctx) => await handlerReady(ctx),
|
|
302
|
+
});
|
|
303
|
+
/**
|
|
304
|
+
* Reads and stores the Pages a connection's token acts for, on a host that
|
|
305
|
+
* opted into Lead Ads (its form lead handler is registered). A host that did
|
|
306
|
+
* not makes no Pages read and keeps no Page token.
|
|
307
|
+
*/
|
|
308
|
+
export async function syncOwnerPages(ctx, client, connection) {
|
|
309
|
+
if (!(await ctx.runQuery(internal.metaLeads.handlerRegistered, {})))
|
|
310
|
+
return;
|
|
311
|
+
const pages = await readPages(client, connection.token);
|
|
312
|
+
await ctx.runMutation(internal.metaLeads.savePages, {
|
|
313
|
+
connectionId: connection.connectionId,
|
|
314
|
+
connectionTokenIv: connection.tokenIv,
|
|
315
|
+
pages: pages.pages,
|
|
316
|
+
complete: pages.complete,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
const OWNERS_BATCH = 20;
|
|
320
|
+
const ownerToReadValidator = v.object({
|
|
321
|
+
connectionId: v.id("metaConnections"),
|
|
322
|
+
creationTime: v.number(),
|
|
323
|
+
tokenCipher: v.string(),
|
|
324
|
+
tokenIv: v.string(),
|
|
325
|
+
active: v.boolean(),
|
|
326
|
+
});
|
|
327
|
+
export const ownersToRead = internalQuery({
|
|
328
|
+
args: { after: v.union(v.number(), v.null()) },
|
|
329
|
+
returns: v.array(ownerToReadValidator),
|
|
330
|
+
handler: async (ctx, args) => {
|
|
331
|
+
const after = args.after;
|
|
332
|
+
const rows = await ctx.db
|
|
333
|
+
.query("metaConnections")
|
|
334
|
+
.withIndex("by_creation_time", (range) => after === null ? range : range.gt("_creationTime", after))
|
|
335
|
+
.take(OWNERS_BATCH);
|
|
336
|
+
return rows.map((row) => ({
|
|
337
|
+
connectionId: row._id,
|
|
338
|
+
creationTime: row._creationTime,
|
|
339
|
+
tokenCipher: row.tokenCipher,
|
|
340
|
+
tokenIv: row.tokenIv,
|
|
341
|
+
active: row.status === "active",
|
|
342
|
+
}));
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
/**
|
|
346
|
+
* The host registered its form lead handler for the first time: the Pages of
|
|
347
|
+
* the owners already connected are read now, a batch of owners at a time,
|
|
348
|
+
* instead of at each owner's next login.
|
|
349
|
+
*/
|
|
350
|
+
export const readOwnersPages = internalAction({
|
|
351
|
+
args: { after: v.union(v.number(), v.null()) },
|
|
352
|
+
returns: v.null(),
|
|
353
|
+
handler: async (ctx, args) => {
|
|
354
|
+
if (!hasSecretsKey(env.RASTRO_SECRETS_KEY))
|
|
355
|
+
return null;
|
|
356
|
+
const owners = await ctx.runQuery(internal.metaLeads.ownersToRead, args);
|
|
357
|
+
const client = graph();
|
|
358
|
+
for (const owner of owners) {
|
|
359
|
+
if (!owner.active)
|
|
360
|
+
continue;
|
|
361
|
+
let token;
|
|
362
|
+
try {
|
|
363
|
+
token = await decryptSecret(env.RASTRO_SECRETS_KEY, {
|
|
364
|
+
cipher: owner.tokenCipher,
|
|
365
|
+
iv: owner.tokenIv,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
await syncOwnerPages(ctx, client, {
|
|
372
|
+
connectionId: owner.connectionId,
|
|
373
|
+
token,
|
|
374
|
+
tokenIv: owner.tokenIv,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
if (owners.length === OWNERS_BATCH) {
|
|
378
|
+
await ctx.scheduler.runAfter(0, internal.metaLeads.readOwnersPages, {
|
|
379
|
+
after: owners[owners.length - 1].creationTime,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
async function pauseLink(ctx, link, message, now) {
|
|
386
|
+
await ctx.db.patch("sitePages", link._id, {
|
|
387
|
+
runId: undefined,
|
|
388
|
+
lastError: message.slice(0, MAX_ERROR),
|
|
389
|
+
nextReconcileAt: PAUSED_AT,
|
|
390
|
+
updatedAt: now,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Meta refused the Page token sealed with `tokenIv`: the Page stops until a
|
|
395
|
+
* login lists it with a new one. A newer token (another IV) is left alone.
|
|
396
|
+
*/
|
|
397
|
+
export const markPageInvalid = internalMutation({
|
|
398
|
+
args: {
|
|
399
|
+
pageRowId: v.id("metaPages"),
|
|
400
|
+
tokenIv: v.string(),
|
|
401
|
+
message: v.string(),
|
|
402
|
+
},
|
|
403
|
+
returns: v.null(),
|
|
404
|
+
handler: async (ctx, args) => {
|
|
405
|
+
const row = await ctx.db.get("metaPages", args.pageRowId);
|
|
406
|
+
if (row?.tokenIv !== args.tokenIv)
|
|
407
|
+
return null;
|
|
408
|
+
const now = Date.now();
|
|
409
|
+
await ctx.db.patch("metaPages", row._id, {
|
|
410
|
+
invalidSince: row.invalidSince ?? now,
|
|
411
|
+
updatedAt: now,
|
|
412
|
+
});
|
|
413
|
+
const links = await ctx.db
|
|
414
|
+
.query("sitePages")
|
|
415
|
+
.withIndex("by_pageId", (range) => range.eq("pageId", row.pageId))
|
|
416
|
+
.take(2);
|
|
417
|
+
for (const link of links) {
|
|
418
|
+
if (link.connectionId !== row.connectionId)
|
|
419
|
+
continue;
|
|
420
|
+
await pauseLink(ctx, link, `META_TOKEN_INVALID: ${args.message}`, now);
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
/** Forgets a removed connection's Pages and links, a batch at a time. */
|
|
426
|
+
export async function removeConnectionPages(ctx, connectionId, batch) {
|
|
427
|
+
const links = await ctx.db
|
|
428
|
+
.query("sitePages")
|
|
429
|
+
.withIndex("by_connectionId", (range) => range.eq("connectionId", connectionId))
|
|
430
|
+
.take(batch);
|
|
431
|
+
for (const link of links)
|
|
432
|
+
await ctx.db.delete("sitePages", link._id);
|
|
433
|
+
const pages = await ctx.db
|
|
434
|
+
.query("metaPages")
|
|
435
|
+
.withIndex("by_connectionId_and_pageId", (range) => range.eq("connectionId", connectionId))
|
|
436
|
+
.take(batch);
|
|
437
|
+
for (const page of pages)
|
|
438
|
+
await ctx.db.delete("metaPages", page._id);
|
|
439
|
+
return links.length === batch || pages.length === batch;
|
|
440
|
+
}
|
|
441
|
+
async function connectionByOwner(ctx, ownerKey) {
|
|
442
|
+
return await ctx.db
|
|
443
|
+
.query("metaConnections")
|
|
444
|
+
.withIndex("by_ownerKey", (range) => range.eq("ownerKey", ownerKey))
|
|
445
|
+
.unique();
|
|
446
|
+
}
|
|
447
|
+
async function linkOfPage(ctx, pageId) {
|
|
448
|
+
return await ctx.db
|
|
449
|
+
.query("sitePages")
|
|
450
|
+
.withIndex("by_pageId", (range) => range.eq("pageId", pageId))
|
|
451
|
+
.first();
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* The owner's Pages as a screen shows them: whether Meta granted their
|
|
455
|
+
* leads, the site each feeds (whoever linked it, since a Page feeds one
|
|
456
|
+
* site) and whether this owner linked it. Never a token.
|
|
457
|
+
*/
|
|
458
|
+
export const pages = query({
|
|
459
|
+
args: { ownerKey: v.string() },
|
|
460
|
+
returns: v.array(metaPageValidator),
|
|
461
|
+
handler: async (ctx, args) => {
|
|
462
|
+
const ownerKey = ownerKeyOf(args.ownerKey);
|
|
463
|
+
const connection = await connectionByOwner(ctx, ownerKey);
|
|
464
|
+
if (!connection)
|
|
465
|
+
return [];
|
|
466
|
+
const rows = await ctx.db
|
|
467
|
+
.query("metaPages")
|
|
468
|
+
.withIndex("by_connectionId_and_pageId", (range) => range.eq("connectionId", connection._id))
|
|
469
|
+
.take(MAX_PAGES_PER_CONNECTION * 2);
|
|
470
|
+
const ready = await handlerReady(ctx);
|
|
471
|
+
const out = [];
|
|
472
|
+
for (const row of rows) {
|
|
473
|
+
const link = await linkOfPage(ctx, row.pageId);
|
|
474
|
+
const own = link ? link.ownerKey === ownerKey : null;
|
|
475
|
+
const lastError = !row.listed
|
|
476
|
+
? "META_PERMISSION_MISSING"
|
|
477
|
+
: row.invalidSince !== undefined
|
|
478
|
+
? "META_TOKEN_INVALID"
|
|
479
|
+
: own
|
|
480
|
+
? (pageErrorCode(link?.lastError) ??
|
|
481
|
+
(ready ? null : "HANDLER_MISSING"))
|
|
482
|
+
: null;
|
|
483
|
+
out.push({
|
|
484
|
+
pageId: row.pageId,
|
|
485
|
+
name: row.name,
|
|
486
|
+
tasks: row.tasks,
|
|
487
|
+
leadsAllowed: row.leadsAllowed,
|
|
488
|
+
linkedSiteId: link?.siteId ?? null,
|
|
489
|
+
own,
|
|
490
|
+
subscribedAt: own ? (link?.subscribedAt ?? null) : null,
|
|
491
|
+
lastError,
|
|
492
|
+
handlerReady: ready,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
return out.sort((left, right) => left.name.localeCompare(right.name));
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
/**
|
|
499
|
+
* The Pages linked to the sites, for the control plane's webhook routes, and
|
|
500
|
+
* whether this host's leadgen route takes forwards at all: `leadgenReady` (the
|
|
501
|
+
* component has `RASTRO_FEDERATION_AUDIENCE`), the `issuer` it checks them
|
|
502
|
+
* against and the `audience` it expects. The control plane routes a Page here
|
|
503
|
+
* only when it is ready, the issuer is its own and the audience is the one it
|
|
504
|
+
* signs forwards for (decision D1). `handlerReady` says whether the host
|
|
505
|
+
* registered its form lead handler: without it no Page is read, so a screen
|
|
506
|
+
* with no Pages names the host's setup instead of the Meta login.
|
|
507
|
+
*/
|
|
508
|
+
export const linkedPages = query({
|
|
509
|
+
args: { siteIds: v.array(v.id("sites")) },
|
|
510
|
+
returns: linkedPagesValidator,
|
|
511
|
+
handler: async (ctx, args) => {
|
|
512
|
+
const siteIds = [...new Set(args.siteIds)];
|
|
513
|
+
if (siteIds.length < 1 ||
|
|
514
|
+
siteIds.length > MAX_REPORT_SITES ||
|
|
515
|
+
siteIds.length !== args.siteIds.length) {
|
|
516
|
+
fail("INVALID_ARGUMENT", `siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`);
|
|
517
|
+
}
|
|
518
|
+
const out = [];
|
|
519
|
+
for (const siteId of siteIds) {
|
|
520
|
+
const links = await ctx.db
|
|
521
|
+
.query("sitePages")
|
|
522
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
523
|
+
.take(MAX_SITE_PAGES);
|
|
524
|
+
for (const link of links)
|
|
525
|
+
out.push({ pageId: link.pageId, siteId });
|
|
526
|
+
}
|
|
527
|
+
const { issuer, audience } = leadgenTrust(env);
|
|
528
|
+
return {
|
|
529
|
+
pages: out,
|
|
530
|
+
leadgenReady: audience !== null,
|
|
531
|
+
issuer,
|
|
532
|
+
audience,
|
|
533
|
+
handlerReady: await handlerReady(ctx),
|
|
534
|
+
};
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// Link a Page to a site.
|
|
539
|
+
const preparedLinkValidator = v.object({
|
|
540
|
+
pageRowId: v.id("metaPages"),
|
|
541
|
+
tokenCipher: v.string(),
|
|
542
|
+
tokenIv: v.string(),
|
|
543
|
+
});
|
|
544
|
+
export const prepareLink = internalQuery({
|
|
545
|
+
args: {
|
|
546
|
+
siteId: v.id("sites"),
|
|
547
|
+
pageId: v.string(),
|
|
548
|
+
ownerKey: v.string(),
|
|
549
|
+
onlyOwn: v.boolean(),
|
|
550
|
+
},
|
|
551
|
+
returns: preparedLinkValidator,
|
|
552
|
+
handler: async (ctx, args) => {
|
|
553
|
+
await requireSite(ctx, args.siteId);
|
|
554
|
+
const connection = await connectionByOwner(ctx, args.ownerKey);
|
|
555
|
+
if (!connection)
|
|
556
|
+
fail("NOT_FOUND", "no Meta connection for this owner");
|
|
557
|
+
if (connection.status !== "active") {
|
|
558
|
+
fail("META_TOKEN_INVALID", "the owner's Meta token is invalid; connect again");
|
|
559
|
+
}
|
|
560
|
+
const page = await ctx.db
|
|
561
|
+
.query("metaPages")
|
|
562
|
+
.withIndex("by_connectionId_and_pageId", (range) => range.eq("connectionId", connection._id).eq("pageId", args.pageId))
|
|
563
|
+
.unique();
|
|
564
|
+
if (!page?.listed) {
|
|
565
|
+
fail("NOT_FOUND", "the owner's Meta login does not list this Page");
|
|
566
|
+
}
|
|
567
|
+
if (!page.leadsAllowed)
|
|
568
|
+
fail("META_PERMISSION_MISSING", LEADS_NOT_ALLOWED);
|
|
569
|
+
await checkLinkable(ctx, args);
|
|
570
|
+
return {
|
|
571
|
+
pageRowId: page._id,
|
|
572
|
+
tokenCipher: page.tokenCipher,
|
|
573
|
+
tokenIv: page.tokenIv,
|
|
574
|
+
};
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
/** The conflicts a link refuses; checked before Meta and again on saving. */
|
|
578
|
+
async function checkLinkable(ctx, args) {
|
|
579
|
+
const link = await linkOfPage(ctx, args.pageId);
|
|
580
|
+
if (link && link.siteId !== args.siteId) {
|
|
581
|
+
fail("CONFLICT", "this Page already feeds another site; unlink it there");
|
|
582
|
+
}
|
|
583
|
+
if (link && link.ownerKey !== args.ownerKey && args.onlyOwn) {
|
|
584
|
+
fail("CONFLICT", "this Page is linked to the site by another owner on this host; change it there");
|
|
585
|
+
}
|
|
586
|
+
if (!link) {
|
|
587
|
+
const siblings = await ctx.db
|
|
588
|
+
.query("sitePages")
|
|
589
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
590
|
+
.take(MAX_SITE_PAGES);
|
|
591
|
+
if (siblings.length >= MAX_SITE_PAGES) {
|
|
592
|
+
fail("LIMIT_EXCEEDED", `a site links at most ${MAX_SITE_PAGES} Pages`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return link;
|
|
596
|
+
}
|
|
597
|
+
const graphFormValidator = v.object({
|
|
598
|
+
formId: v.string(),
|
|
599
|
+
name: v.string(),
|
|
600
|
+
status: v.string(),
|
|
601
|
+
questions: v.array(metaFormQuestionValidator),
|
|
602
|
+
whatsappButton: v.union(v.boolean(), v.null()),
|
|
603
|
+
createdTime: v.union(v.number(), v.null()),
|
|
604
|
+
});
|
|
605
|
+
const toFormArgs = (forms) => forms.slice(0, MAX_FORMS_PER_PAGE).map((form) => ({
|
|
606
|
+
formId: form.formId,
|
|
607
|
+
name: form.name.slice(0, 200),
|
|
608
|
+
status: form.status.slice(0, 32),
|
|
609
|
+
questions: form.questions.slice(0, MAX_QUESTIONS).map((question) => ({
|
|
610
|
+
key: question.key.slice(0, 120),
|
|
611
|
+
label: question.label.slice(0, 300),
|
|
612
|
+
type: question.type.slice(0, 40),
|
|
613
|
+
...(question.options
|
|
614
|
+
? {
|
|
615
|
+
options: question.options.slice(0, 100).map((option) => ({
|
|
616
|
+
key: option.key.slice(0, 200),
|
|
617
|
+
value: option.value.slice(0, 300),
|
|
618
|
+
})),
|
|
619
|
+
}
|
|
620
|
+
: {}),
|
|
621
|
+
})),
|
|
622
|
+
whatsappButton: form.whatsappButton,
|
|
623
|
+
createdTime: form.createdTime,
|
|
624
|
+
}));
|
|
625
|
+
/** Upserts a Page's forms; with `complete`, a form Meta no longer lists goes. */
|
|
626
|
+
async function upsertForms(ctx, pageId, forms, complete) {
|
|
627
|
+
const now = Date.now();
|
|
628
|
+
const rows = await ctx.db
|
|
629
|
+
.query("metaForms")
|
|
630
|
+
.withIndex("by_pageId", (range) => range.eq("pageId", pageId))
|
|
631
|
+
.take(MAX_FORMS_PER_PAGE * 2);
|
|
632
|
+
const byId = new Map(rows.map((row) => [row.formId, row]));
|
|
633
|
+
for (const form of forms) {
|
|
634
|
+
const fields = {
|
|
635
|
+
name: form.name,
|
|
636
|
+
status: form.status,
|
|
637
|
+
questions: form.questions,
|
|
638
|
+
whatsappButton: form.whatsappButton ?? undefined,
|
|
639
|
+
createdTime: form.createdTime ?? undefined,
|
|
640
|
+
syncedAt: now,
|
|
641
|
+
};
|
|
642
|
+
const row = byId.get(form.formId);
|
|
643
|
+
if (row)
|
|
644
|
+
await ctx.db.patch("metaForms", row._id, fields);
|
|
645
|
+
else {
|
|
646
|
+
await ctx.db.insert("metaForms", {
|
|
647
|
+
pageId,
|
|
648
|
+
formId: form.formId,
|
|
649
|
+
...fields,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (complete) {
|
|
654
|
+
// Deleted at Meta: its rules and leads stay; the listing forgets it.
|
|
655
|
+
const listed = new Set(forms.map((form) => form.formId));
|
|
656
|
+
for (const row of rows) {
|
|
657
|
+
if (!listed.has(row.formId))
|
|
658
|
+
await ctx.db.delete("metaForms", row._id);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
export const saveForms = internalMutation({
|
|
663
|
+
args: {
|
|
664
|
+
pageId: v.string(),
|
|
665
|
+
forms: v.array(graphFormValidator),
|
|
666
|
+
complete: v.boolean(),
|
|
667
|
+
},
|
|
668
|
+
returns: v.null(),
|
|
669
|
+
handler: async (ctx, args) => {
|
|
670
|
+
await upsertForms(ctx, args.pageId, args.forms, args.complete);
|
|
671
|
+
return null;
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
export const saveLink = internalMutation({
|
|
675
|
+
args: {
|
|
676
|
+
siteId: v.id("sites"),
|
|
677
|
+
pageId: v.string(),
|
|
678
|
+
ownerKey: v.string(),
|
|
679
|
+
onlyOwn: v.boolean(),
|
|
680
|
+
subscribedAt: v.number(),
|
|
681
|
+
reconcileFrom: v.number(),
|
|
682
|
+
forms: v.union(v.array(graphFormValidator), v.null()),
|
|
683
|
+
},
|
|
684
|
+
returns: v.null(),
|
|
685
|
+
handler: async (ctx, args) => {
|
|
686
|
+
const connection = await connectionByOwner(ctx, args.ownerKey);
|
|
687
|
+
if (!connection || connection.status !== "active") {
|
|
688
|
+
fail("META_TOKEN_INVALID", "the owner's Meta token is invalid; connect again");
|
|
689
|
+
}
|
|
690
|
+
const page = await ctx.db
|
|
691
|
+
.query("metaPages")
|
|
692
|
+
.withIndex("by_connectionId_and_pageId", (range) => range.eq("connectionId", connection._id).eq("pageId", args.pageId))
|
|
693
|
+
.unique();
|
|
694
|
+
if (!page)
|
|
695
|
+
fail("NOT_FOUND", "the owner's Meta login does not list this Page");
|
|
696
|
+
const existing = await checkLinkable(ctx, args);
|
|
697
|
+
const now = Date.now();
|
|
698
|
+
const fields = {
|
|
699
|
+
name: page.name,
|
|
700
|
+
ownerKey: args.ownerKey,
|
|
701
|
+
connectionId: connection._id,
|
|
702
|
+
subscribedAt: args.subscribedAt,
|
|
703
|
+
subscriptionCheckedAt: args.subscribedAt,
|
|
704
|
+
lastError: undefined,
|
|
705
|
+
failures: 0,
|
|
706
|
+
nextReconcileAt: now,
|
|
707
|
+
updatedAt: now,
|
|
708
|
+
};
|
|
709
|
+
if (existing) {
|
|
710
|
+
// Linked again: the subscription is renewed, the cursors stay.
|
|
711
|
+
await ctx.db.patch("sitePages", existing._id, fields);
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
await ctx.db.insert("sitePages", {
|
|
715
|
+
siteId: args.siteId,
|
|
716
|
+
pageId: args.pageId,
|
|
717
|
+
...fields,
|
|
718
|
+
reconcileFrom: args.reconcileFrom,
|
|
719
|
+
createdAt: now,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
if (args.forms)
|
|
723
|
+
await upsertForms(ctx, args.pageId, args.forms, true);
|
|
724
|
+
await kickLeadsLoop(ctx, now);
|
|
725
|
+
return null;
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
/**
|
|
729
|
+
* Makes one of the owner's Pages feed the site's leads: subscribes the app to
|
|
730
|
+
* the Page's `leadgen` webhook with the Page's own token, reads its forms,
|
|
731
|
+
* records the link and starts the reconciliation. One Page feeds one site
|
|
732
|
+
* (`CONFLICT` otherwise). With `onlyOwn` (the federated path) a Page another
|
|
733
|
+
* owner linked to the site is a `CONFLICT` too; the host's own call moves it.
|
|
734
|
+
* `backfillDays` (0–30, host only) also brings the leads of the days before.
|
|
735
|
+
*/
|
|
736
|
+
export const linkPage = action({
|
|
737
|
+
args: {
|
|
738
|
+
siteId: v.id("sites"),
|
|
739
|
+
pageId: v.string(),
|
|
740
|
+
ownerKey: v.string(),
|
|
741
|
+
onlyOwn: v.optional(v.boolean()),
|
|
742
|
+
backfillDays: v.optional(v.number()),
|
|
743
|
+
},
|
|
744
|
+
returns: v.object({ linked: v.boolean(), forms: v.number() }),
|
|
745
|
+
handler: async (ctx, args) => {
|
|
746
|
+
const pageId = graphIdOf(args.pageId, "pageId");
|
|
747
|
+
const ownerKey = ownerKeyOf(args.ownerKey);
|
|
748
|
+
const backfillDays = args.backfillDays ?? 0;
|
|
749
|
+
if (!Number.isSafeInteger(backfillDays) ||
|
|
750
|
+
backfillDays < 0 ||
|
|
751
|
+
backfillDays > MAX_BACKFILL_DAYS) {
|
|
752
|
+
fail("INVALID_ARGUMENT", `backfillDays must be an integer from 0 to ${MAX_BACKFILL_DAYS}`);
|
|
753
|
+
}
|
|
754
|
+
requireKey();
|
|
755
|
+
requireLeadgenRoute();
|
|
756
|
+
const onlyOwn = args.onlyOwn ?? false;
|
|
757
|
+
const prepared = await ctx.runQuery(internal.metaLeads.prepareLink, {
|
|
758
|
+
siteId: args.siteId,
|
|
759
|
+
pageId,
|
|
760
|
+
ownerKey,
|
|
761
|
+
onlyOwn,
|
|
762
|
+
});
|
|
763
|
+
const token = await decryptSecret(env.RASTRO_SECRETS_KEY, {
|
|
764
|
+
cipher: prepared.tokenCipher,
|
|
765
|
+
iv: prepared.tokenIv,
|
|
766
|
+
});
|
|
767
|
+
const client = graph();
|
|
768
|
+
try {
|
|
769
|
+
await client.subscribePage(token, pageId);
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
if (error instanceof MetaGraphError && error.kind === "token") {
|
|
773
|
+
await ctx.runMutation(internal.metaLeads.markPageInvalid, {
|
|
774
|
+
pageRowId: prepared.pageRowId,
|
|
775
|
+
tokenIv: prepared.tokenIv,
|
|
776
|
+
message: error.message,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
if (error instanceof MetaGraphError) {
|
|
780
|
+
console.warn(`[rastro] Meta refused the leadgen subscription of Page ${pageId}: ${error.message}`);
|
|
781
|
+
}
|
|
782
|
+
pageFailure(error);
|
|
783
|
+
}
|
|
784
|
+
const subscribedAt = Date.now();
|
|
785
|
+
let forms = null;
|
|
786
|
+
try {
|
|
787
|
+
const listed = await client.leadForms(token, pageId);
|
|
788
|
+
if (listed.complete)
|
|
789
|
+
forms = listed.forms;
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
// The forms come with the first reconciliation run anyway.
|
|
793
|
+
if (!(error instanceof MetaGraphError))
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
await ctx.runMutation(internal.metaLeads.saveLink, {
|
|
797
|
+
siteId: args.siteId,
|
|
798
|
+
pageId,
|
|
799
|
+
ownerKey,
|
|
800
|
+
onlyOwn,
|
|
801
|
+
subscribedAt,
|
|
802
|
+
reconcileFrom: subscribedAt - backfillDays * DAY_MS,
|
|
803
|
+
forms: forms ? toFormArgs(forms) : null,
|
|
804
|
+
});
|
|
805
|
+
return { linked: true, forms: forms?.length ?? 0 };
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
/**
|
|
809
|
+
* Stops a Page feeding the site: forgets the link and so its reconciliation.
|
|
810
|
+
* The app's `leadgen` subscription stays on the Page: it is the Page's and
|
|
811
|
+
* the app's, shared by every host (one app, one dashboard webhook), so taking
|
|
812
|
+
* it off here would silence a host that links the Page now. The dashboard
|
|
813
|
+
* drops notifications for a Page no host claims. The site's form rules and
|
|
814
|
+
* the leads already in stay. With `ownerKey`, only that owner's link
|
|
815
|
+
* (another's is a `CONFLICT`). An action, like `linkPage`, for the callers
|
|
816
|
+
* that pair them.
|
|
817
|
+
*/
|
|
818
|
+
export const unlinkPage = action({
|
|
819
|
+
args: {
|
|
820
|
+
siteId: v.id("sites"),
|
|
821
|
+
pageId: v.string(),
|
|
822
|
+
ownerKey: v.optional(v.string()),
|
|
823
|
+
},
|
|
824
|
+
returns: v.object({ unlinked: v.boolean() }),
|
|
825
|
+
handler: async (ctx, args) => {
|
|
826
|
+
const pageId = graphIdOf(args.pageId, "pageId");
|
|
827
|
+
return await ctx.runMutation(internal.metaLeads.removeLink, {
|
|
828
|
+
siteId: args.siteId,
|
|
829
|
+
pageId,
|
|
830
|
+
ownerKey: args.ownerKey === undefined ? undefined : ownerKeyOf(args.ownerKey),
|
|
831
|
+
});
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
export const removeLink = internalMutation({
|
|
835
|
+
args: {
|
|
836
|
+
siteId: v.id("sites"),
|
|
837
|
+
pageId: v.string(),
|
|
838
|
+
ownerKey: v.optional(v.string()),
|
|
839
|
+
},
|
|
840
|
+
returns: v.object({ unlinked: v.boolean() }),
|
|
841
|
+
handler: async (ctx, args) => {
|
|
842
|
+
const link = await linkOfPage(ctx, args.pageId);
|
|
843
|
+
if (!link || link.siteId !== args.siteId)
|
|
844
|
+
return { unlinked: false };
|
|
845
|
+
if (args.ownerKey !== undefined && link.ownerKey !== args.ownerKey) {
|
|
846
|
+
fail("CONFLICT", "this Page is linked to the site by another owner on this host; unlink it there");
|
|
847
|
+
}
|
|
848
|
+
await ctx.db.delete("sitePages", link._id);
|
|
849
|
+
return { unlinked: true };
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
// Forms.
|
|
854
|
+
const sitePageTokensValidator = v.array(v.object({
|
|
855
|
+
pageId: v.string(),
|
|
856
|
+
pageRowId: v.id("metaPages"),
|
|
857
|
+
tokenCipher: v.string(),
|
|
858
|
+
tokenIv: v.string(),
|
|
859
|
+
}));
|
|
860
|
+
export const loadSitePageTokens = internalQuery({
|
|
861
|
+
args: { siteId: v.id("sites"), pageId: v.optional(v.string()) },
|
|
862
|
+
returns: sitePageTokensValidator,
|
|
863
|
+
handler: async (ctx, args) => {
|
|
864
|
+
await requireSite(ctx, args.siteId);
|
|
865
|
+
const links = await ctx.db
|
|
866
|
+
.query("sitePages")
|
|
867
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
868
|
+
.take(MAX_SITE_PAGES);
|
|
869
|
+
const out = [];
|
|
870
|
+
for (const link of links) {
|
|
871
|
+
if (args.pageId !== undefined && link.pageId !== args.pageId)
|
|
872
|
+
continue;
|
|
873
|
+
const page = await ctx.db
|
|
874
|
+
.query("metaPages")
|
|
875
|
+
.withIndex("by_connectionId_and_pageId", (range) => range.eq("connectionId", link.connectionId).eq("pageId", link.pageId))
|
|
876
|
+
.unique();
|
|
877
|
+
if (!page || page.invalidSince !== undefined || !page.listed)
|
|
878
|
+
continue;
|
|
879
|
+
out.push({
|
|
880
|
+
pageId: link.pageId,
|
|
881
|
+
pageRowId: page._id,
|
|
882
|
+
tokenCipher: page.tokenCipher,
|
|
883
|
+
tokenIv: page.tokenIv,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
return out;
|
|
887
|
+
},
|
|
888
|
+
});
|
|
889
|
+
/**
|
|
890
|
+
* Reads the instant forms of the site's linked Pages (or one of them) from
|
|
891
|
+
* Meta again. New forms show up by themselves every reconciliation run; this
|
|
892
|
+
* is the screen's "refresh now".
|
|
893
|
+
*/
|
|
894
|
+
export const refreshForms = action({
|
|
895
|
+
args: { siteId: v.id("sites"), pageId: v.optional(v.string()) },
|
|
896
|
+
returns: v.object({ pages: v.number(), forms: v.number() }),
|
|
897
|
+
handler: async (ctx, args) => {
|
|
898
|
+
const pageId = args.pageId === undefined ? undefined : graphIdOf(args.pageId, "pageId");
|
|
899
|
+
requireKey();
|
|
900
|
+
const targets = await ctx.runQuery(internal.metaLeads.loadSitePageTokens, {
|
|
901
|
+
siteId: args.siteId,
|
|
902
|
+
...(pageId === undefined ? {} : { pageId }),
|
|
903
|
+
});
|
|
904
|
+
const client = graph();
|
|
905
|
+
let forms = 0;
|
|
906
|
+
for (const target of targets) {
|
|
907
|
+
const token = await decryptSecret(env.RASTRO_SECRETS_KEY, {
|
|
908
|
+
cipher: target.tokenCipher,
|
|
909
|
+
iv: target.tokenIv,
|
|
910
|
+
});
|
|
911
|
+
let listed;
|
|
912
|
+
try {
|
|
913
|
+
listed = await client.leadForms(token, target.pageId);
|
|
914
|
+
}
|
|
915
|
+
catch (error) {
|
|
916
|
+
if (error instanceof MetaGraphError && error.kind === "token") {
|
|
917
|
+
await ctx.runMutation(internal.metaLeads.markPageInvalid, {
|
|
918
|
+
pageRowId: target.pageRowId,
|
|
919
|
+
tokenIv: target.tokenIv,
|
|
920
|
+
message: error.message,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
pageFailure(error);
|
|
924
|
+
}
|
|
925
|
+
await ctx.runMutation(internal.metaLeads.saveForms, {
|
|
926
|
+
pageId: target.pageId,
|
|
927
|
+
forms: toFormArgs(listed.forms),
|
|
928
|
+
complete: listed.complete,
|
|
929
|
+
});
|
|
930
|
+
forms += listed.forms.length;
|
|
931
|
+
}
|
|
932
|
+
return { pages: targets.length, forms };
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
async function rulesOf(ctx, siteId, formId) {
|
|
936
|
+
return await ctx.db
|
|
937
|
+
.query("formRules")
|
|
938
|
+
.withIndex("by_siteId_and_formId", (range) => range.eq("siteId", siteId).eq("formId", formId))
|
|
939
|
+
.unique();
|
|
940
|
+
}
|
|
941
|
+
/** Whether `ownerKey` may edit the form's rules: its Page and its rules. */
|
|
942
|
+
function ownsRules(link, rules, ownerKey) {
|
|
943
|
+
return (link?.ownerKey === ownerKey && (rules === null || rules.setBy === ownerKey));
|
|
944
|
+
}
|
|
945
|
+
async function handlerReady(ctx) {
|
|
946
|
+
return ((await ctx.db
|
|
947
|
+
.query("metaFormLeadHandler")
|
|
948
|
+
.withIndex("by_key", (range) => range.eq("key", "handler"))
|
|
949
|
+
.unique()) !== null);
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* The Pages linked to the site and their forms, for the forms screen: each
|
|
953
|
+
* form's leads in the 30 UTC days up to `now` (and per day), its last lead,
|
|
954
|
+
* its rules and the end screen's WhatsApp button. With `ownerKey`, whether
|
|
955
|
+
* that owner linked each Page and may edit each form's rules (`own`).
|
|
956
|
+
*/
|
|
957
|
+
export const forms = query({
|
|
958
|
+
args: {
|
|
959
|
+
siteId: v.id("sites"),
|
|
960
|
+
now: v.optional(v.number()),
|
|
961
|
+
ownerKey: v.optional(v.string()),
|
|
962
|
+
},
|
|
963
|
+
returns: metaFormsValidator,
|
|
964
|
+
handler: async (ctx, args) => {
|
|
965
|
+
await requireSite(ctx, args.siteId);
|
|
966
|
+
const now = nowOf(args.now);
|
|
967
|
+
const ownerKey = args.ownerKey === undefined ? undefined : ownerKeyOf(args.ownerKey);
|
|
968
|
+
const ready = await handlerReady(ctx);
|
|
969
|
+
const links = await ctx.db
|
|
970
|
+
.query("sitePages")
|
|
971
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
972
|
+
.take(MAX_SITE_PAGES);
|
|
973
|
+
const to = now === undefined ? 0 : dayOf(now);
|
|
974
|
+
const from = to - (MONTH_DAYS - 1) * DAY_MS;
|
|
975
|
+
// Every form's cohort days, one index range a day: a day holds a row per
|
|
976
|
+
// form that had leads that day, so the cap only bites past
|
|
977
|
+
// MAX_SITE_FORMS forms with leads on the same day.
|
|
978
|
+
const days = new Map();
|
|
979
|
+
if (now !== undefined) {
|
|
980
|
+
for (let index = 0; index < MONTH_DAYS; index += 1) {
|
|
981
|
+
const dayStart = from + index * DAY_MS;
|
|
982
|
+
const rows = await ctx.db
|
|
983
|
+
.query("leadDailyRollups")
|
|
984
|
+
.withIndex("by_siteId_and_dimension_and_dayStart_and_key", (range) => range
|
|
985
|
+
.eq("siteId", args.siteId)
|
|
986
|
+
.eq("dimension", "form")
|
|
987
|
+
.eq("dayStart", dayStart))
|
|
988
|
+
.take(MAX_SITE_FORMS);
|
|
989
|
+
for (const row of rows) {
|
|
990
|
+
const series = days.get(row.key) ?? Array.from({ length: MONTH_DAYS }, () => 0);
|
|
991
|
+
series[index] += row.leads;
|
|
992
|
+
days.set(row.key, series);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// At most MAX_SITE_FORMS forms in all: every Page's active forms first,
|
|
997
|
+
// then the others (Meta keeps archived forms forever) while room is left.
|
|
998
|
+
// Each form costs one more read (its rules); its last lead is on its row.
|
|
999
|
+
const pageForms = new Map();
|
|
1000
|
+
let budget = MAX_SITE_FORMS;
|
|
1001
|
+
for (const link of links) {
|
|
1002
|
+
const active = await ctx.db
|
|
1003
|
+
.query("metaForms")
|
|
1004
|
+
.withIndex("by_pageId_and_status", (range) => range.eq("pageId", link.pageId).eq("status", "ACTIVE"))
|
|
1005
|
+
.take(Math.min(budget, MAX_FORMS_PER_PAGE));
|
|
1006
|
+
budget -= active.length;
|
|
1007
|
+
pageForms.set(link.pageId, active);
|
|
1008
|
+
}
|
|
1009
|
+
for (const link of links) {
|
|
1010
|
+
if (budget <= 0)
|
|
1011
|
+
break;
|
|
1012
|
+
const listed = pageForms.get(link.pageId) ?? [];
|
|
1013
|
+
const room = Math.min(budget, MAX_FORMS_PER_PAGE - listed.length);
|
|
1014
|
+
if (room <= 0)
|
|
1015
|
+
continue;
|
|
1016
|
+
const before = await ctx.db
|
|
1017
|
+
.query("metaForms")
|
|
1018
|
+
.withIndex("by_pageId_and_status", (range) => range.eq("pageId", link.pageId).lt("status", "ACTIVE"))
|
|
1019
|
+
.take(room);
|
|
1020
|
+
const after = room - before.length > 0
|
|
1021
|
+
? await ctx.db
|
|
1022
|
+
.query("metaForms")
|
|
1023
|
+
.withIndex("by_pageId_and_status", (range) => range.eq("pageId", link.pageId).gt("status", "ACTIVE"))
|
|
1024
|
+
.take(room - before.length)
|
|
1025
|
+
: [];
|
|
1026
|
+
budget -= before.length + after.length;
|
|
1027
|
+
pageForms.set(link.pageId, [...listed, ...before, ...after]);
|
|
1028
|
+
}
|
|
1029
|
+
const out = [];
|
|
1030
|
+
for (const link of links) {
|
|
1031
|
+
const page = await ctx.db
|
|
1032
|
+
.query("metaPages")
|
|
1033
|
+
.withIndex("by_connectionId_and_pageId", (range) => range.eq("connectionId", link.connectionId).eq("pageId", link.pageId))
|
|
1034
|
+
.unique();
|
|
1035
|
+
const rows = pageForms.get(link.pageId) ?? [];
|
|
1036
|
+
const forms = [];
|
|
1037
|
+
for (const form of rows) {
|
|
1038
|
+
const daily = now === undefined
|
|
1039
|
+
? null
|
|
1040
|
+
: (days.get(form.formId) ??
|
|
1041
|
+
Array.from({ length: MONTH_DAYS }, () => 0));
|
|
1042
|
+
const leads30d = daily === null ? null : daily.reduce((sum, count) => sum + count, 0);
|
|
1043
|
+
const rules = await rulesOf(ctx, args.siteId, form.formId);
|
|
1044
|
+
forms.push({
|
|
1045
|
+
formId: form.formId,
|
|
1046
|
+
name: form.name,
|
|
1047
|
+
status: form.status,
|
|
1048
|
+
questions: form.questions,
|
|
1049
|
+
whatsappButton: form.whatsappButton ?? null,
|
|
1050
|
+
whatsappButtonConfirmed: rules?.whatsappButtonConfirmed === true,
|
|
1051
|
+
createdTime: form.createdTime ?? null,
|
|
1052
|
+
syncedAt: form.syncedAt,
|
|
1053
|
+
leads30d,
|
|
1054
|
+
daily,
|
|
1055
|
+
lastLeadAt: form.lastLead?.siteId === args.siteId ? form.lastLead.at : null,
|
|
1056
|
+
rules: {
|
|
1057
|
+
configured: rules !== null,
|
|
1058
|
+
count: rules?.rules.length ?? 0,
|
|
1059
|
+
entryColumn: rules?.entryColumn ?? null,
|
|
1060
|
+
...(ownerKey === undefined
|
|
1061
|
+
? {}
|
|
1062
|
+
: { own: ownsRules(link, rules, ownerKey) }),
|
|
1063
|
+
},
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
forms.sort((left, right) => Number(left.status !== "ACTIVE") -
|
|
1067
|
+
Number(right.status !== "ACTIVE") ||
|
|
1068
|
+
(right.lastLeadAt ?? 0) - (left.lastLeadAt ?? 0) ||
|
|
1069
|
+
left.name.localeCompare(right.name));
|
|
1070
|
+
out.push({
|
|
1071
|
+
pageId: link.pageId,
|
|
1072
|
+
pageName: link.name,
|
|
1073
|
+
leadsAllowed: page?.leadsAllowed ?? false,
|
|
1074
|
+
subscribedAt: link.subscribedAt ?? null,
|
|
1075
|
+
lastReconcileAt: link.lastReconcileAt ?? null,
|
|
1076
|
+
lastLeadAt: link.lastLeadAt ?? null,
|
|
1077
|
+
lastError: pageErrorCode(link.lastError) ?? (ready ? null : "HANDLER_MISSING"),
|
|
1078
|
+
...(ownerKey === undefined ? {} : { own: link.ownerKey === ownerKey }),
|
|
1079
|
+
handlerReady: ready,
|
|
1080
|
+
forms,
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
return out;
|
|
1084
|
+
},
|
|
1085
|
+
});
|
|
1086
|
+
// ---------------------------------------------------------------------------
|
|
1087
|
+
// Rules.
|
|
1088
|
+
/** The form, which must belong to a Page linked to the site. */
|
|
1089
|
+
async function siteForm(ctx, siteId, formId) {
|
|
1090
|
+
await requireSite(ctx, siteId);
|
|
1091
|
+
const form = await ctx.db
|
|
1092
|
+
.query("metaForms")
|
|
1093
|
+
.withIndex("by_formId", (range) => range.eq("formId", formId))
|
|
1094
|
+
.first();
|
|
1095
|
+
const link = form ? await linkOfPage(ctx, form.pageId) : null;
|
|
1096
|
+
if (!form || !link || link.siteId !== siteId) {
|
|
1097
|
+
fail("NOT_FOUND", "the form is not on a Page linked to this site");
|
|
1098
|
+
}
|
|
1099
|
+
return { form, link };
|
|
1100
|
+
}
|
|
1101
|
+
function columnsOf(pipeline) {
|
|
1102
|
+
return new Set((pipeline?.columns ?? []).map((column) => column.key));
|
|
1103
|
+
}
|
|
1104
|
+
function columnOf(value, declared, field) {
|
|
1105
|
+
const column = value.trim();
|
|
1106
|
+
if (!declared.has(column)) {
|
|
1107
|
+
fail("INVALID_ARGUMENT", `${field} ${column.slice(0, 64)} is not on the site's pipeline (setPipeline)`);
|
|
1108
|
+
}
|
|
1109
|
+
return column;
|
|
1110
|
+
}
|
|
1111
|
+
function reasonOf(value) {
|
|
1112
|
+
const reason = cleanString(value, MAX_REASON);
|
|
1113
|
+
if (!reason)
|
|
1114
|
+
fail("INVALID_ARGUMENT", "a discard needs its reason");
|
|
1115
|
+
return reason;
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* The rules as stored: each question a choice question of the form, each
|
|
1119
|
+
* answer one of its options (by key or shown value, stored by key), each
|
|
1120
|
+
* column on the site's pipeline. `lenient` (the simulation's draft) skips
|
|
1121
|
+
* what does not resolve instead of refusing it.
|
|
1122
|
+
*/
|
|
1123
|
+
function cleanRules(rules, questions, declared, lenient) {
|
|
1124
|
+
if (rules.length > MAX_RULES) {
|
|
1125
|
+
fail("LIMIT_EXCEEDED", `a form has at most ${MAX_RULES} rules`);
|
|
1126
|
+
}
|
|
1127
|
+
const byKey = new Map(questions.map((question) => [question.key, question]));
|
|
1128
|
+
const out = [];
|
|
1129
|
+
for (const rule of rules) {
|
|
1130
|
+
const question = byKey.get(rule.questionKey);
|
|
1131
|
+
if (!choiceOptions(question)) {
|
|
1132
|
+
if (lenient)
|
|
1133
|
+
continue;
|
|
1134
|
+
fail("INVALID_ARGUMENT", `${rule.questionKey.slice(0, 64)} is not a choice question of the form`);
|
|
1135
|
+
}
|
|
1136
|
+
if (rule.answers.length === 0 || rule.answers.length > MAX_RULE_ANSWERS) {
|
|
1137
|
+
if (lenient)
|
|
1138
|
+
continue;
|
|
1139
|
+
fail("INVALID_ARGUMENT", `a rule names 1-${MAX_RULE_ANSWERS} answers`);
|
|
1140
|
+
}
|
|
1141
|
+
const answers = [];
|
|
1142
|
+
for (const answer of rule.answers) {
|
|
1143
|
+
const key = optionKeyOf(question, answer);
|
|
1144
|
+
if (key === null) {
|
|
1145
|
+
if (lenient)
|
|
1146
|
+
continue;
|
|
1147
|
+
fail("INVALID_ARGUMENT", `${answer.slice(0, 64)} is not an option of ${rule.questionKey.slice(0, 64)}`);
|
|
1148
|
+
}
|
|
1149
|
+
if (!answers.includes(key))
|
|
1150
|
+
answers.push(key);
|
|
1151
|
+
}
|
|
1152
|
+
if (answers.length === 0)
|
|
1153
|
+
continue;
|
|
1154
|
+
let to;
|
|
1155
|
+
if ("column" in rule.to) {
|
|
1156
|
+
if (lenient && !declared.has(rule.to.column.trim()))
|
|
1157
|
+
continue;
|
|
1158
|
+
to = { column: columnOf(rule.to.column, declared, "column") };
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
const reason = cleanString(rule.to.discard, MAX_REASON);
|
|
1162
|
+
if (!reason) {
|
|
1163
|
+
if (lenient)
|
|
1164
|
+
continue;
|
|
1165
|
+
reasonOf(rule.to.discard);
|
|
1166
|
+
}
|
|
1167
|
+
to = { discard: reason };
|
|
1168
|
+
}
|
|
1169
|
+
out.push({ questionKey: rule.questionKey, answers, to });
|
|
1170
|
+
}
|
|
1171
|
+
return out;
|
|
1172
|
+
}
|
|
1173
|
+
function cleanFirstContact(value) {
|
|
1174
|
+
const text = value.text === undefined
|
|
1175
|
+
? undefined
|
|
1176
|
+
: cleanString(value.text, MAX_FIRST_CONTACT_TEXT) || undefined;
|
|
1177
|
+
let brakes = value.brakes;
|
|
1178
|
+
if (brakes) {
|
|
1179
|
+
const { waitMinutes, maxPerHour } = brakes;
|
|
1180
|
+
if (!Number.isSafeInteger(waitMinutes) ||
|
|
1181
|
+
waitMinutes < 15 ||
|
|
1182
|
+
waitMinutes > 1_440 ||
|
|
1183
|
+
!Number.isSafeInteger(maxPerHour) ||
|
|
1184
|
+
maxPerHour < 1 ||
|
|
1185
|
+
maxPerHour > 120) {
|
|
1186
|
+
fail("INVALID_ARGUMENT", "brakes: wait 15-1440 whole minutes, send 1-120 per hour");
|
|
1187
|
+
}
|
|
1188
|
+
brakes = {
|
|
1189
|
+
waitMinutes,
|
|
1190
|
+
maxPerHour,
|
|
1191
|
+
businessHoursOnly: brakes.businessHoursOnly,
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
if (value.mode === "automatic" && !brakes) {
|
|
1195
|
+
fail("INVALID_ARGUMENT", "an automatic first contact needs its brakes");
|
|
1196
|
+
}
|
|
1197
|
+
if (value.mode === "one_click" && !text) {
|
|
1198
|
+
fail("INVALID_ARGUMENT", "a one-click first contact needs its text");
|
|
1199
|
+
}
|
|
1200
|
+
return {
|
|
1201
|
+
mode: value.mode,
|
|
1202
|
+
...(text ? { text } : {}),
|
|
1203
|
+
...(brakes ? { brakes } : {}),
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
const DEFAULT_FIRST_CONTACT = { mode: "lead_starts" };
|
|
1207
|
+
/**
|
|
1208
|
+
* Where a site's form sends its leads: the rules in order (the first that
|
|
1209
|
+
* matches decides; none → `entryColumn`, null for the host's own entry), and
|
|
1210
|
+
* how the host makes the first contact. Replaces what was there; it decides
|
|
1211
|
+
* the next leads only. With `ownerKey` and `onlyOwn` (the federated path), a
|
|
1212
|
+
* Page or rules another owner set are a `CONFLICT`.
|
|
1213
|
+
*/
|
|
1214
|
+
export const setFormRules = mutation({
|
|
1215
|
+
args: {
|
|
1216
|
+
siteId: v.id("sites"),
|
|
1217
|
+
formId: v.string(),
|
|
1218
|
+
entryColumn: v.union(v.string(), v.null()),
|
|
1219
|
+
rules: v.array(formRuleValidator),
|
|
1220
|
+
firstContact: firstContactValidator,
|
|
1221
|
+
/** The host checked the end screen's WhatsApp button by hand. */
|
|
1222
|
+
whatsappButtonConfirmed: v.optional(v.boolean()),
|
|
1223
|
+
ownerKey: v.optional(v.string()),
|
|
1224
|
+
onlyOwn: v.optional(v.boolean()),
|
|
1225
|
+
},
|
|
1226
|
+
returns: v.null(),
|
|
1227
|
+
handler: async (ctx, args) => {
|
|
1228
|
+
const formId = graphIdOf(args.formId, "formId");
|
|
1229
|
+
const { form, link } = await siteForm(ctx, args.siteId, formId);
|
|
1230
|
+
const existing = await rulesOf(ctx, args.siteId, formId);
|
|
1231
|
+
const ownerKey = args.ownerKey === undefined ? undefined : ownerKeyOf(args.ownerKey);
|
|
1232
|
+
if (args.onlyOwn) {
|
|
1233
|
+
if (ownerKey === undefined) {
|
|
1234
|
+
fail("INVALID_ARGUMENT", "onlyOwn needs the ownerKey");
|
|
1235
|
+
}
|
|
1236
|
+
if (!ownsRules(link, existing, ownerKey)) {
|
|
1237
|
+
fail("CONFLICT", "this form's Page or rules were set by another owner on this host; change them there");
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
const pipeline = await ctx.db
|
|
1241
|
+
.query("sitePipelines")
|
|
1242
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
1243
|
+
.unique();
|
|
1244
|
+
const declared = columnsOf(pipeline);
|
|
1245
|
+
const entryColumn = args.entryColumn === null
|
|
1246
|
+
? undefined
|
|
1247
|
+
: columnOf(args.entryColumn, declared, "entryColumn");
|
|
1248
|
+
const rules = cleanRules(args.rules, form.questions, declared, false);
|
|
1249
|
+
const fields = {
|
|
1250
|
+
siteId: args.siteId,
|
|
1251
|
+
formId,
|
|
1252
|
+
...(entryColumn ? { entryColumn } : {}),
|
|
1253
|
+
rules,
|
|
1254
|
+
signatures: await Promise.all(rules.map((rule) => sha256Hex(ruleSignature(rule)))),
|
|
1255
|
+
firstContact: cleanFirstContact(args.firstContact),
|
|
1256
|
+
...((args.whatsappButtonConfirmed ?? existing?.whatsappButtonConfirmed)
|
|
1257
|
+
? { whatsappButtonConfirmed: true }
|
|
1258
|
+
: {}),
|
|
1259
|
+
...(ownerKey ? { setBy: ownerKey } : {}),
|
|
1260
|
+
updatedAt: Date.now(),
|
|
1261
|
+
};
|
|
1262
|
+
if (existing)
|
|
1263
|
+
await ctx.db.replace("formRules", existing._id, fields);
|
|
1264
|
+
else
|
|
1265
|
+
await ctx.db.insert("formRules", fields);
|
|
1266
|
+
return null;
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
/** Distinct leads a rule decided since `since` (bounded). */
|
|
1270
|
+
async function decidedSince(ctx, siteId, formId, signature, since) {
|
|
1271
|
+
const rows = await ctx.db
|
|
1272
|
+
.query("formLeads")
|
|
1273
|
+
.withIndex("by_siteId_and_formId_and_ruleSignature_and_receivedAt", (range) => range
|
|
1274
|
+
.eq("siteId", siteId)
|
|
1275
|
+
.eq("formId", formId)
|
|
1276
|
+
.eq("ruleSignature", signature)
|
|
1277
|
+
.gt("receivedAt", since))
|
|
1278
|
+
.take(MAX_MONTH_LEADS);
|
|
1279
|
+
return rows.length;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* A form's rules for the drawer, each with the leads it decided in the 30
|
|
1283
|
+
* days up to `now` (null without `now`). A form never configured answers its
|
|
1284
|
+
* defaults with `configured: false`.
|
|
1285
|
+
*/
|
|
1286
|
+
export const formRules = query({
|
|
1287
|
+
args: {
|
|
1288
|
+
siteId: v.id("sites"),
|
|
1289
|
+
formId: v.string(),
|
|
1290
|
+
now: v.optional(v.number()),
|
|
1291
|
+
ownerKey: v.optional(v.string()),
|
|
1292
|
+
},
|
|
1293
|
+
returns: formRulesValidator,
|
|
1294
|
+
handler: async (ctx, args) => {
|
|
1295
|
+
const formId = graphIdOf(args.formId, "formId");
|
|
1296
|
+
const { link } = await siteForm(ctx, args.siteId, formId);
|
|
1297
|
+
const now = nowOf(args.now);
|
|
1298
|
+
const rules = await rulesOf(ctx, args.siteId, formId);
|
|
1299
|
+
const since = now === undefined ? 0 : now - MONTH_DAYS * DAY_MS;
|
|
1300
|
+
const rows = [];
|
|
1301
|
+
for (const [index, rule] of (rules?.rules ?? []).entries()) {
|
|
1302
|
+
const signature = rules?.signatures[index];
|
|
1303
|
+
rows.push({
|
|
1304
|
+
...rule,
|
|
1305
|
+
monthCount: now === undefined || signature === undefined
|
|
1306
|
+
? null
|
|
1307
|
+
: await decidedSince(ctx, args.siteId, formId, signature, since),
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
return {
|
|
1311
|
+
siteId: args.siteId,
|
|
1312
|
+
formId,
|
|
1313
|
+
configured: rules !== null,
|
|
1314
|
+
entryColumn: rules?.entryColumn ?? null,
|
|
1315
|
+
rules: rows,
|
|
1316
|
+
firstContact: rules?.firstContact ?? DEFAULT_FIRST_CONTACT,
|
|
1317
|
+
whatsappButtonConfirmed: rules?.whatsappButtonConfirmed === true,
|
|
1318
|
+
updatedAt: rules?.updatedAt ?? null,
|
|
1319
|
+
...(args.ownerKey === undefined
|
|
1320
|
+
? {}
|
|
1321
|
+
: { own: ownsRules(link, rules, ownerKeyOf(args.ownerKey)) }),
|
|
1322
|
+
};
|
|
1323
|
+
},
|
|
1324
|
+
});
|
|
1325
|
+
/**
|
|
1326
|
+
* The form's leads of the 30 days up to `now` under a draft of its rules:
|
|
1327
|
+
* how many each rule would decide, and where they would all go. Reads the
|
|
1328
|
+
* choice answers Rastro keeps; never Meta.
|
|
1329
|
+
*/
|
|
1330
|
+
export const simulateFormRules = query({
|
|
1331
|
+
args: {
|
|
1332
|
+
siteId: v.id("sites"),
|
|
1333
|
+
formId: v.string(),
|
|
1334
|
+
entryColumn: v.union(v.string(), v.null()),
|
|
1335
|
+
rules: v.array(formRuleValidator),
|
|
1336
|
+
now: v.number(),
|
|
1337
|
+
},
|
|
1338
|
+
returns: formRulesSimulationValidator,
|
|
1339
|
+
handler: async (ctx, args) => {
|
|
1340
|
+
const formId = graphIdOf(args.formId, "formId");
|
|
1341
|
+
const { form } = await siteForm(ctx, args.siteId, formId);
|
|
1342
|
+
const now = nowOf(args.now);
|
|
1343
|
+
const pipeline = await ctx.db
|
|
1344
|
+
.query("sitePipelines")
|
|
1345
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
1346
|
+
.unique();
|
|
1347
|
+
const declared = columnsOf(pipeline);
|
|
1348
|
+
// The draft as it would be saved, minus what does not resolve yet.
|
|
1349
|
+
const draft = [];
|
|
1350
|
+
const draftIndex = [];
|
|
1351
|
+
for (const [index, rule] of args.rules.entries()) {
|
|
1352
|
+
const [clean] = cleanRules([rule], form.questions, declared, true);
|
|
1353
|
+
if (clean) {
|
|
1354
|
+
draft.push(clean);
|
|
1355
|
+
draftIndex.push(index);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
const entryColumn = args.entryColumn !== null && declared.has(args.entryColumn.trim())
|
|
1359
|
+
? args.entryColumn.trim()
|
|
1360
|
+
: null;
|
|
1361
|
+
const leads = await ctx.db
|
|
1362
|
+
.query("formLeads")
|
|
1363
|
+
.withIndex("by_siteId_and_formId_and_receivedAt", (range) => range
|
|
1364
|
+
.eq("siteId", args.siteId)
|
|
1365
|
+
.eq("formId", formId)
|
|
1366
|
+
.gt("receivedAt", now - MONTH_DAYS * DAY_MS)
|
|
1367
|
+
.lte("receivedAt", now))
|
|
1368
|
+
.order("desc")
|
|
1369
|
+
.take(MAX_MONTH_LEADS + 1);
|
|
1370
|
+
const partial = leads.length > MAX_MONTH_LEADS;
|
|
1371
|
+
const perRule = args.rules.map(() => 0);
|
|
1372
|
+
const columns = new Map();
|
|
1373
|
+
const discarded = new Map();
|
|
1374
|
+
let entry = 0;
|
|
1375
|
+
for (const lead of leads.slice(0, MAX_MONTH_LEADS)) {
|
|
1376
|
+
const outcome = evaluateRules(draft, entryColumn, lead.answers);
|
|
1377
|
+
if (outcome.ruleIndex === null)
|
|
1378
|
+
entry += 1;
|
|
1379
|
+
else
|
|
1380
|
+
perRule[draftIndex[outcome.ruleIndex]] += 1;
|
|
1381
|
+
if ("discard" in outcome.decision) {
|
|
1382
|
+
const reason = outcome.decision.discard;
|
|
1383
|
+
discarded.set(reason, (discarded.get(reason) ?? 0) + 1);
|
|
1384
|
+
}
|
|
1385
|
+
else if (outcome.decision.column !== null) {
|
|
1386
|
+
const column = outcome.decision.column;
|
|
1387
|
+
columns.set(column, (columns.get(column) ?? 0) + 1);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
return {
|
|
1391
|
+
total: Math.min(leads.length, MAX_MONTH_LEADS),
|
|
1392
|
+
entry,
|
|
1393
|
+
rules: perRule,
|
|
1394
|
+
columns: [...columns].map(([column, count]) => ({
|
|
1395
|
+
column,
|
|
1396
|
+
leads: count,
|
|
1397
|
+
})),
|
|
1398
|
+
discarded: [...discarded].map(([reason, count]) => ({
|
|
1399
|
+
reason,
|
|
1400
|
+
leads: count,
|
|
1401
|
+
})),
|
|
1402
|
+
partial,
|
|
1403
|
+
};
|
|
1404
|
+
},
|
|
1405
|
+
});
|
|
1406
|
+
/**
|
|
1407
|
+
* Sends a discarded lead back to the funnel: its decision is cleared (it
|
|
1408
|
+
* goes to the host's entry) and the reason it had is kept as history. The
|
|
1409
|
+
* host moves its card back itself. With `ownerKey`, only for a Page that
|
|
1410
|
+
* owner linked.
|
|
1411
|
+
*/
|
|
1412
|
+
export const returnFormLead = mutation({
|
|
1413
|
+
args: {
|
|
1414
|
+
siteId: v.id("sites"),
|
|
1415
|
+
leadId: v.string(),
|
|
1416
|
+
ownerKey: v.optional(v.string()),
|
|
1417
|
+
},
|
|
1418
|
+
returns: v.object({ returned: v.boolean() }),
|
|
1419
|
+
handler: async (ctx, args) => {
|
|
1420
|
+
const leadId = ctx.db.normalizeId("leads", args.leadId);
|
|
1421
|
+
if (!leadId)
|
|
1422
|
+
fail("NOT_FOUND", "lead not found");
|
|
1423
|
+
const row = await ctx.db
|
|
1424
|
+
.query("formLeads")
|
|
1425
|
+
.withIndex("by_leadId", (range) => range.eq("leadId", leadId))
|
|
1426
|
+
.first();
|
|
1427
|
+
if (!row || row.siteId !== args.siteId) {
|
|
1428
|
+
fail("NOT_FOUND", "no form lead with this id on the site");
|
|
1429
|
+
}
|
|
1430
|
+
if (args.ownerKey !== undefined) {
|
|
1431
|
+
const link = await linkOfPage(ctx, row.pageId);
|
|
1432
|
+
if (link?.ownerKey !== ownerKeyOf(args.ownerKey)) {
|
|
1433
|
+
fail("CONFLICT", "this lead's Page is linked by another owner on this host; return it there");
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
if (!("discard" in row.decision))
|
|
1437
|
+
return { returned: false };
|
|
1438
|
+
await ctx.db.patch("formLeads", row._id, {
|
|
1439
|
+
decision: { column: null },
|
|
1440
|
+
returned: { at: Date.now(), reason: row.decision.discard },
|
|
1441
|
+
});
|
|
1442
|
+
return { returned: true };
|
|
1443
|
+
},
|
|
1444
|
+
});
|
|
1445
|
+
// ---------------------------------------------------------------------------
|
|
1446
|
+
// The host's handler.
|
|
1447
|
+
/**
|
|
1448
|
+
* Registers the host's form lead handler: a function handle to an internal
|
|
1449
|
+
* mutation that receives each lead (see `formLeadPayloadValidator`). Safe to
|
|
1450
|
+
* call again; `null` removes it, and leads then wait (not marked ingested)
|
|
1451
|
+
* until a handler is back.
|
|
1452
|
+
*/
|
|
1453
|
+
export const setFormLeadHandler = mutation({
|
|
1454
|
+
args: { handle: v.union(v.string(), v.null()) },
|
|
1455
|
+
returns: v.null(),
|
|
1456
|
+
handler: async (ctx, args) => {
|
|
1457
|
+
const existing = await ctx.db
|
|
1458
|
+
.query("metaFormLeadHandler")
|
|
1459
|
+
.withIndex("by_key", (range) => range.eq("key", "handler"))
|
|
1460
|
+
.unique();
|
|
1461
|
+
if (args.handle === null) {
|
|
1462
|
+
if (existing)
|
|
1463
|
+
await ctx.db.delete("metaFormLeadHandler", existing._id);
|
|
1464
|
+
return null;
|
|
1465
|
+
}
|
|
1466
|
+
const handle = args.handle.trim();
|
|
1467
|
+
if (!HANDLE.test(handle) || handle.length > 512) {
|
|
1468
|
+
fail("INVALID_ARGUMENT", "handle must come from createFunctionHandle(internal.yourModule.yourMutation)");
|
|
1469
|
+
}
|
|
1470
|
+
const now = Date.now();
|
|
1471
|
+
if (existing) {
|
|
1472
|
+
if (existing.handle !== handle) {
|
|
1473
|
+
await ctx.db.patch("metaFormLeadHandler", existing._id, {
|
|
1474
|
+
handle,
|
|
1475
|
+
updatedAt: now,
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
else {
|
|
1480
|
+
await ctx.db.insert("metaFormLeadHandler", {
|
|
1481
|
+
key: "handler",
|
|
1482
|
+
handle,
|
|
1483
|
+
updatedAt: now,
|
|
1484
|
+
});
|
|
1485
|
+
// The host opts into Lead Ads here: its owners' Pages are read now.
|
|
1486
|
+
await ctx.scheduler.runAfter(0, internal.metaLeads.readOwnersPages, {
|
|
1487
|
+
after: null,
|
|
1488
|
+
});
|
|
1489
|
+
// Leads that waited for a handler come in now, not in 15 minutes.
|
|
1490
|
+
const links = await ctx.db
|
|
1491
|
+
.query("sitePages")
|
|
1492
|
+
.withIndex("by_nextReconcileAt", (range) => range.lt("nextReconcileAt", PAUSED_AT))
|
|
1493
|
+
.take(MAX_LINKS_PER_CONNECTION);
|
|
1494
|
+
let woke = false;
|
|
1495
|
+
for (const link of links) {
|
|
1496
|
+
if (link.runId !== undefined)
|
|
1497
|
+
continue;
|
|
1498
|
+
if (!link.lastError?.startsWith("HANDLER_MISSING"))
|
|
1499
|
+
continue;
|
|
1500
|
+
await ctx.db.patch("sitePages", link._id, {
|
|
1501
|
+
nextReconcileAt: now,
|
|
1502
|
+
updatedAt: now,
|
|
1503
|
+
});
|
|
1504
|
+
woke = true;
|
|
1505
|
+
}
|
|
1506
|
+
if (woke)
|
|
1507
|
+
await kickLeadsLoop(ctx, now);
|
|
1508
|
+
}
|
|
1509
|
+
return null;
|
|
1510
|
+
},
|
|
1511
|
+
});
|
|
1512
|
+
// ---------------------------------------------------------------------------
|
|
1513
|
+
// Ingestion.
|
|
1514
|
+
const ingestStatusValidator = v.union(v.literal("ingested"), v.literal("duplicate"), v.literal("unlinked"), v.literal("handler_missing"), v.literal("paused"));
|
|
1515
|
+
/**
|
|
1516
|
+
* What a failed commit says, safe for a log: a `ConvexError`'s `code` or the
|
|
1517
|
+
* error's class name. Never the message: the host's handler had the lead's
|
|
1518
|
+
* name, phone and e-mail in hand, and its message (or a validator's dump of
|
|
1519
|
+
* the arguments) may carry them.
|
|
1520
|
+
*/
|
|
1521
|
+
export function failureCode(error) {
|
|
1522
|
+
if (error instanceof ConvexError &&
|
|
1523
|
+
isPlainRecord(error.data) &&
|
|
1524
|
+
typeof error.data.code === "string" &&
|
|
1525
|
+
/^[A-Za-z0-9_.:-]{1,60}$/u.test(error.data.code)) {
|
|
1526
|
+
return error.data.code;
|
|
1527
|
+
}
|
|
1528
|
+
if (error instanceof Error &&
|
|
1529
|
+
/^[A-Za-z0-9_]{1,60}$/u.test(error.name) &&
|
|
1530
|
+
error.name !== "Error") {
|
|
1531
|
+
return error.name;
|
|
1532
|
+
}
|
|
1533
|
+
return "HANDLER_ERROR";
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Counts one failed commit of a lead (its ids and the error's code, nothing
|
|
1537
|
+
* else). Returns the attempts so far and whether the lead was already skipped.
|
|
1538
|
+
*/
|
|
1539
|
+
export const recordLeadFailure = internalMutation({
|
|
1540
|
+
args: {
|
|
1541
|
+
leadgenId: v.string(),
|
|
1542
|
+
pageId: v.string(),
|
|
1543
|
+
formId: v.string(),
|
|
1544
|
+
code: v.string(),
|
|
1545
|
+
},
|
|
1546
|
+
returns: v.object({ attempts: v.number(), skipped: v.boolean() }),
|
|
1547
|
+
handler: async (ctx, args) => {
|
|
1548
|
+
const now = Date.now();
|
|
1549
|
+
const row = await ctx.db
|
|
1550
|
+
.query("formLeadFailures")
|
|
1551
|
+
.withIndex("by_leadgenId", (range) => range.eq("leadgenId", args.leadgenId))
|
|
1552
|
+
.first();
|
|
1553
|
+
const code = args.code.slice(0, 60);
|
|
1554
|
+
if (row) {
|
|
1555
|
+
const attempts = row.attempts + 1;
|
|
1556
|
+
await ctx.db.patch("formLeadFailures", row._id, {
|
|
1557
|
+
attempts,
|
|
1558
|
+
lastCode: code,
|
|
1559
|
+
lastAt: now,
|
|
1560
|
+
});
|
|
1561
|
+
return { attempts, skipped: row.skippedAt !== undefined };
|
|
1562
|
+
}
|
|
1563
|
+
await ctx.db.insert("formLeadFailures", {
|
|
1564
|
+
leadgenId: args.leadgenId,
|
|
1565
|
+
pageId: args.pageId,
|
|
1566
|
+
formId: args.formId,
|
|
1567
|
+
attempts: 1,
|
|
1568
|
+
lastCode: code,
|
|
1569
|
+
lastAt: now,
|
|
1570
|
+
});
|
|
1571
|
+
return { attempts: 1, skipped: false };
|
|
1572
|
+
},
|
|
1573
|
+
});
|
|
1574
|
+
/** Marks leads the reconciliation gave up on, so they no longer hold it. */
|
|
1575
|
+
export const skipFailedLeads = internalMutation({
|
|
1576
|
+
args: { leadgenIds: v.array(v.string()) },
|
|
1577
|
+
returns: v.null(),
|
|
1578
|
+
handler: async (ctx, args) => {
|
|
1579
|
+
const now = Date.now();
|
|
1580
|
+
for (const leadgenId of args.leadgenIds.slice(0, MAX_SKIPPED_PER_RUN)) {
|
|
1581
|
+
const row = await ctx.db
|
|
1582
|
+
.query("formLeadFailures")
|
|
1583
|
+
.withIndex("by_leadgenId", (range) => range.eq("leadgenId", leadgenId))
|
|
1584
|
+
.first();
|
|
1585
|
+
if (row && row.skippedAt === undefined) {
|
|
1586
|
+
await ctx.db.patch("formLeadFailures", row._id, { skippedAt: now });
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
return null;
|
|
1590
|
+
},
|
|
1591
|
+
});
|
|
1592
|
+
/**
|
|
1593
|
+
* Commits one lead: the ingestion record, the lead (origin `lead_form`) and
|
|
1594
|
+
* the host's handler call, in one transaction. `contact` and `freeAnswers`
|
|
1595
|
+
* only pass through to the handler. A handler that throws rolls everything
|
|
1596
|
+
* back, so the lead is read again later.
|
|
1597
|
+
*/
|
|
1598
|
+
const commitResultValidator = v.object({
|
|
1599
|
+
status: ingestStatusValidator,
|
|
1600
|
+
leadId: v.union(v.string(), v.null()),
|
|
1601
|
+
});
|
|
1602
|
+
export const commitFormLead = internalMutation({
|
|
1603
|
+
args: {
|
|
1604
|
+
pageId: v.string(),
|
|
1605
|
+
leadgenId: v.string(),
|
|
1606
|
+
receivedAt: v.number(),
|
|
1607
|
+
formId: v.string(),
|
|
1608
|
+
adId: v.union(v.string(), v.null()),
|
|
1609
|
+
adsetId: v.union(v.string(), v.null()),
|
|
1610
|
+
campaignId: v.union(v.string(), v.null()),
|
|
1611
|
+
platform: v.union(v.string(), v.null()),
|
|
1612
|
+
isOrganic: v.boolean(),
|
|
1613
|
+
choices: v.array(formChoiceAnswerValidator),
|
|
1614
|
+
contact: v.object({
|
|
1615
|
+
name: v.union(v.string(), v.null()),
|
|
1616
|
+
phone: v.union(v.string(), v.null()),
|
|
1617
|
+
email: v.union(v.string(), v.null()),
|
|
1618
|
+
}),
|
|
1619
|
+
freeAnswers: v.array(v.object({
|
|
1620
|
+
questionKey: v.string(),
|
|
1621
|
+
label: v.string(),
|
|
1622
|
+
value: v.string(),
|
|
1623
|
+
})),
|
|
1624
|
+
},
|
|
1625
|
+
returns: commitResultValidator,
|
|
1626
|
+
handler: async (ctx, args) => {
|
|
1627
|
+
const seen = await ctx.db
|
|
1628
|
+
.query("formLeads")
|
|
1629
|
+
.withIndex("by_leadgenId", (range) => range.eq("leadgenId", args.leadgenId))
|
|
1630
|
+
.first();
|
|
1631
|
+
if (seen)
|
|
1632
|
+
return { status: "duplicate", leadId: seen.leadId };
|
|
1633
|
+
// Removed by the host: never brought back.
|
|
1634
|
+
if (await isForgottenFormLead(ctx, args.leadgenId)) {
|
|
1635
|
+
return { status: "duplicate", leadId: null };
|
|
1636
|
+
}
|
|
1637
|
+
const link = await linkOfPage(ctx, args.pageId);
|
|
1638
|
+
if (!link)
|
|
1639
|
+
return { status: "unlinked", leadId: null };
|
|
1640
|
+
const handler = await ctx.db
|
|
1641
|
+
.query("metaFormLeadHandler")
|
|
1642
|
+
.withIndex("by_key", (range) => range.eq("key", "handler"))
|
|
1643
|
+
.unique();
|
|
1644
|
+
if (!handler)
|
|
1645
|
+
return { status: "handler_missing", leadId: null };
|
|
1646
|
+
const form = await ctx.db
|
|
1647
|
+
.query("metaForms")
|
|
1648
|
+
.withIndex("by_formId", (range) => range.eq("formId", args.formId))
|
|
1649
|
+
.first();
|
|
1650
|
+
const rules = await rulesOf(ctx, link.siteId, args.formId);
|
|
1651
|
+
const outcome = evaluateRules(rules?.rules ?? [], rules?.entryColumn ?? null, args.choices);
|
|
1652
|
+
const now = Date.now();
|
|
1653
|
+
const receivedAt = Math.min(args.receivedAt, now);
|
|
1654
|
+
const leadKey = `meta_lead:${args.leadgenId}`;
|
|
1655
|
+
const { leadId } = await recordLeadCore(ctx, {
|
|
1656
|
+
siteId: link.siteId,
|
|
1657
|
+
leadKey,
|
|
1658
|
+
channel: "form",
|
|
1659
|
+
entryPoint: LEAD_FORM_ORIGIN,
|
|
1660
|
+
ad: args.adId ? { adId: args.adId } : undefined,
|
|
1661
|
+
column: "column" in outcome.decision && outcome.decision.column !== null
|
|
1662
|
+
? outcome.decision.column
|
|
1663
|
+
: undefined,
|
|
1664
|
+
occurredAt: receivedAt,
|
|
1665
|
+
form: { formId: args.formId, pageId: args.pageId },
|
|
1666
|
+
});
|
|
1667
|
+
const row = {
|
|
1668
|
+
siteId: link.siteId,
|
|
1669
|
+
pageId: args.pageId,
|
|
1670
|
+
formId: args.formId,
|
|
1671
|
+
leadgenId: args.leadgenId,
|
|
1672
|
+
leadId,
|
|
1673
|
+
receivedAt,
|
|
1674
|
+
...(args.adId ? { adId: args.adId } : {}),
|
|
1675
|
+
...(args.adsetId ? { adsetId: args.adsetId } : {}),
|
|
1676
|
+
...(args.campaignId ? { campaignId: args.campaignId } : {}),
|
|
1677
|
+
...(args.platform ? { platform: args.platform } : {}),
|
|
1678
|
+
isOrganic: args.isOrganic,
|
|
1679
|
+
answers: args.choices,
|
|
1680
|
+
decision: outcome.decision,
|
|
1681
|
+
rulePath: outcome.rulePath,
|
|
1682
|
+
...(outcome.ruleIndex === null
|
|
1683
|
+
? {}
|
|
1684
|
+
: {
|
|
1685
|
+
ruleIndex: outcome.ruleIndex,
|
|
1686
|
+
...(rules?.signatures[outcome.ruleIndex]
|
|
1687
|
+
? { ruleSignature: rules.signatures[outcome.ruleIndex] }
|
|
1688
|
+
: {}),
|
|
1689
|
+
}),
|
|
1690
|
+
createdAt: now,
|
|
1691
|
+
};
|
|
1692
|
+
await ctx.db.insert("formLeads", row);
|
|
1693
|
+
const payload = {
|
|
1694
|
+
siteId: link.siteId,
|
|
1695
|
+
leadId,
|
|
1696
|
+
leadKey,
|
|
1697
|
+
leadgenId: args.leadgenId,
|
|
1698
|
+
receivedAt,
|
|
1699
|
+
contact: args.contact,
|
|
1700
|
+
freeAnswers: args.freeAnswers,
|
|
1701
|
+
answers: labelChoices(form?.questions ?? [], args.choices),
|
|
1702
|
+
decision: outcome.decision,
|
|
1703
|
+
rulePath: outcome.rulePath,
|
|
1704
|
+
ruleIndex: outcome.ruleIndex,
|
|
1705
|
+
origin: await formLeadOrigin(ctx, row),
|
|
1706
|
+
firstContact: rules?.firstContact ?? DEFAULT_FIRST_CONTACT,
|
|
1707
|
+
};
|
|
1708
|
+
await ctx.runMutation(handler.handle, payload);
|
|
1709
|
+
await ctx.db.patch("sitePages", link._id, {
|
|
1710
|
+
lastLeadAt: Math.max(link.lastLeadAt ?? 0, receivedAt),
|
|
1711
|
+
});
|
|
1712
|
+
if (form) {
|
|
1713
|
+
const last = form.lastLead?.siteId === link.siteId ? form.lastLead.at : 0;
|
|
1714
|
+
if (receivedAt > last) {
|
|
1715
|
+
await ctx.db.patch("metaForms", form._id, {
|
|
1716
|
+
lastLead: { siteId: link.siteId, at: receivedAt },
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
const failure = await ctx.db
|
|
1721
|
+
.query("formLeadFailures")
|
|
1722
|
+
.withIndex("by_leadgenId", (range) => range.eq("leadgenId", args.leadgenId))
|
|
1723
|
+
.first();
|
|
1724
|
+
if (failure)
|
|
1725
|
+
await ctx.db.delete("formLeadFailures", failure._id);
|
|
1726
|
+
return { status: "ingested", leadId };
|
|
1727
|
+
},
|
|
1728
|
+
});
|
|
1729
|
+
const ingestJobValidator = v.union(v.object({
|
|
1730
|
+
status: v.union(v.literal("duplicate"), v.literal("unlinked"), v.literal("handler_missing"), v.literal("paused")),
|
|
1731
|
+
}), v.object({
|
|
1732
|
+
status: v.literal("ready"),
|
|
1733
|
+
linkId: v.id("sitePages"),
|
|
1734
|
+
pageRowId: v.id("metaPages"),
|
|
1735
|
+
tokenCipher: v.string(),
|
|
1736
|
+
tokenIv: v.string(),
|
|
1737
|
+
}));
|
|
1738
|
+
export const loadIngestJob = internalQuery({
|
|
1739
|
+
args: { pageId: v.string(), leadgenId: v.optional(v.string()) },
|
|
1740
|
+
returns: ingestJobValidator,
|
|
1741
|
+
handler: async (ctx, args) => {
|
|
1742
|
+
if (args.leadgenId !== undefined) {
|
|
1743
|
+
const seen = await ctx.db
|
|
1744
|
+
.query("formLeads")
|
|
1745
|
+
.withIndex("by_leadgenId", (range) => range.eq("leadgenId", args.leadgenId))
|
|
1746
|
+
.first();
|
|
1747
|
+
if (seen || (await isForgottenFormLead(ctx, args.leadgenId))) {
|
|
1748
|
+
return { status: "duplicate" };
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
const link = await linkOfPage(ctx, args.pageId);
|
|
1752
|
+
if (!link)
|
|
1753
|
+
return { status: "unlinked" };
|
|
1754
|
+
if (!(await handlerReady(ctx)))
|
|
1755
|
+
return { status: "handler_missing" };
|
|
1756
|
+
const connection = await ctx.db.get("metaConnections", link.connectionId);
|
|
1757
|
+
const page = await ctx.db
|
|
1758
|
+
.query("metaPages")
|
|
1759
|
+
.withIndex("by_connectionId_and_pageId", (range) => range.eq("connectionId", link.connectionId).eq("pageId", link.pageId))
|
|
1760
|
+
.unique();
|
|
1761
|
+
if (connection?.status !== "active" ||
|
|
1762
|
+
!page ||
|
|
1763
|
+
!page.listed ||
|
|
1764
|
+
!page.leadsAllowed ||
|
|
1765
|
+
page.invalidSince !== undefined ||
|
|
1766
|
+
link.nextReconcileAt === PAUSED_AT) {
|
|
1767
|
+
return { status: "paused" };
|
|
1768
|
+
}
|
|
1769
|
+
return {
|
|
1770
|
+
status: "ready",
|
|
1771
|
+
linkId: link._id,
|
|
1772
|
+
pageRowId: page._id,
|
|
1773
|
+
tokenCipher: page.tokenCipher,
|
|
1774
|
+
tokenIv: page.tokenIv,
|
|
1775
|
+
};
|
|
1776
|
+
},
|
|
1777
|
+
});
|
|
1778
|
+
const formQuestionsValidator = v.union(v.null(), v.object({
|
|
1779
|
+
pageId: v.string(),
|
|
1780
|
+
questions: v.array(metaFormQuestionValidator),
|
|
1781
|
+
}));
|
|
1782
|
+
export const formQuestions = internalQuery({
|
|
1783
|
+
args: { formId: v.string() },
|
|
1784
|
+
returns: formQuestionsValidator,
|
|
1785
|
+
handler: async (ctx, args) => {
|
|
1786
|
+
const form = await ctx.db
|
|
1787
|
+
.query("metaForms")
|
|
1788
|
+
.withIndex("by_formId", (range) => range.eq("formId", args.formId))
|
|
1789
|
+
.first();
|
|
1790
|
+
return form ? { pageId: form.pageId, questions: form.questions } : null;
|
|
1791
|
+
},
|
|
1792
|
+
});
|
|
1793
|
+
/**
|
|
1794
|
+
* One lead, read from Meta, into the host: its answers split (personal ones
|
|
1795
|
+
* kept in memory only), its form's questions read first when unknown, then
|
|
1796
|
+
* `commitFormLead`. Returns what happened; never throws on Meta or on the
|
|
1797
|
+
* handler, so a run goes on with the next lead.
|
|
1798
|
+
*/
|
|
1799
|
+
async function ingestLead(ctx, client, page, lead) {
|
|
1800
|
+
const done = (status) => ({
|
|
1801
|
+
status,
|
|
1802
|
+
attempts: 0,
|
|
1803
|
+
skipped: false,
|
|
1804
|
+
});
|
|
1805
|
+
if (!lead.formId) {
|
|
1806
|
+
console.warn(`[rastro] lead ${lead.leadgenId} names no form; skipped`);
|
|
1807
|
+
return done("skipped");
|
|
1808
|
+
}
|
|
1809
|
+
let form = await ctx.runQuery(internal.metaLeads.formQuestions, {
|
|
1810
|
+
formId: lead.formId,
|
|
1811
|
+
});
|
|
1812
|
+
if (!form) {
|
|
1813
|
+
try {
|
|
1814
|
+
const listed = await client.leadForms(page.token, page.pageId);
|
|
1815
|
+
await ctx.runMutation(internal.metaLeads.saveForms, {
|
|
1816
|
+
pageId: page.pageId,
|
|
1817
|
+
forms: toFormArgs(listed.forms),
|
|
1818
|
+
complete: false,
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
catch (error) {
|
|
1822
|
+
if (!(error instanceof MetaGraphError))
|
|
1823
|
+
throw error;
|
|
1824
|
+
}
|
|
1825
|
+
form = await ctx.runQuery(internal.metaLeads.formQuestions, {
|
|
1826
|
+
formId: lead.formId,
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
if (form && form.pageId !== page.pageId) {
|
|
1830
|
+
console.warn(`[rastro] lead ${lead.leadgenId} belongs to another Page's form; skipped`);
|
|
1831
|
+
return done("skipped");
|
|
1832
|
+
}
|
|
1833
|
+
// Without the form's questions every answer stays in memory: nothing is
|
|
1834
|
+
// known to be a choice.
|
|
1835
|
+
const split = splitLeadFields(form?.questions ?? [], lead.fields);
|
|
1836
|
+
try {
|
|
1837
|
+
const result = await ctx.runMutation(internal.metaLeads.commitFormLead, {
|
|
1838
|
+
pageId: page.pageId,
|
|
1839
|
+
leadgenId: lead.leadgenId,
|
|
1840
|
+
receivedAt: lead.createdTime,
|
|
1841
|
+
formId: lead.formId,
|
|
1842
|
+
adId: lead.adId,
|
|
1843
|
+
adsetId: lead.adsetId,
|
|
1844
|
+
campaignId: lead.campaignId,
|
|
1845
|
+
platform: lead.platform,
|
|
1846
|
+
isOrganic: lead.isOrganic,
|
|
1847
|
+
choices: split.choices,
|
|
1848
|
+
contact: split.contact,
|
|
1849
|
+
freeAnswers: split.freeAnswers,
|
|
1850
|
+
});
|
|
1851
|
+
if (result.status === "handler_missing") {
|
|
1852
|
+
console.warn(`[rastro] form lead ${lead.leadgenId}: ${HANDLER_MISSING}`);
|
|
1853
|
+
}
|
|
1854
|
+
return done(result.status);
|
|
1855
|
+
}
|
|
1856
|
+
catch (error) {
|
|
1857
|
+
// The host's handler threw (or the commit failed): nothing was kept, and
|
|
1858
|
+
// the next reconciliation reads the lead again. Only the error's code is
|
|
1859
|
+
// logged and kept: its message is the host's, and may carry the contact.
|
|
1860
|
+
const code = failureCode(error);
|
|
1861
|
+
console.error(`[rastro] form lead ${lead.leadgenId}: HANDLER_FAILED (${code})`);
|
|
1862
|
+
const counted = await ctx.runMutation(internal.metaLeads.recordLeadFailure, {
|
|
1863
|
+
leadgenId: lead.leadgenId,
|
|
1864
|
+
pageId: page.pageId,
|
|
1865
|
+
formId: lead.formId,
|
|
1866
|
+
code,
|
|
1867
|
+
});
|
|
1868
|
+
return { status: "failed", ...counted };
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Brings one lead in, from Meta's webhook (forwarded by the control plane to
|
|
1873
|
+
* `/rastro/meta/leadgen`). Idempotent on the leadgen id; a lead that cannot
|
|
1874
|
+
* come in now (no handler, a stopped Page) is left for the reconciliation.
|
|
1875
|
+
*/
|
|
1876
|
+
export const ingestFormLead = internalAction({
|
|
1877
|
+
args: { pageId: v.string(), leadgenId: v.string() },
|
|
1878
|
+
returns: v.object({ status: v.string() }),
|
|
1879
|
+
handler: async (ctx, args) => {
|
|
1880
|
+
const job = await ctx.runQuery(internal.metaLeads.loadIngestJob, args);
|
|
1881
|
+
if (job.status !== "ready") {
|
|
1882
|
+
if (job.status === "handler_missing") {
|
|
1883
|
+
console.warn(`[rastro] form lead ${args.leadgenId}: ${HANDLER_MISSING}`);
|
|
1884
|
+
}
|
|
1885
|
+
return { status: job.status };
|
|
1886
|
+
}
|
|
1887
|
+
let token;
|
|
1888
|
+
try {
|
|
1889
|
+
token = await decryptSecret(env.RASTRO_SECRETS_KEY, {
|
|
1890
|
+
cipher: job.tokenCipher,
|
|
1891
|
+
iv: job.tokenIv,
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
catch {
|
|
1895
|
+
console.warn(`[rastro] form lead ${args.leadgenId}: SECRETS_KEY_MISSING; the reconciliation retries`);
|
|
1896
|
+
return { status: "failed" };
|
|
1897
|
+
}
|
|
1898
|
+
const client = graph();
|
|
1899
|
+
let lead;
|
|
1900
|
+
try {
|
|
1901
|
+
lead = await client.lead(token, args.leadgenId);
|
|
1902
|
+
}
|
|
1903
|
+
catch (error) {
|
|
1904
|
+
if (!(error instanceof MetaGraphError))
|
|
1905
|
+
throw error;
|
|
1906
|
+
if (error.kind === "token") {
|
|
1907
|
+
await ctx.runMutation(internal.metaLeads.markPageInvalid, {
|
|
1908
|
+
pageRowId: job.pageRowId,
|
|
1909
|
+
tokenIv: job.tokenIv,
|
|
1910
|
+
message: error.message,
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
console.warn(`[rastro] Meta refused form lead ${args.leadgenId} (${error.kind}): ${error.message}`);
|
|
1914
|
+
return { status: "meta_refused" };
|
|
1915
|
+
}
|
|
1916
|
+
if (lead.leadgenId !== args.leadgenId)
|
|
1917
|
+
return { status: "failed" };
|
|
1918
|
+
const outcome = await ingestLead(ctx, client, { pageId: args.pageId, token }, lead);
|
|
1919
|
+
return { status: outcome.status };
|
|
1920
|
+
},
|
|
1921
|
+
});
|
|
1922
|
+
/**
|
|
1923
|
+
* The webhook's way in (http.ts): schedules the ingestion when the Page feeds
|
|
1924
|
+
* a site here. Answers whether it did.
|
|
1925
|
+
*/
|
|
1926
|
+
export const scheduleLeadgen = internalMutation({
|
|
1927
|
+
args: { pageId: v.string(), leadgenId: v.string() },
|
|
1928
|
+
returns: v.object({ scheduled: v.boolean() }),
|
|
1929
|
+
handler: async (ctx, args) => {
|
|
1930
|
+
if (!(await linkOfPage(ctx, args.pageId)))
|
|
1931
|
+
return { scheduled: false };
|
|
1932
|
+
await ctx.scheduler.runAfter(0, internal.metaLeads.ingestFormLead, args);
|
|
1933
|
+
return { scheduled: true };
|
|
1934
|
+
},
|
|
1935
|
+
});
|
|
1936
|
+
// ---------------------------------------------------------------------------
|
|
1937
|
+
// Reconciliation: one scheduler chain for every linked Page.
|
|
1938
|
+
/** Makes sure the chain runs by `at` (starts it, or pulls it earlier). */
|
|
1939
|
+
export async function kickLeadsLoop(ctx, at) {
|
|
1940
|
+
const state = await ctx.db
|
|
1941
|
+
.query("metaLeadsLoop")
|
|
1942
|
+
.withIndex("by_key", (range) => range.eq("key", "loop"))
|
|
1943
|
+
.unique();
|
|
1944
|
+
if (state) {
|
|
1945
|
+
const job = await ctx.db.system.get("_scheduled_functions", state.jobId);
|
|
1946
|
+
const alive = job?.state.kind === "pending" || job?.state.kind === "inProgress";
|
|
1947
|
+
if (alive && state.scheduledFor <= at)
|
|
1948
|
+
return;
|
|
1949
|
+
if (job?.state.kind === "pending")
|
|
1950
|
+
await ctx.scheduler.cancel(state.jobId);
|
|
1951
|
+
}
|
|
1952
|
+
const jobId = await ctx.scheduler.runAt(at, internal.metaLeads.loop, {});
|
|
1953
|
+
if (state) {
|
|
1954
|
+
await ctx.db.patch("metaLeadsLoop", state._id, { scheduledFor: at, jobId });
|
|
1955
|
+
}
|
|
1956
|
+
else {
|
|
1957
|
+
await ctx.db.insert("metaLeadsLoop", {
|
|
1958
|
+
key: "loop",
|
|
1959
|
+
scheduledFor: at,
|
|
1960
|
+
jobId,
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
/** One link of the chain: hands the due Pages to fresh runs, moves on. */
|
|
1965
|
+
export const loop = internalMutation({
|
|
1966
|
+
args: {},
|
|
1967
|
+
returns: v.null(),
|
|
1968
|
+
handler: async (ctx) => {
|
|
1969
|
+
const now = Date.now();
|
|
1970
|
+
const due = await ctx.db
|
|
1971
|
+
.query("sitePages")
|
|
1972
|
+
.withIndex("by_nextReconcileAt", (range) => range.lte("nextReconcileAt", now))
|
|
1973
|
+
.take(LOOP_BATCH);
|
|
1974
|
+
for (const link of due) {
|
|
1975
|
+
const runId = crypto.randomUUID();
|
|
1976
|
+
await ctx.db.patch("sitePages", link._id, {
|
|
1977
|
+
runId,
|
|
1978
|
+
nextReconcileAt: now + RECONCILE_LEASE_MS,
|
|
1979
|
+
...(link.runId !== undefined
|
|
1980
|
+
? {
|
|
1981
|
+
lastError: "RUN_TIMEOUT: the previous reconciliation did not finish",
|
|
1982
|
+
}
|
|
1983
|
+
: {}),
|
|
1984
|
+
});
|
|
1985
|
+
await ctx.scheduler.runAfter(0, internal.metaLeads.reconcilePage, {
|
|
1986
|
+
linkId: link._id,
|
|
1987
|
+
runId,
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
const state = await ctx.db
|
|
1991
|
+
.query("metaLeadsLoop")
|
|
1992
|
+
.withIndex("by_key", (range) => range.eq("key", "loop"))
|
|
1993
|
+
.unique();
|
|
1994
|
+
const next = await ctx.db
|
|
1995
|
+
.query("sitePages")
|
|
1996
|
+
.withIndex("by_nextReconcileAt", (range) => range.lt("nextReconcileAt", PAUSED_AT))
|
|
1997
|
+
.first();
|
|
1998
|
+
if (!next) {
|
|
1999
|
+
if (state)
|
|
2000
|
+
await ctx.db.delete("metaLeadsLoop", state._id);
|
|
2001
|
+
return null;
|
|
2002
|
+
}
|
|
2003
|
+
const at = Math.max(next.nextReconcileAt, now + (due.length ? LOOP_STAGGER_MS : 0));
|
|
2004
|
+
const jobId = await ctx.scheduler.runAt(at, internal.metaLeads.loop, {});
|
|
2005
|
+
if (state) {
|
|
2006
|
+
await ctx.db.patch("metaLeadsLoop", state._id, {
|
|
2007
|
+
scheduledFor: at,
|
|
2008
|
+
jobId,
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
else {
|
|
2012
|
+
await ctx.db.insert("metaLeadsLoop", {
|
|
2013
|
+
key: "loop",
|
|
2014
|
+
scheduledFor: at,
|
|
2015
|
+
jobId,
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
return null;
|
|
2019
|
+
},
|
|
2020
|
+
});
|
|
2021
|
+
const runArgs = { linkId: v.id("sitePages"), runId: v.string() };
|
|
2022
|
+
const reconcileJobValidator = v.union(v.null(), v.object({
|
|
2023
|
+
pageId: v.string(),
|
|
2024
|
+
reconcileFrom: v.number(),
|
|
2025
|
+
connectionActive: v.boolean(),
|
|
2026
|
+
handlerReady: v.boolean(),
|
|
2027
|
+
/** The app's subscription to the Page is to be asserted again. */
|
|
2028
|
+
subscriptionDue: v.boolean(),
|
|
2029
|
+
page: v.union(v.null(), v.object({
|
|
2030
|
+
pageRowId: v.id("metaPages"),
|
|
2031
|
+
tokenCipher: v.string(),
|
|
2032
|
+
tokenIv: v.string(),
|
|
2033
|
+
listed: v.boolean(),
|
|
2034
|
+
leadsAllowed: v.boolean(),
|
|
2035
|
+
invalid: v.boolean(),
|
|
2036
|
+
})),
|
|
2037
|
+
}));
|
|
2038
|
+
export const loadReconcileJob = internalQuery({
|
|
2039
|
+
args: runArgs,
|
|
2040
|
+
returns: reconcileJobValidator,
|
|
2041
|
+
handler: async (ctx, args) => {
|
|
2042
|
+
const link = await ctx.db.get("sitePages", args.linkId);
|
|
2043
|
+
if (!link || link.runId !== args.runId)
|
|
2044
|
+
return null;
|
|
2045
|
+
const connection = await ctx.db.get("metaConnections", link.connectionId);
|
|
2046
|
+
const page = await ctx.db
|
|
2047
|
+
.query("metaPages")
|
|
2048
|
+
.withIndex("by_connectionId_and_pageId", (range) => range.eq("connectionId", link.connectionId).eq("pageId", link.pageId))
|
|
2049
|
+
.unique();
|
|
2050
|
+
return {
|
|
2051
|
+
pageId: link.pageId,
|
|
2052
|
+
reconcileFrom: link.reconcileFrom,
|
|
2053
|
+
connectionActive: connection?.status === "active",
|
|
2054
|
+
handlerReady: await handlerReady(ctx),
|
|
2055
|
+
subscriptionDue: Date.now() - (link.subscriptionCheckedAt ?? link.subscribedAt ?? 0) >=
|
|
2056
|
+
SUBSCRIPTION_CHECK_MS,
|
|
2057
|
+
page: page
|
|
2058
|
+
? {
|
|
2059
|
+
pageRowId: page._id,
|
|
2060
|
+
tokenCipher: page.tokenCipher,
|
|
2061
|
+
tokenIv: page.tokenIv,
|
|
2062
|
+
listed: page.listed,
|
|
2063
|
+
leadsAllowed: page.leadsAllowed,
|
|
2064
|
+
invalid: page.invalidSince !== undefined,
|
|
2065
|
+
}
|
|
2066
|
+
: null,
|
|
2067
|
+
};
|
|
2068
|
+
},
|
|
2069
|
+
});
|
|
2070
|
+
export const markSubscriptionChecked = internalMutation({
|
|
2071
|
+
args: { ...runArgs, at: v.number() },
|
|
2072
|
+
returns: v.null(),
|
|
2073
|
+
handler: async (ctx, args) => {
|
|
2074
|
+
const link = await ctx.db.get("sitePages", args.linkId);
|
|
2075
|
+
if (!link || link.runId !== args.runId)
|
|
2076
|
+
return null;
|
|
2077
|
+
await ctx.db.patch("sitePages", link._id, {
|
|
2078
|
+
subscriptionCheckedAt: args.at,
|
|
2079
|
+
});
|
|
2080
|
+
return null;
|
|
2081
|
+
},
|
|
2082
|
+
});
|
|
2083
|
+
/** The Page's active forms with where the run reads each from (seconds). */
|
|
2084
|
+
export const reconcileForms = internalQuery({
|
|
2085
|
+
args: runArgs,
|
|
2086
|
+
returns: v.array(v.object({ formId: v.string(), sinceSec: v.number() })),
|
|
2087
|
+
handler: async (ctx, args) => {
|
|
2088
|
+
const link = await ctx.db.get("sitePages", args.linkId);
|
|
2089
|
+
if (!link || link.runId !== args.runId)
|
|
2090
|
+
return [];
|
|
2091
|
+
const rows = await ctx.db
|
|
2092
|
+
.query("metaForms")
|
|
2093
|
+
.withIndex("by_pageId_and_status", (range) => range.eq("pageId", link.pageId).eq("status", "ACTIVE"))
|
|
2094
|
+
.take(MAX_FORMS_PER_PAGE);
|
|
2095
|
+
const from = Math.floor(link.reconcileFrom / 1000);
|
|
2096
|
+
return rows.map((form) => ({
|
|
2097
|
+
formId: form.formId,
|
|
2098
|
+
sinceSec: form.cursor?.linkId === link._id
|
|
2099
|
+
? Math.max(form.cursor.sec, from)
|
|
2100
|
+
: from,
|
|
2101
|
+
}));
|
|
2102
|
+
},
|
|
2103
|
+
});
|
|
2104
|
+
export const saveCursor = internalMutation({
|
|
2105
|
+
args: { ...runArgs, formId: v.string(), sec: v.number() },
|
|
2106
|
+
returns: v.object({ stop: v.boolean() }),
|
|
2107
|
+
handler: async (ctx, args) => {
|
|
2108
|
+
const link = await ctx.db.get("sitePages", args.linkId);
|
|
2109
|
+
if (!link || link.runId !== args.runId)
|
|
2110
|
+
return { stop: true };
|
|
2111
|
+
const form = await ctx.db
|
|
2112
|
+
.query("metaForms")
|
|
2113
|
+
.withIndex("by_formId", (range) => range.eq("formId", args.formId))
|
|
2114
|
+
.first();
|
|
2115
|
+
if (!form || form.pageId !== link.pageId)
|
|
2116
|
+
return { stop: false };
|
|
2117
|
+
const current = form.cursor?.linkId === link._id ? form.cursor.sec : 0;
|
|
2118
|
+
if (args.sec > current) {
|
|
2119
|
+
await ctx.db.patch("metaForms", form._id, {
|
|
2120
|
+
cursor: { linkId: link._id, sec: args.sec },
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
return { stop: false };
|
|
2124
|
+
},
|
|
2125
|
+
});
|
|
2126
|
+
const reconcileOutcomeValidator = v.union(v.object({
|
|
2127
|
+
kind: v.literal("ok"),
|
|
2128
|
+
usagePct: v.number(),
|
|
2129
|
+
regainMs: v.union(v.number(), v.null()),
|
|
2130
|
+
/** Something worth showing that did not stop the run. */
|
|
2131
|
+
note: v.union(v.string(), v.null()),
|
|
2132
|
+
}), v.object({
|
|
2133
|
+
kind: v.union(v.literal("token"), v.literal("connection"), v.literal("granted"), v.literal("throttled"), v.literal("permission"), v.literal("other"), v.literal("key")),
|
|
2134
|
+
message: v.string(),
|
|
2135
|
+
regainMs: v.union(v.number(), v.null()),
|
|
2136
|
+
}));
|
|
2137
|
+
/** Records how a run ended and when the Page is read next. */
|
|
2138
|
+
export const finishReconcile = internalMutation({
|
|
2139
|
+
args: {
|
|
2140
|
+
...runArgs,
|
|
2141
|
+
startedAt: v.number(),
|
|
2142
|
+
outcome: reconcileOutcomeValidator,
|
|
2143
|
+
},
|
|
2144
|
+
returns: v.null(),
|
|
2145
|
+
handler: async (ctx, args) => {
|
|
2146
|
+
const link = await ctx.db.get("sitePages", args.linkId);
|
|
2147
|
+
if (!link || link.runId !== args.runId)
|
|
2148
|
+
return null;
|
|
2149
|
+
const now = Date.now();
|
|
2150
|
+
const { outcome } = args;
|
|
2151
|
+
if (outcome.kind === "ok") {
|
|
2152
|
+
const busy = outcome.usagePct > USAGE_BACKOFF_PCT;
|
|
2153
|
+
const next = now +
|
|
2154
|
+
(busy
|
|
2155
|
+
? Math.max(outcome.regainMs ?? 0, THROTTLE_BACKOFF_MS)
|
|
2156
|
+
: RECONCILE_INTERVAL_MS);
|
|
2157
|
+
await ctx.db.patch("sitePages", link._id, {
|
|
2158
|
+
runId: undefined,
|
|
2159
|
+
failures: 0,
|
|
2160
|
+
lastReconcileAt: args.startedAt,
|
|
2161
|
+
lastError: outcome.note ?? (busy ? "META_RATE_LIMITED: backing off" : undefined),
|
|
2162
|
+
nextReconcileAt: next,
|
|
2163
|
+
updatedAt: now,
|
|
2164
|
+
});
|
|
2165
|
+
await kickLeadsLoop(ctx, next);
|
|
2166
|
+
return null;
|
|
2167
|
+
}
|
|
2168
|
+
const message = outcome.message.slice(0, MAX_ERROR);
|
|
2169
|
+
if (outcome.kind === "token" ||
|
|
2170
|
+
outcome.kind === "connection" ||
|
|
2171
|
+
outcome.kind === "granted") {
|
|
2172
|
+
// Stopped until a login lists the Page with a working token again.
|
|
2173
|
+
await pauseLink(ctx, link, message, now);
|
|
2174
|
+
return null;
|
|
2175
|
+
}
|
|
2176
|
+
const failures = (link.failures ?? 0) + 1;
|
|
2177
|
+
const delay = outcome.kind === "throttled"
|
|
2178
|
+
? Math.max(outcome.regainMs ?? 0, THROTTLE_BACKOFF_MS)
|
|
2179
|
+
: outcome.kind === "permission"
|
|
2180
|
+
? PERMISSION_RETRY_MS
|
|
2181
|
+
: retryDelay(failures);
|
|
2182
|
+
const code = outcome.kind === "throttled"
|
|
2183
|
+
? "META_RATE_LIMITED"
|
|
2184
|
+
: outcome.kind === "key"
|
|
2185
|
+
? "SECRETS_KEY_MISSING"
|
|
2186
|
+
: outcome.kind === "permission"
|
|
2187
|
+
? "META_PERMISSION_MISSING"
|
|
2188
|
+
: "META_UNAVAILABLE";
|
|
2189
|
+
const next = now + delay;
|
|
2190
|
+
await ctx.db.patch("sitePages", link._id, {
|
|
2191
|
+
runId: undefined,
|
|
2192
|
+
failures,
|
|
2193
|
+
lastError: `${code}: ${message}`.slice(0, MAX_ERROR),
|
|
2194
|
+
nextReconcileAt: next,
|
|
2195
|
+
updatedAt: now,
|
|
2196
|
+
});
|
|
2197
|
+
await kickLeadsLoop(ctx, next);
|
|
2198
|
+
return null;
|
|
2199
|
+
},
|
|
2200
|
+
});
|
|
2201
|
+
/**
|
|
2202
|
+
* One form's leads since its cursor, in windows Meta lists completely.
|
|
2203
|
+
*
|
|
2204
|
+
* Meta lists a form's leads newest first and the client stops at its cap, so
|
|
2205
|
+
* a capped listing (`complete: false`) holds the window's newest leads and
|
|
2206
|
+
* lacks its oldest: nothing of it is trusted, the window is halved and read
|
|
2207
|
+
* again. A complete window is ingested oldest first; the cursor then moves
|
|
2208
|
+
* to its end (to the last lead's second for the window that reaches now, so
|
|
2209
|
+
* a lead Meta lists late in that second is still read). The cursor never
|
|
2210
|
+
* moves over a lead that failed and was not given up on: the leads after it
|
|
2211
|
+
* are still ingested, and skipped by their leadgen id when read again.
|
|
2212
|
+
*/
|
|
2213
|
+
async function reconcileForm(ctx, read) {
|
|
2214
|
+
let since = read.sinceSec;
|
|
2215
|
+
// How many seconds the next listing spans; null reads up to now.
|
|
2216
|
+
let width = null;
|
|
2217
|
+
// A note of an earlier window of this run (a lead given up on).
|
|
2218
|
+
let shown = null;
|
|
2219
|
+
for (let fetches = 0; fetches < MAX_FORM_FETCHES; fetches += 1) {
|
|
2220
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
2221
|
+
const until = width !== null && since + width <= nowSec ? since + width : null;
|
|
2222
|
+
// `> since - 1`: a lead in the cursor's own second is read again, and
|
|
2223
|
+
// skipped by its leadgen id if it is in.
|
|
2224
|
+
const result = await read.client.formLeads(read.token, read.formId, since - 1, until ?? undefined);
|
|
2225
|
+
read.track(result.usage);
|
|
2226
|
+
if (!result.complete) {
|
|
2227
|
+
const span = (until ?? nowSec + 1) - since;
|
|
2228
|
+
if (span <= 1) {
|
|
2229
|
+
return {
|
|
2230
|
+
kind: "done",
|
|
2231
|
+
note: "META_TRUNCATED: Meta listed more leads in one second than one read holds; the cursor holds",
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
width = Math.max(1, Math.floor(span / 2));
|
|
2235
|
+
continue;
|
|
2236
|
+
}
|
|
2237
|
+
const leads = result.leads
|
|
2238
|
+
.filter((lead) => lead.createdTime >= read.reconcileFrom &&
|
|
2239
|
+
(until === null || lead.createdTime < until * 1000))
|
|
2240
|
+
.sort((left, right) => left.createdTime - right.createdTime);
|
|
2241
|
+
const outcomes = [];
|
|
2242
|
+
let handlerMissing = false;
|
|
2243
|
+
for (const lead of leads) {
|
|
2244
|
+
const outcome = await ingestLead(ctx, read.client, { pageId: read.pageId, token: read.token }, lead.formId ? lead : { ...lead, formId: read.formId });
|
|
2245
|
+
if (outcome.status === "unlinked")
|
|
2246
|
+
return { kind: "unlinked" };
|
|
2247
|
+
if (outcome.status === "handler_missing") {
|
|
2248
|
+
handlerMissing = true;
|
|
2249
|
+
break;
|
|
2250
|
+
}
|
|
2251
|
+
outcomes.push({
|
|
2252
|
+
leadgenId: lead.leadgenId,
|
|
2253
|
+
sec: Math.floor(lead.createdTime / 1000),
|
|
2254
|
+
outcome,
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
// A failing lead with a later one of its form in (now or before) is the
|
|
2258
|
+
// lead's problem, not an outage: after MAX_LEAD_ATTEMPTS it is given up
|
|
2259
|
+
// on. While the later leads fail too (the host's CRM is down), every one
|
|
2260
|
+
// is held, however long it takes.
|
|
2261
|
+
let lastIn = -1;
|
|
2262
|
+
outcomes.forEach((row, index) => {
|
|
2263
|
+
if (row.outcome.status === "ingested" ||
|
|
2264
|
+
row.outcome.status === "duplicate")
|
|
2265
|
+
lastIn = index;
|
|
2266
|
+
});
|
|
2267
|
+
let cursor = since;
|
|
2268
|
+
let held = null;
|
|
2269
|
+
const givenUp = [];
|
|
2270
|
+
for (const [index, row] of outcomes.entries()) {
|
|
2271
|
+
const { status, attempts, skipped } = row.outcome;
|
|
2272
|
+
if (status === "failed") {
|
|
2273
|
+
if (skipped || (lastIn > index && attempts >= MAX_LEAD_ATTEMPTS)) {
|
|
2274
|
+
if (!skipped)
|
|
2275
|
+
givenUp.push(row.leadgenId);
|
|
2276
|
+
cursor = Math.max(cursor, row.sec);
|
|
2277
|
+
continue;
|
|
2278
|
+
}
|
|
2279
|
+
held = { attempts };
|
|
2280
|
+
break;
|
|
2281
|
+
}
|
|
2282
|
+
cursor = Math.max(cursor, row.sec);
|
|
2283
|
+
}
|
|
2284
|
+
let note = null;
|
|
2285
|
+
if (givenUp.length > 0) {
|
|
2286
|
+
await ctx.runMutation(internal.metaLeads.skipFailedLeads, {
|
|
2287
|
+
leadgenIds: givenUp,
|
|
2288
|
+
});
|
|
2289
|
+
for (const leadgenId of givenUp) {
|
|
2290
|
+
console.error(`[rastro] form lead ${leadgenId} of form ${read.formId}: skipped after ${MAX_LEAD_ATTEMPTS} failed attempts (formLeadFailures)`);
|
|
2291
|
+
}
|
|
2292
|
+
note = `HANDLER_GAVE_UP: ${givenUp.length} form lead(s) skipped after ${MAX_LEAD_ATTEMPTS} failed attempts (first: ${givenUp[0]})`;
|
|
2293
|
+
}
|
|
2294
|
+
const stopped = handlerMissing || held !== null;
|
|
2295
|
+
if (handlerMissing)
|
|
2296
|
+
note = HANDLER_MISSING;
|
|
2297
|
+
else if (held !== null) {
|
|
2298
|
+
note = `HANDLER_FAILED: a form lead did not come in (${held.attempts} attempt(s)); it is read again next run`;
|
|
2299
|
+
}
|
|
2300
|
+
const next = stopped || until === null ? cursor : until;
|
|
2301
|
+
if (next > read.sinceSec) {
|
|
2302
|
+
const saved = await ctx.runMutation(internal.metaLeads.saveCursor, { ...read.run, formId: read.formId, sec: next });
|
|
2303
|
+
if (saved.stop)
|
|
2304
|
+
return { kind: "stop" };
|
|
2305
|
+
}
|
|
2306
|
+
if (stopped || until === null)
|
|
2307
|
+
return { kind: "done", note: note ?? shown };
|
|
2308
|
+
// Caught up to the window's end: the next one from there, wider.
|
|
2309
|
+
since = until;
|
|
2310
|
+
width = Math.max(1, (width ?? 1) * 2);
|
|
2311
|
+
// Keep reading, but remember what to show.
|
|
2312
|
+
if (note !== null)
|
|
2313
|
+
shown = note;
|
|
2314
|
+
}
|
|
2315
|
+
return {
|
|
2316
|
+
kind: "done",
|
|
2317
|
+
note: shown ??
|
|
2318
|
+
"META_TRUNCATED: a form has more leads than one run reads; the next run goes on from here",
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* One reconciliation run of a Page: its forms read again (new ones show up
|
|
2323
|
+
* by themselves), then each active form's leads since its cursor, oldest
|
|
2324
|
+
* first, in windows Meta lists completely (`reconcileForm`), through the
|
|
2325
|
+
* same ingestion as the webhook. A lead that cannot come in now (the handler
|
|
2326
|
+
* threw, or is missing) holds its form's cursor there, so it is read again
|
|
2327
|
+
* next run, until it is given up on (MAX_LEAD_ATTEMPTS); leads already in
|
|
2328
|
+
* are skipped by their leadgen id.
|
|
2329
|
+
*/
|
|
2330
|
+
export const reconcilePage = internalAction({
|
|
2331
|
+
args: runArgs,
|
|
2332
|
+
returns: v.null(),
|
|
2333
|
+
handler: async (ctx, args) => {
|
|
2334
|
+
const startedAt = Date.now();
|
|
2335
|
+
const job = await ctx.runQuery(internal.metaLeads.loadReconcileJob, args);
|
|
2336
|
+
if (!job)
|
|
2337
|
+
return null;
|
|
2338
|
+
const finish = (outcome) => ctx.runMutation(internal.metaLeads.finishReconcile, {
|
|
2339
|
+
...args,
|
|
2340
|
+
startedAt,
|
|
2341
|
+
outcome,
|
|
2342
|
+
});
|
|
2343
|
+
if (!job.connectionActive) {
|
|
2344
|
+
await finish({
|
|
2345
|
+
kind: "connection",
|
|
2346
|
+
message: "META_TOKEN_INVALID: the Meta connection's token was refused; connect again",
|
|
2347
|
+
regainMs: null,
|
|
2348
|
+
});
|
|
2349
|
+
return null;
|
|
2350
|
+
}
|
|
2351
|
+
if (!job.page || !job.page.listed) {
|
|
2352
|
+
await finish({
|
|
2353
|
+
kind: "granted",
|
|
2354
|
+
message: PAGE_NOT_GRANTED,
|
|
2355
|
+
regainMs: null,
|
|
2356
|
+
});
|
|
2357
|
+
return null;
|
|
2358
|
+
}
|
|
2359
|
+
if (job.page.invalid) {
|
|
2360
|
+
await finish({
|
|
2361
|
+
kind: "token",
|
|
2362
|
+
message: "META_TOKEN_INVALID: Meta refused the Page token; connect again",
|
|
2363
|
+
regainMs: null,
|
|
2364
|
+
});
|
|
2365
|
+
return null;
|
|
2366
|
+
}
|
|
2367
|
+
if (!job.page.leadsAllowed) {
|
|
2368
|
+
await finish({
|
|
2369
|
+
kind: "granted",
|
|
2370
|
+
message: LEADS_NOT_ALLOWED,
|
|
2371
|
+
regainMs: null,
|
|
2372
|
+
});
|
|
2373
|
+
return null;
|
|
2374
|
+
}
|
|
2375
|
+
let token;
|
|
2376
|
+
try {
|
|
2377
|
+
token = await decryptSecret(env.RASTRO_SECRETS_KEY, {
|
|
2378
|
+
cipher: job.page.tokenCipher,
|
|
2379
|
+
iv: job.page.tokenIv,
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
catch {
|
|
2383
|
+
await finish({
|
|
2384
|
+
kind: "key",
|
|
2385
|
+
message: "RASTRO_SECRETS_KEY is missing or not the one the Page token was sealed with",
|
|
2386
|
+
regainMs: null,
|
|
2387
|
+
});
|
|
2388
|
+
return null;
|
|
2389
|
+
}
|
|
2390
|
+
const client = graph();
|
|
2391
|
+
let usage = { pct: 0, regainMs: null };
|
|
2392
|
+
const track = (next) => {
|
|
2393
|
+
usage = {
|
|
2394
|
+
pct: Math.max(usage.pct, next.pct),
|
|
2395
|
+
regainMs: Math.max(usage.regainMs ?? 0, next.regainMs ?? 0) || null,
|
|
2396
|
+
};
|
|
2397
|
+
};
|
|
2398
|
+
let note = null;
|
|
2399
|
+
try {
|
|
2400
|
+
if (job.subscriptionDue) {
|
|
2401
|
+
// Idempotent; a refusal is handled below like any other Graph one.
|
|
2402
|
+
await client.subscribePage(token, job.pageId);
|
|
2403
|
+
await ctx.runMutation(internal.metaLeads.markSubscriptionChecked, {
|
|
2404
|
+
...args,
|
|
2405
|
+
at: Date.now(),
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
const listed = await client.leadForms(token, job.pageId);
|
|
2409
|
+
track(listed.usage);
|
|
2410
|
+
await ctx.runMutation(internal.metaLeads.saveForms, {
|
|
2411
|
+
pageId: job.pageId,
|
|
2412
|
+
forms: toFormArgs(listed.forms),
|
|
2413
|
+
complete: listed.complete,
|
|
2414
|
+
});
|
|
2415
|
+
if (!job.handlerReady) {
|
|
2416
|
+
// Nothing can come in without the host's handler: Meta is not asked
|
|
2417
|
+
// for leads until one is registered.
|
|
2418
|
+
await finish({
|
|
2419
|
+
kind: "ok",
|
|
2420
|
+
usagePct: usage.pct,
|
|
2421
|
+
regainMs: usage.regainMs,
|
|
2422
|
+
note: HANDLER_MISSING,
|
|
2423
|
+
});
|
|
2424
|
+
return null;
|
|
2425
|
+
}
|
|
2426
|
+
const targets = await ctx.runQuery(internal.metaLeads.reconcileForms, args);
|
|
2427
|
+
for (const target of targets) {
|
|
2428
|
+
const read = await reconcileForm(ctx, {
|
|
2429
|
+
run: args,
|
|
2430
|
+
client,
|
|
2431
|
+
token,
|
|
2432
|
+
pageId: job.pageId,
|
|
2433
|
+
reconcileFrom: job.reconcileFrom,
|
|
2434
|
+
formId: target.formId,
|
|
2435
|
+
sinceSec: target.sinceSec,
|
|
2436
|
+
track,
|
|
2437
|
+
});
|
|
2438
|
+
if (read.kind === "unlinked") {
|
|
2439
|
+
await finish({
|
|
2440
|
+
kind: "ok",
|
|
2441
|
+
usagePct: usage.pct,
|
|
2442
|
+
regainMs: null,
|
|
2443
|
+
note: null,
|
|
2444
|
+
});
|
|
2445
|
+
return null;
|
|
2446
|
+
}
|
|
2447
|
+
if (read.kind === "stop")
|
|
2448
|
+
return null;
|
|
2449
|
+
if (read.note !== null)
|
|
2450
|
+
note = read.note;
|
|
2451
|
+
}
|
|
2452
|
+
await finish({
|
|
2453
|
+
kind: "ok",
|
|
2454
|
+
usagePct: usage.pct,
|
|
2455
|
+
regainMs: usage.regainMs,
|
|
2456
|
+
note,
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
catch (error) {
|
|
2460
|
+
if (error instanceof MetaGraphError) {
|
|
2461
|
+
if (error.kind === "token") {
|
|
2462
|
+
await ctx.runMutation(internal.metaLeads.markPageInvalid, {
|
|
2463
|
+
pageRowId: job.page.pageRowId,
|
|
2464
|
+
tokenIv: job.page.tokenIv,
|
|
2465
|
+
message: error.message,
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
await finish({
|
|
2469
|
+
kind: error.kind === "app" ? "other" : error.kind,
|
|
2470
|
+
message: error.kind === "token"
|
|
2471
|
+
? `META_TOKEN_INVALID: ${error.message}`
|
|
2472
|
+
: error.message,
|
|
2473
|
+
regainMs: error.regainMs ?? usage.regainMs,
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
else {
|
|
2477
|
+
await finish({
|
|
2478
|
+
kind: "other",
|
|
2479
|
+
message: error instanceof Error ? error.message : "reconciliation failed",
|
|
2480
|
+
regainMs: null,
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
return null;
|
|
2485
|
+
},
|
|
2486
|
+
});
|
|
2487
|
+
// ---------------------------------------------------------------------------
|
|
2488
|
+
// Report.
|
|
2489
|
+
function validateSites(siteIds) {
|
|
2490
|
+
const unique = [...new Set(siteIds)];
|
|
2491
|
+
if (unique.length < 1 ||
|
|
2492
|
+
unique.length > MAX_REPORT_SITES ||
|
|
2493
|
+
unique.length !== siteIds.length) {
|
|
2494
|
+
fail("INVALID_ARGUMENT", `siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`);
|
|
2495
|
+
}
|
|
2496
|
+
return unique;
|
|
2497
|
+
}
|
|
2498
|
+
/**
|
|
2499
|
+
* Per form, over the cohort days in the range: its leads, qualified, sales
|
|
2500
|
+
* and revenue (from the lead rollups), and where its rules sent the leads
|
|
2501
|
+
* (columns, the host's entry, discards by reason, the ones sent back).
|
|
2502
|
+
*/
|
|
2503
|
+
export const formsReport = query({
|
|
2504
|
+
args: { siteIds: v.array(v.id("sites")), from: v.number(), to: v.number() },
|
|
2505
|
+
returns: formsReportValidator,
|
|
2506
|
+
handler: async (ctx, args) => {
|
|
2507
|
+
const siteIds = validateSites(args.siteIds);
|
|
2508
|
+
if (!Number.isSafeInteger(args.from) ||
|
|
2509
|
+
!Number.isSafeInteger(args.to) ||
|
|
2510
|
+
args.from < 0 ||
|
|
2511
|
+
args.to < args.from ||
|
|
2512
|
+
args.to - args.from >= MAX_REPORT_RANGE_DAYS * DAY_MS) {
|
|
2513
|
+
fail("INVALID_ARGUMENT", `range must use inclusive integer milliseconds and span at most ${MAX_REPORT_RANGE_DAYS} UTC days`);
|
|
2514
|
+
}
|
|
2515
|
+
const first = dayOf(args.from);
|
|
2516
|
+
let budget = MAX_LEAD_REPORT_ROWS;
|
|
2517
|
+
let partial = false;
|
|
2518
|
+
const entries = new Map();
|
|
2519
|
+
const entryOf = (siteId, formId, pageId) => {
|
|
2520
|
+
const key = `${siteId}:${formId}`;
|
|
2521
|
+
let entry = entries.get(key);
|
|
2522
|
+
if (!entry) {
|
|
2523
|
+
entry = {
|
|
2524
|
+
siteId,
|
|
2525
|
+
pageId,
|
|
2526
|
+
formId,
|
|
2527
|
+
leads: 0,
|
|
2528
|
+
qualified: 0,
|
|
2529
|
+
won: 0,
|
|
2530
|
+
revenueCents: 0,
|
|
2531
|
+
columns: new Map(),
|
|
2532
|
+
entry: 0,
|
|
2533
|
+
discarded: new Map(),
|
|
2534
|
+
returned: 0,
|
|
2535
|
+
};
|
|
2536
|
+
entries.set(key, entry);
|
|
2537
|
+
}
|
|
2538
|
+
return entry;
|
|
2539
|
+
};
|
|
2540
|
+
for (const siteId of siteIds) {
|
|
2541
|
+
await requireSite(ctx, siteId);
|
|
2542
|
+
const rollups = await ctx.db
|
|
2543
|
+
.query("leadDailyRollups")
|
|
2544
|
+
.withIndex("by_siteId_and_dimension_and_dayStart_and_key", (range) => range
|
|
2545
|
+
.eq("siteId", siteId)
|
|
2546
|
+
.eq("dimension", "form")
|
|
2547
|
+
.gte("dayStart", first)
|
|
2548
|
+
.lte("dayStart", args.to))
|
|
2549
|
+
.take(budget + 1);
|
|
2550
|
+
if (rollups.length > budget) {
|
|
2551
|
+
fail("REPORT_INCOMPLETE", "forms report exceeds its row budget", {
|
|
2552
|
+
maximumRows: MAX_LEAD_REPORT_ROWS,
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
budget -= rollups.length;
|
|
2556
|
+
const rows = await ctx.db
|
|
2557
|
+
.query("formLeads")
|
|
2558
|
+
.withIndex("by_siteId_and_receivedAt", (range) => range
|
|
2559
|
+
.eq("siteId", siteId)
|
|
2560
|
+
.gte("receivedAt", first)
|
|
2561
|
+
.lte("receivedAt", args.to))
|
|
2562
|
+
.take(budget + 1);
|
|
2563
|
+
if (rows.length > budget)
|
|
2564
|
+
partial = true;
|
|
2565
|
+
const counted = rows.slice(0, budget);
|
|
2566
|
+
budget -= counted.length;
|
|
2567
|
+
for (const row of counted) {
|
|
2568
|
+
const entry = entryOf(siteId, row.formId, row.pageId);
|
|
2569
|
+
if (row.returned)
|
|
2570
|
+
entry.returned += 1;
|
|
2571
|
+
if ("discard" in row.decision) {
|
|
2572
|
+
const reason = row.decision.discard;
|
|
2573
|
+
entry.discarded.set(reason, (entry.discarded.get(reason) ?? 0) + 1);
|
|
2574
|
+
}
|
|
2575
|
+
else if (row.decision.column === null) {
|
|
2576
|
+
entry.entry += 1;
|
|
2577
|
+
}
|
|
2578
|
+
else {
|
|
2579
|
+
const column = row.decision.column;
|
|
2580
|
+
entry.columns.set(column, (entry.columns.get(column) ?? 0) + 1);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
for (const row of rollups) {
|
|
2584
|
+
const existing = entries.get(`${siteId}:${row.key}`);
|
|
2585
|
+
const entry = existing ??
|
|
2586
|
+
entryOf(siteId, row.key, (await ctx.db
|
|
2587
|
+
.query("metaForms")
|
|
2588
|
+
.withIndex("by_formId", (range) => range.eq("formId", row.key))
|
|
2589
|
+
.first())?.pageId ?? "");
|
|
2590
|
+
entry.leads += row.leads;
|
|
2591
|
+
entry.qualified += row.qualified;
|
|
2592
|
+
entry.won += row.won;
|
|
2593
|
+
entry.revenueCents += row.revenueCents;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
const forms = [];
|
|
2597
|
+
for (const entry of entries.values()) {
|
|
2598
|
+
// Removed leads leave zeroed rollups and no decisions behind.
|
|
2599
|
+
if (entry.leads === 0 &&
|
|
2600
|
+
entry.columns.size === 0 &&
|
|
2601
|
+
entry.discarded.size === 0 &&
|
|
2602
|
+
entry.entry === 0) {
|
|
2603
|
+
continue;
|
|
2604
|
+
}
|
|
2605
|
+
const form = await ctx.db
|
|
2606
|
+
.query("metaForms")
|
|
2607
|
+
.withIndex("by_formId", (range) => range.eq("formId", entry.formId))
|
|
2608
|
+
.first();
|
|
2609
|
+
forms.push({
|
|
2610
|
+
siteId: entry.siteId,
|
|
2611
|
+
pageId: entry.pageId || form?.pageId || "",
|
|
2612
|
+
formId: entry.formId,
|
|
2613
|
+
name: form?.name ?? null,
|
|
2614
|
+
leads: entry.leads,
|
|
2615
|
+
qualified: entry.qualified,
|
|
2616
|
+
won: entry.won,
|
|
2617
|
+
revenueCents: entry.revenueCents,
|
|
2618
|
+
columns: [...entry.columns].map(([column, leads]) => ({
|
|
2619
|
+
column,
|
|
2620
|
+
leads,
|
|
2621
|
+
})),
|
|
2622
|
+
entry: entry.entry,
|
|
2623
|
+
discarded: [...entry.discarded].map(([reason, leads]) => ({
|
|
2624
|
+
reason,
|
|
2625
|
+
leads,
|
|
2626
|
+
})),
|
|
2627
|
+
returned: entry.returned,
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
forms.sort((left, right) => right.leads - left.leads || left.formId.localeCompare(right.formId));
|
|
2631
|
+
return { forms, partial };
|
|
2632
|
+
},
|
|
2633
|
+
});
|
|
2634
|
+
//# sourceMappingURL=metaLeads.js.map
|