@iann29/rastro 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/agent/integration.md +23 -1
- package/agent/manifest.json +4 -4
- package/dist/client/federation.d.ts +114 -9
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +54 -2
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +642 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +391 -9
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +14 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +278 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +4 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/constants.d.ts +5 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +10 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +4 -0
- package/dist/component/convex.config.js +9 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/errors.d.ts +1 -1
- package/dist/component/errors.d.ts.map +1 -1
- package/dist/component/errors.js.map +1 -1
- package/dist/component/leads.d.ts +69 -3
- package/dist/component/leads.d.ts.map +1 -1
- package/dist/component/leads.js +419 -16
- package/dist/component/leads.js.map +1 -1
- package/dist/component/meta.d.ts +358 -0
- package/dist/component/meta.d.ts.map +1 -0
- package/dist/component/meta.js +1295 -0
- package/dist/component/meta.js.map +1 -0
- package/dist/component/metaCapi.d.ts +162 -0
- package/dist/component/metaCapi.d.ts.map +1 -0
- package/dist/component/metaCapi.js +754 -0
- package/dist/component/metaCapi.js.map +1 -0
- package/dist/component/metaFake.d.ts +5 -0
- package/dist/component/metaFake.d.ts.map +1 -0
- package/dist/component/metaFake.js +225 -0
- package/dist/component/metaFake.js.map +1 -0
- package/dist/component/metaGraph.d.ts +165 -0
- package/dist/component/metaGraph.d.ts.map +1 -0
- package/dist/component/metaGraph.js +507 -0
- package/dist/component/metaGraph.js.map +1 -0
- package/dist/component/metaSync.d.ts +186 -0
- package/dist/component/metaSync.d.ts.map +1 -0
- package/dist/component/metaSync.js +961 -0
- package/dist/component/metaSync.js.map +1 -0
- package/dist/component/pipeline.d.ts +99 -0
- package/dist/component/pipeline.d.ts.map +1 -0
- package/dist/component/pipeline.js +337 -0
- package/dist/component/pipeline.js.map +1 -0
- package/dist/component/schema.d.ts +404 -2
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +168 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/component/secrets.d.ts +15 -0
- package/dist/component/secrets.d.ts.map +1 -0
- package/dist/component/secrets.js +67 -0
- package/dist/component/secrets.js.map +1 -0
- package/dist/component/validators.d.ts +1052 -15
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +307 -3
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +1 -1
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +1 -1
- package/dist/tracker/generated.js.map +1 -1
- package/dist/ui/campaigns/CampaignsView.d.ts +48 -0
- package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -0
- package/dist/ui/campaigns/CampaignsView.js +47 -0
- package/dist/ui/campaigns/CampaignsView.js.map +1 -0
- package/dist/ui/campaigns/Conversions.d.ts +16 -0
- package/dist/ui/campaigns/Conversions.d.ts.map +1 -0
- package/dist/ui/campaigns/Conversions.js +40 -0
- package/dist/ui/campaigns/Conversions.js.map +1 -0
- package/dist/ui/campaigns/Drawer.d.ts +24 -0
- package/dist/ui/campaigns/Drawer.d.ts.map +1 -0
- package/dist/ui/campaigns/Drawer.js +120 -0
- package/dist/ui/campaigns/Drawer.js.map +1 -0
- package/dist/ui/campaigns/Icons.d.ts +35 -0
- package/dist/ui/campaigns/Icons.d.ts.map +1 -0
- package/dist/ui/campaigns/Icons.js +41 -0
- package/dist/ui/campaigns/Icons.js.map +1 -0
- package/dist/ui/campaigns/Summary.d.ts +21 -0
- package/dist/ui/campaigns/Summary.d.ts.map +1 -0
- package/dist/ui/campaigns/Summary.js +56 -0
- package/dist/ui/campaigns/Summary.js.map +1 -0
- package/dist/ui/campaigns/Thumbnail.d.ts +11 -0
- package/dist/ui/campaigns/Thumbnail.d.ts.map +1 -0
- package/dist/ui/campaigns/Thumbnail.js +23 -0
- package/dist/ui/campaigns/Thumbnail.js.map +1 -0
- package/dist/ui/campaigns/Tree.d.ts +12 -0
- package/dist/ui/campaigns/Tree.d.ts.map +1 -0
- package/dist/ui/campaigns/Tree.js +105 -0
- package/dist/ui/campaigns/Tree.js.map +1 -0
- package/dist/ui/campaigns/glyphs.d.ts +20 -0
- package/dist/ui/campaigns/glyphs.d.ts.map +1 -0
- package/dist/ui/campaigns/glyphs.js +19 -0
- package/dist/ui/campaigns/glyphs.js.map +1 -0
- package/dist/ui/campaigns/index.d.ts +4 -0
- package/dist/ui/campaigns/index.d.ts.map +1 -0
- package/dist/ui/campaigns/index.js +3 -0
- package/dist/ui/campaigns/index.js.map +1 -0
- package/dist/ui/campaigns/labels.d.ts +53 -0
- package/dist/ui/campaigns/labels.d.ts.map +1 -0
- package/dist/ui/campaigns/labels.js +169 -0
- package/dist/ui/campaigns/labels.js.map +1 -0
- package/dist/ui/campaigns/model.d.ts +99 -0
- package/dist/ui/campaigns/model.d.ts.map +1 -0
- package/dist/ui/campaigns/model.js +230 -0
- package/dist/ui/campaigns/model.js.map +1 -0
- package/dist/ui/campaigns/modes.d.ts +18 -0
- package/dist/ui/campaigns/modes.d.ts.map +1 -0
- package/dist/ui/campaigns/modes.js +7 -0
- package/dist/ui/campaigns/modes.js.map +1 -0
- package/dist/ui/index.d.ts +21 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +20 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/integrations/AccountPicker.d.ts +26 -0
- package/dist/ui/integrations/AccountPicker.d.ts.map +1 -0
- package/dist/ui/integrations/AccountPicker.js +101 -0
- package/dist/ui/integrations/AccountPicker.js.map +1 -0
- package/dist/ui/integrations/ClientCards.d.ts +17 -0
- package/dist/ui/integrations/ClientCards.d.ts.map +1 -0
- package/dist/ui/integrations/ClientCards.js +42 -0
- package/dist/ui/integrations/ClientCards.js.map +1 -0
- package/dist/ui/integrations/ClientsTable.d.ts +36 -0
- package/dist/ui/integrations/ClientsTable.d.ts.map +1 -0
- package/dist/ui/integrations/ClientsTable.js +143 -0
- package/dist/ui/integrations/ClientsTable.js.map +1 -0
- package/dist/ui/integrations/Connect.d.ts +35 -0
- package/dist/ui/integrations/Connect.d.ts.map +1 -0
- package/dist/ui/integrations/Connect.js +62 -0
- package/dist/ui/integrations/Connect.js.map +1 -0
- package/dist/ui/integrations/Connection.d.ts +37 -0
- package/dist/ui/integrations/Connection.d.ts.map +1 -0
- package/dist/ui/integrations/Connection.js +108 -0
- package/dist/ui/integrations/Connection.js.map +1 -0
- package/dist/ui/integrations/Dialogs.d.ts +21 -0
- package/dist/ui/integrations/Dialogs.d.ts.map +1 -0
- package/dist/ui/integrations/Dialogs.js +34 -0
- package/dist/ui/integrations/Dialogs.js.map +1 -0
- package/dist/ui/integrations/EventsFeed.d.ts +15 -0
- package/dist/ui/integrations/EventsFeed.d.ts.map +1 -0
- package/dist/ui/integrations/EventsFeed.js +61 -0
- package/dist/ui/integrations/EventsFeed.js.map +1 -0
- package/dist/ui/integrations/EventsSheet.d.ts +34 -0
- package/dist/ui/integrations/EventsSheet.d.ts.map +1 -0
- package/dist/ui/integrations/EventsSheet.js +136 -0
- package/dist/ui/integrations/EventsSheet.js.map +1 -0
- package/dist/ui/integrations/Icons.d.ts +38 -0
- package/dist/ui/integrations/Icons.d.ts.map +1 -0
- package/dist/ui/integrations/Icons.js +40 -0
- package/dist/ui/integrations/Icons.js.map +1 -0
- package/dist/ui/integrations/IntegrationsView.d.ts +15 -0
- package/dist/ui/integrations/IntegrationsView.d.ts.map +1 -0
- package/dist/ui/integrations/IntegrationsView.js +141 -0
- package/dist/ui/integrations/IntegrationsView.js.map +1 -0
- package/dist/ui/integrations/Overlay.d.ts +18 -0
- package/dist/ui/integrations/Overlay.d.ts.map +1 -0
- package/dist/ui/integrations/Overlay.js +36 -0
- package/dist/ui/integrations/Overlay.js.map +1 -0
- package/dist/ui/integrations/Partner.d.ts +14 -0
- package/dist/ui/integrations/Partner.d.ts.map +1 -0
- package/dist/ui/integrations/Partner.js +38 -0
- package/dist/ui/integrations/Partner.js.map +1 -0
- package/dist/ui/integrations/RowMenu.d.ts +15 -0
- package/dist/ui/integrations/RowMenu.d.ts.map +1 -0
- package/dist/ui/integrations/RowMenu.js +47 -0
- package/dist/ui/integrations/RowMenu.js.map +1 -0
- package/dist/ui/integrations/UnlinkDialog.d.ts +18 -0
- package/dist/ui/integrations/UnlinkDialog.d.ts.map +1 -0
- package/dist/ui/integrations/UnlinkDialog.js +29 -0
- package/dist/ui/integrations/UnlinkDialog.js.map +1 -0
- package/dist/ui/integrations/audience.d.ts +12 -0
- package/dist/ui/integrations/audience.d.ts.map +1 -0
- package/dist/ui/integrations/audience.js +12 -0
- package/dist/ui/integrations/audience.js.map +1 -0
- package/dist/ui/integrations/format.d.ts +28 -0
- package/dist/ui/integrations/format.d.ts.map +1 -0
- package/dist/ui/integrations/format.js +101 -0
- package/dist/ui/integrations/format.js.map +1 -0
- package/dist/ui/integrations/index.d.ts +5 -0
- package/dist/ui/integrations/index.d.ts.map +1 -0
- package/dist/ui/integrations/index.js +3 -0
- package/dist/ui/integrations/index.js.map +1 -0
- package/dist/ui/integrations/modal.d.ts +10 -0
- package/dist/ui/integrations/modal.d.ts.map +1 -0
- package/dist/ui/integrations/modal.js +77 -0
- package/dist/ui/integrations/modal.js.map +1 -0
- package/dist/ui/integrations/model.d.ts +99 -0
- package/dist/ui/integrations/model.d.ts.map +1 -0
- package/dist/ui/integrations/model.js +164 -0
- package/dist/ui/integrations/model.js.map +1 -0
- package/dist/ui/integrations/parts.d.ts +36 -0
- package/dist/ui/integrations/parts.d.ts.map +1 -0
- package/dist/ui/integrations/parts.js +85 -0
- package/dist/ui/integrations/parts.js.map +1 -0
- package/dist/ui/integrations/types.d.ts +146 -0
- package/dist/ui/integrations/types.d.ts.map +1 -0
- package/dist/ui/integrations/types.js +2 -0
- package/dist/ui/integrations/types.js.map +1 -0
- package/dist/ui/metrics.d.ts +106 -0
- package/dist/ui/metrics.d.ts.map +1 -0
- package/dist/ui/metrics.js +166 -0
- package/dist/ui/metrics.js.map +1 -0
- package/dist/ui/portfolio/PortfolioView.d.ts +26 -0
- package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -0
- package/dist/ui/portfolio/PortfolioView.js +216 -0
- package/dist/ui/portfolio/PortfolioView.js.map +1 -0
- package/dist/ui/portfolio/SpendChart.d.ts +10 -0
- package/dist/ui/portfolio/SpendChart.d.ts.map +1 -0
- package/dist/ui/portfolio/SpendChart.js +196 -0
- package/dist/ui/portfolio/SpendChart.js.map +1 -0
- package/dist/ui/portfolio/alerts.d.ts +74 -0
- package/dist/ui/portfolio/alerts.d.ts.map +1 -0
- package/dist/ui/portfolio/alerts.js +291 -0
- package/dist/ui/portfolio/alerts.js.map +1 -0
- package/dist/ui/portfolio/figures.d.ts +79 -0
- package/dist/ui/portfolio/figures.d.ts.map +1 -0
- package/dist/ui/portfolio/figures.js +103 -0
- package/dist/ui/portfolio/figures.js.map +1 -0
- package/dist/ui/portfolio/format.d.ts +8 -0
- package/dist/ui/portfolio/format.d.ts.map +1 -0
- package/dist/ui/portfolio/format.js +28 -0
- package/dist/ui/portfolio/format.js.map +1 -0
- package/dist/ui/sample.d.ts +536 -0
- package/dist/ui/sample.d.ts.map +1 -0
- package/dist/ui/sample.js +574 -0
- package/dist/ui/sample.js.map +1 -0
- package/dist/ui/seed.d.ts +188 -0
- package/dist/ui/seed.d.ts.map +1 -0
- package/dist/ui/seed.js +563 -0
- package/dist/ui/seed.js.map +1 -0
- package/dist/ui/ui.css +3424 -0
- package/docs/federation.md +46 -1
- package/docs/meta-ads.md +795 -0
- package/docs/upgrading.md +116 -0
- package/llms.txt +3 -0
- package/package.json +15 -3
- package/src/component/_generated/api.ts +14 -0
- package/src/component/_generated/component.ts +324 -1
- package/src/component/_generated/server.ts +4 -0
- package/src/component/constants.ts +10 -0
- package/src/component/convex.config.ts +9 -0
- package/src/component/errors.ts +5 -1
- package/src/component/leads.ts +517 -17
- package/src/component/meta.ts +1525 -0
- package/src/component/metaCapi.ts +921 -0
- package/src/component/metaFake.ts +277 -0
- package/src/component/metaGraph.ts +722 -0
- package/src/component/metaSync.ts +1164 -0
- package/src/component/pipeline.ts +410 -0
- package/src/component/schema.ts +197 -1
- package/src/component/secrets.ts +98 -0
- package/src/component/validators.ts +376 -3
- package/src/tracker/generated.ts +1 -1
- package/src/ui/seed.ts +755 -0
|
@@ -0,0 +1,1525 @@
|
|
|
1
|
+
import { v, type Infer } from "convex/values";
|
|
2
|
+
import { internal } from "./_generated/api.js";
|
|
3
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
4
|
+
import {
|
|
5
|
+
action,
|
|
6
|
+
env,
|
|
7
|
+
internalMutation,
|
|
8
|
+
internalQuery,
|
|
9
|
+
mutation,
|
|
10
|
+
query,
|
|
11
|
+
type ActionCtx,
|
|
12
|
+
type MutationCtx,
|
|
13
|
+
type QueryCtx,
|
|
14
|
+
} from "./_generated/server.js";
|
|
15
|
+
import {
|
|
16
|
+
MAX_LEAD_LIST,
|
|
17
|
+
MAX_REPORT_SITES,
|
|
18
|
+
MAX_SITE_AD_ACCOUNTS,
|
|
19
|
+
} from "./constants.js";
|
|
20
|
+
import { fail } from "./errors.js";
|
|
21
|
+
import { conversionCounts, listConversions } from "./metaCapi.js";
|
|
22
|
+
import {
|
|
23
|
+
metaGraph,
|
|
24
|
+
MetaGraphError,
|
|
25
|
+
type GraphAdAccount,
|
|
26
|
+
type MetaApp,
|
|
27
|
+
type MetaGraph,
|
|
28
|
+
} from "./metaGraph.js";
|
|
29
|
+
import {
|
|
30
|
+
backfillProgress,
|
|
31
|
+
kickSyncLoop,
|
|
32
|
+
MAX_LINKS_PER_CONNECTION,
|
|
33
|
+
PAUSED_AT,
|
|
34
|
+
} from "./metaSync.js";
|
|
35
|
+
import { sanitizeOpaqueId } from "./sanitize.js";
|
|
36
|
+
import { decryptSecret, encryptSecret, hasSecretsKey } from "./secrets.js";
|
|
37
|
+
import {
|
|
38
|
+
metaAdAccountValidator,
|
|
39
|
+
metaAuthorizerValidator,
|
|
40
|
+
metaConnectionValidator,
|
|
41
|
+
metaConnectResultValidator,
|
|
42
|
+
metaConversionValidator,
|
|
43
|
+
metaPixelSourceValidator,
|
|
44
|
+
metaPixelValidator,
|
|
45
|
+
metaStatusValidator,
|
|
46
|
+
metaUnlinkPreviewValidator,
|
|
47
|
+
} from "./validators.js";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Meta for a site: the connection (a host owner's token and its ad accounts),
|
|
51
|
+
* which of those accounts feed the site's spend, the site's pixel, and the
|
|
52
|
+
* read side (`status`, `conversions`). The contract is in `docs/meta-ads.md`;
|
|
53
|
+
* the sync itself is in metaSync.ts. Tokens are sealed with
|
|
54
|
+
* RASTRO_SECRETS_KEY before any write and only actions open them; no query
|
|
55
|
+
* returns one.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
type MetaAdAccount = Infer<typeof metaAdAccountValidator>;
|
|
59
|
+
type MetaPixel = Infer<typeof metaPixelValidator>;
|
|
60
|
+
type MetaAuthorizer = Infer<typeof metaAuthorizerValidator>;
|
|
61
|
+
|
|
62
|
+
const MAX_TOKEN = 1_024;
|
|
63
|
+
const MIN_TOKEN = 20;
|
|
64
|
+
const MAX_TEST_EVENT_CODE = 64;
|
|
65
|
+
const PIXEL_ID = /^\d{5,20}$/;
|
|
66
|
+
const AD_ACCOUNT_ID = /^act_\d{1,24}$/;
|
|
67
|
+
const UNLINK_BATCH = 50;
|
|
68
|
+
const MAX_CODE = 1_024;
|
|
69
|
+
const MAX_REDIRECT_URI = 512;
|
|
70
|
+
// Pixels are listed per ad account, a call each: past this many accounts the
|
|
71
|
+
// listing stops (an agency picks a pixel among its clients', not thousands).
|
|
72
|
+
const MAX_PIXEL_ACCOUNTS = 100;
|
|
73
|
+
const PIXEL_CONCURRENCY = 5;
|
|
74
|
+
const MAX_PIXELS = 500;
|
|
75
|
+
const MAX_PREVIEW_DAYS = 1_000;
|
|
76
|
+
const MAX_PREVIEW_CATALOG = 4_000;
|
|
77
|
+
const TOKEN_EXCHANGE_FAILED =
|
|
78
|
+
"TOKEN_EXCHANGE_FAILED: Meta did not trade the short-lived token for a long-lived one; connect again before it expires";
|
|
79
|
+
/** How long before a token dies the screens ask for a new login. */
|
|
80
|
+
const EXPIRY_WARNING_MS = 7 * 86_400_000;
|
|
81
|
+
|
|
82
|
+
/** An owner key as stored: the host's opaque id, checked. */
|
|
83
|
+
export function ownerKeyOf(value: string): string {
|
|
84
|
+
try {
|
|
85
|
+
return sanitizeOpaqueId(value, "ownerKey");
|
|
86
|
+
} catch (error) {
|
|
87
|
+
fail(
|
|
88
|
+
"INVALID_ARGUMENT",
|
|
89
|
+
error instanceof Error ? error.message : "ownerKey is invalid",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function tokenOf(value: string): string {
|
|
95
|
+
const token = value.trim();
|
|
96
|
+
if (
|
|
97
|
+
token.length < MIN_TOKEN ||
|
|
98
|
+
token.length > MAX_TOKEN ||
|
|
99
|
+
/\s/.test(token)
|
|
100
|
+
) {
|
|
101
|
+
fail("INVALID_ARGUMENT", "accessToken is not a Meta access token");
|
|
102
|
+
}
|
|
103
|
+
return token;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function adAccountIdOf(value: string): string {
|
|
107
|
+
const trimmed = value.trim();
|
|
108
|
+
const id = /^\d+$/.test(trimmed) ? `act_${trimmed}` : trimmed;
|
|
109
|
+
if (!AD_ACCOUNT_ID.test(id)) {
|
|
110
|
+
fail("INVALID_ARGUMENT", "adAccountId must look like act_1234567890");
|
|
111
|
+
}
|
|
112
|
+
return id;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function requireKey() {
|
|
116
|
+
if (!hasSecretsKey(env.RASTRO_SECRETS_KEY)) {
|
|
117
|
+
fail(
|
|
118
|
+
"SECRETS_KEY_MISSING",
|
|
119
|
+
"set RASTRO_SECRETS_KEY (32 random bytes, base64) and pass it to the component",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const graph = () => metaGraph({ fake: env.RASTRO_META_FAKE === "true" });
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The Meta app Facebook Login for Business runs on. The fake needs none: any
|
|
128
|
+
* code logs in there, so a demo works without a real app.
|
|
129
|
+
*/
|
|
130
|
+
function loginApp(): MetaApp {
|
|
131
|
+
const appId = env.RASTRO_META_APP_ID?.trim() ?? "";
|
|
132
|
+
const appSecret = env.RASTRO_META_APP_SECRET?.trim() ?? "";
|
|
133
|
+
if (env.RASTRO_META_FAKE === "true") return { appId, appSecret };
|
|
134
|
+
if (!/^\d{5,20}$/.test(appId) || !appSecret) {
|
|
135
|
+
fail(
|
|
136
|
+
"META_LOGIN_UNAVAILABLE",
|
|
137
|
+
"set RASTRO_META_APP_ID and RASTRO_META_APP_SECRET and pass them to the component; a pasted token still connects",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return { appId, appSecret };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function codeOf(value: string): string {
|
|
144
|
+
const code = value.trim();
|
|
145
|
+
if (code.length < 1 || code.length > MAX_CODE || /\s/.test(code)) {
|
|
146
|
+
fail("INVALID_ARGUMENT", "code is not a Meta authorization code");
|
|
147
|
+
}
|
|
148
|
+
return code;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The redirect the code was issued for: https, or http on this machine. */
|
|
152
|
+
function redirectUriOf(value: string): string {
|
|
153
|
+
let url: URL;
|
|
154
|
+
try {
|
|
155
|
+
url = new URL(value.trim());
|
|
156
|
+
} catch {
|
|
157
|
+
fail("INVALID_ARGUMENT", "redirectUri must be an absolute URL");
|
|
158
|
+
}
|
|
159
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
160
|
+
if (
|
|
161
|
+
value.length > MAX_REDIRECT_URI ||
|
|
162
|
+
url.username ||
|
|
163
|
+
url.password ||
|
|
164
|
+
url.hash ||
|
|
165
|
+
!(url.protocol === "https:" || (url.protocol === "http:" && local))
|
|
166
|
+
) {
|
|
167
|
+
fail(
|
|
168
|
+
"INVALID_ARGUMENT",
|
|
169
|
+
"redirectUri must be the https page Meta sent the code to",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return url.toString();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The pixels the token's accounts own, deduplicated. An account the token
|
|
177
|
+
* cannot read pixels for is skipped (a token with only `ads_read` sees none);
|
|
178
|
+
* a refused token fails the whole listing. `complete` is false when Meta
|
|
179
|
+
* throttled or failed on an account: the caller then keeps the pixels it had.
|
|
180
|
+
*/
|
|
181
|
+
async function listPixels(
|
|
182
|
+
client: MetaGraph,
|
|
183
|
+
token: string,
|
|
184
|
+
adAccounts: MetaAdAccount[],
|
|
185
|
+
): Promise<{ pixels: MetaPixel[]; complete: boolean }> {
|
|
186
|
+
const accounts = adAccounts.slice(0, MAX_PIXEL_ACCOUNTS);
|
|
187
|
+
const pixels = new Map<string, MetaPixel>();
|
|
188
|
+
let complete = true;
|
|
189
|
+
for (let start = 0; start < accounts.length; start += PIXEL_CONCURRENCY) {
|
|
190
|
+
const batch = accounts.slice(start, start + PIXEL_CONCURRENCY);
|
|
191
|
+
const results = await Promise.all(
|
|
192
|
+
batch.map(async (account) => {
|
|
193
|
+
try {
|
|
194
|
+
return await client.adPixels(token, account.adAccountId);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error instanceof MetaGraphError && error.kind === "token") {
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
if (
|
|
200
|
+
!(error instanceof MetaGraphError) ||
|
|
201
|
+
error.kind !== "permission"
|
|
202
|
+
) {
|
|
203
|
+
complete = false;
|
|
204
|
+
}
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
for (const pixel of results.flat()) {
|
|
210
|
+
if (!PIXEL_ID.test(pixel.pixelId) || pixels.has(pixel.pixelId)) continue;
|
|
211
|
+
pixels.set(pixel.pixelId, {
|
|
212
|
+
pixelId: pixel.pixelId,
|
|
213
|
+
name: pixel.name.slice(0, 200),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { pixels: [...pixels.values()].slice(0, MAX_PIXELS), complete };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Meta's failure as the component's own error code. */
|
|
221
|
+
function metaFailure(error: unknown): never {
|
|
222
|
+
if (error instanceof MetaGraphError) {
|
|
223
|
+
if (error.kind === "token") {
|
|
224
|
+
fail("META_TOKEN_INVALID", `Meta refused the token: ${error.message}`);
|
|
225
|
+
}
|
|
226
|
+
if (error.kind === "app") {
|
|
227
|
+
fail(
|
|
228
|
+
"META_LOGIN_UNAVAILABLE",
|
|
229
|
+
"Meta refused RASTRO_META_APP_ID / RASTRO_META_APP_SECRET; check the host's env",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
fail("META_UNAVAILABLE", `Meta answered: ${error.message}`, {
|
|
233
|
+
kind: error.kind,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const toAccount = (account: GraphAdAccount): MetaAdAccount => ({
|
|
240
|
+
adAccountId: account.adAccountId,
|
|
241
|
+
name: account.name.slice(0, 200),
|
|
242
|
+
currency: account.currency.slice(0, 8),
|
|
243
|
+
timezone: account.timezone.slice(0, 64),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Connection.
|
|
248
|
+
|
|
249
|
+
export const saveConnection = internalMutation({
|
|
250
|
+
args: {
|
|
251
|
+
ownerKey: v.string(),
|
|
252
|
+
tokenCipher: v.string(),
|
|
253
|
+
tokenIv: v.string(),
|
|
254
|
+
businessName: v.union(v.string(), v.null()),
|
|
255
|
+
adAccounts: v.array(metaAdAccountValidator),
|
|
256
|
+
pixels: v.array(metaPixelValidator),
|
|
257
|
+
/** False when Meta failed part of the pixel listing: keep the old ones. */
|
|
258
|
+
pixelsComplete: v.boolean(),
|
|
259
|
+
/** Facebook Login only; a pasted token clears it. */
|
|
260
|
+
authorizedBy: v.union(metaAuthorizerValidator, v.null()),
|
|
261
|
+
/** When Meta said the token expires; null for never, or not known. */
|
|
262
|
+
tokenExpiresAt: v.union(v.number(), v.null()),
|
|
263
|
+
/** A problem that did not stop the connection, such as a failed trade. */
|
|
264
|
+
lastError: v.union(v.string(), v.null()),
|
|
265
|
+
},
|
|
266
|
+
returns: v.id("metaConnections"),
|
|
267
|
+
handler: async (ctx, args) => {
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
const warnAt =
|
|
270
|
+
args.tokenExpiresAt === null
|
|
271
|
+
? null
|
|
272
|
+
: args.tokenExpiresAt - EXPIRY_WARNING_MS;
|
|
273
|
+
const existing = await ctx.db
|
|
274
|
+
.query("metaConnections")
|
|
275
|
+
.withIndex("by_ownerKey", (range) => range.eq("ownerKey", args.ownerKey))
|
|
276
|
+
.unique();
|
|
277
|
+
const pixels = args.pixelsComplete
|
|
278
|
+
? args.pixels
|
|
279
|
+
: mergePixels(args.pixels, existing?.pixels ?? []);
|
|
280
|
+
const fields = {
|
|
281
|
+
tokenExpiresAt: args.tokenExpiresAt ?? undefined,
|
|
282
|
+
expiringSoon: warnAt !== null && warnAt <= now ? true : undefined,
|
|
283
|
+
tokenCipher: args.tokenCipher,
|
|
284
|
+
tokenIv: args.tokenIv,
|
|
285
|
+
businessName: args.businessName ?? undefined,
|
|
286
|
+
status: "active" as const,
|
|
287
|
+
adAccounts: args.adAccounts,
|
|
288
|
+
pixels,
|
|
289
|
+
authorizedBy: args.authorizedBy ?? undefined,
|
|
290
|
+
authorizedAt: args.authorizedBy ? now : undefined,
|
|
291
|
+
invalidSince: undefined,
|
|
292
|
+
lastCheckedAt: now,
|
|
293
|
+
lastError: args.lastError ?? undefined,
|
|
294
|
+
updatedAt: now,
|
|
295
|
+
};
|
|
296
|
+
const connectionId =
|
|
297
|
+
existing?._id ??
|
|
298
|
+
(await ctx.db.insert("metaConnections", {
|
|
299
|
+
ownerKey: args.ownerKey,
|
|
300
|
+
...fields,
|
|
301
|
+
createdAt: now,
|
|
302
|
+
}));
|
|
303
|
+
// A query cannot read the clock, so the warning is a flag a scheduled
|
|
304
|
+
// run sets 7 days before the token dies (that token only: a newer one
|
|
305
|
+
// has another IV and ignores it).
|
|
306
|
+
if (warnAt !== null && warnAt > now) {
|
|
307
|
+
await ctx.scheduler.runAt(warnAt, internal.meta.markExpiringSoon, {
|
|
308
|
+
connectionId,
|
|
309
|
+
tokenIv: args.tokenIv,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (!existing) return connectionId;
|
|
313
|
+
await ctx.db.patch("metaConnections", existing._id, fields);
|
|
314
|
+
// A new token resumes the accounts a dead one had stopped, but only
|
|
315
|
+
// those it lists: another business's token sees other accounts.
|
|
316
|
+
await reconcileGrants(ctx, existing._id, args.adAccounts, pixels, true);
|
|
317
|
+
return existing._id;
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const ACCOUNT_NOT_GRANTED =
|
|
322
|
+
"AD_ACCOUNT_NOT_GRANTED: this connection's token does not list the ad account; grant it in Meta and connect again";
|
|
323
|
+
const PIXEL_NOT_GRANTED =
|
|
324
|
+
"PIXEL_NOT_GRANTED: this connection's token does not list the pixel; grant it in Meta and connect again";
|
|
325
|
+
|
|
326
|
+
const mergePixels = (fresh: MetaPixel[], kept: MetaPixel[]) => {
|
|
327
|
+
const byId = new Map(kept.map((pixel) => [pixel.pixelId, pixel]));
|
|
328
|
+
for (const pixel of fresh) byId.set(pixel.pixelId, pixel);
|
|
329
|
+
return [...byId.values()].slice(0, MAX_PIXELS);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Lines the connection's links and pixels up with what its token lists. A
|
|
334
|
+
* link to an account the token no longer lists stops, saying why; one it
|
|
335
|
+
* lists again (or, with a new token, one a dead token had stopped) resumes.
|
|
336
|
+
* A pixel the token no longer lists says why too (`loadPixelToken` already
|
|
337
|
+
* refuses it).
|
|
338
|
+
*/
|
|
339
|
+
async function reconcileGrants(
|
|
340
|
+
ctx: MutationCtx,
|
|
341
|
+
connectionId: Id<"metaConnections">,
|
|
342
|
+
adAccounts: MetaAdAccount[],
|
|
343
|
+
pixels: MetaPixel[],
|
|
344
|
+
newToken: boolean,
|
|
345
|
+
) {
|
|
346
|
+
const now = Date.now();
|
|
347
|
+
const listed = new Set(adAccounts.map((account) => account.adAccountId));
|
|
348
|
+
const links = await ctx.db
|
|
349
|
+
.query("siteAdAccounts")
|
|
350
|
+
.withIndex("by_connectionId", (range) =>
|
|
351
|
+
range.eq("connectionId", connectionId),
|
|
352
|
+
)
|
|
353
|
+
.take(MAX_LINKS_PER_CONNECTION);
|
|
354
|
+
let resumed = false;
|
|
355
|
+
for (const link of links) {
|
|
356
|
+
if (!listed.has(link.adAccountId)) {
|
|
357
|
+
if (link.lastError === ACCOUNT_NOT_GRANTED) continue;
|
|
358
|
+
await ctx.db.patch("siteAdAccounts", link._id, {
|
|
359
|
+
syncState: "error",
|
|
360
|
+
runId: undefined,
|
|
361
|
+
syncRequested: undefined,
|
|
362
|
+
lastError: ACCOUNT_NOT_GRANTED,
|
|
363
|
+
nextSyncAt: PAUSED_AT,
|
|
364
|
+
});
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (
|
|
368
|
+
link.nextSyncAt !== PAUSED_AT ||
|
|
369
|
+
!(newToken || link.lastError === ACCOUNT_NOT_GRANTED)
|
|
370
|
+
) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
await ctx.db.patch("siteAdAccounts", link._id, {
|
|
374
|
+
syncState: link.lastSyncAt === undefined ? "backfill" : "live",
|
|
375
|
+
lastError: undefined,
|
|
376
|
+
nextSyncAt: now,
|
|
377
|
+
});
|
|
378
|
+
resumed = true;
|
|
379
|
+
}
|
|
380
|
+
if (resumed) await kickSyncLoop(ctx, now);
|
|
381
|
+
const pixelIds = new Set(pixels.map((pixel) => pixel.pixelId));
|
|
382
|
+
const sitePixels = await ctx.db
|
|
383
|
+
.query("sitePixels")
|
|
384
|
+
.withIndex("by_connectionId", (range) =>
|
|
385
|
+
range.eq("connectionId", connectionId),
|
|
386
|
+
)
|
|
387
|
+
.take(MAX_LINKS_PER_CONNECTION);
|
|
388
|
+
for (const pixel of sitePixels) {
|
|
389
|
+
const granted = pixelIds.has(pixel.pixelId);
|
|
390
|
+
if (!granted && pixel.lastError !== PIXEL_NOT_GRANTED) {
|
|
391
|
+
await ctx.db.patch("sitePixels", pixel._id, {
|
|
392
|
+
lastError: PIXEL_NOT_GRANTED,
|
|
393
|
+
});
|
|
394
|
+
} else if (granted && pixel.lastError === PIXEL_NOT_GRANTED) {
|
|
395
|
+
await ctx.db.patch("sitePixels", pixel._id, { lastError: undefined });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export const markExpiringSoon = internalMutation({
|
|
401
|
+
args: { connectionId: v.id("metaConnections"), tokenIv: v.string() },
|
|
402
|
+
returns: v.null(),
|
|
403
|
+
handler: async (ctx, args) => {
|
|
404
|
+
const connection = await ctx.db.get("metaConnections", args.connectionId);
|
|
405
|
+
if (connection?.tokenIv !== args.tokenIv) return null;
|
|
406
|
+
await ctx.db.patch("metaConnections", args.connectionId, {
|
|
407
|
+
expiringSoon: true,
|
|
408
|
+
});
|
|
409
|
+
return null;
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
export const updateConnectionAccounts = internalMutation({
|
|
414
|
+
args: {
|
|
415
|
+
connectionId: v.id("metaConnections"),
|
|
416
|
+
/** The token the listing came from: a newer one's listing wins. */
|
|
417
|
+
tokenIv: v.string(),
|
|
418
|
+
adAccounts: v.array(metaAdAccountValidator),
|
|
419
|
+
pixels: v.array(metaPixelValidator),
|
|
420
|
+
pixelsComplete: v.boolean(),
|
|
421
|
+
},
|
|
422
|
+
returns: v.null(),
|
|
423
|
+
handler: async (ctx, args) => {
|
|
424
|
+
const now = Date.now();
|
|
425
|
+
const connection = await ctx.db.get("metaConnections", args.connectionId);
|
|
426
|
+
// Removed, or reconnected, while Meta answered: not this token's to say.
|
|
427
|
+
if (connection?.tokenIv !== args.tokenIv) return null;
|
|
428
|
+
const pixels = args.pixelsComplete
|
|
429
|
+
? args.pixels
|
|
430
|
+
: mergePixels(args.pixels, connection.pixels ?? []);
|
|
431
|
+
await ctx.db.patch("metaConnections", args.connectionId, {
|
|
432
|
+
adAccounts: args.adAccounts,
|
|
433
|
+
pixels,
|
|
434
|
+
lastCheckedAt: now,
|
|
435
|
+
updatedAt: now,
|
|
436
|
+
});
|
|
437
|
+
await reconcileGrants(
|
|
438
|
+
ctx,
|
|
439
|
+
args.connectionId,
|
|
440
|
+
args.adAccounts,
|
|
441
|
+
pixels,
|
|
442
|
+
false,
|
|
443
|
+
);
|
|
444
|
+
return null;
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Meta refused the token sealed with `tokenIv`. A call that started with an
|
|
450
|
+
* older token and ends after a reconnect changes nothing: the new token is
|
|
451
|
+
* not the one that failed.
|
|
452
|
+
*/
|
|
453
|
+
export const markConnectionInvalid = internalMutation({
|
|
454
|
+
args: {
|
|
455
|
+
connectionId: v.id("metaConnections"),
|
|
456
|
+
tokenIv: v.string(),
|
|
457
|
+
message: v.string(),
|
|
458
|
+
},
|
|
459
|
+
returns: v.null(),
|
|
460
|
+
handler: async (ctx, args) => {
|
|
461
|
+
const now = Date.now();
|
|
462
|
+
const connection = await ctx.db.get("metaConnections", args.connectionId);
|
|
463
|
+
if (connection?.tokenIv !== args.tokenIv) return null;
|
|
464
|
+
await ctx.db.patch("metaConnections", args.connectionId, {
|
|
465
|
+
status: "invalid",
|
|
466
|
+
invalidSince: connection.invalidSince ?? now,
|
|
467
|
+
lastError: args.message.slice(0, 300),
|
|
468
|
+
lastCheckedAt: now,
|
|
469
|
+
updatedAt: now,
|
|
470
|
+
});
|
|
471
|
+
return null;
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
export const loadConnection = internalQuery({
|
|
476
|
+
args: { ownerKey: v.string() },
|
|
477
|
+
returns: v.union(
|
|
478
|
+
v.null(),
|
|
479
|
+
v.object({
|
|
480
|
+
connectionId: v.id("metaConnections"),
|
|
481
|
+
tokenCipher: v.string(),
|
|
482
|
+
tokenIv: v.string(),
|
|
483
|
+
status: v.union(v.literal("active"), v.literal("invalid")),
|
|
484
|
+
}),
|
|
485
|
+
),
|
|
486
|
+
handler: async (ctx, args) => {
|
|
487
|
+
const connection = await ctx.db
|
|
488
|
+
.query("metaConnections")
|
|
489
|
+
.withIndex("by_ownerKey", (range) => range.eq("ownerKey", args.ownerKey))
|
|
490
|
+
.unique();
|
|
491
|
+
return connection
|
|
492
|
+
? {
|
|
493
|
+
connectionId: connection._id,
|
|
494
|
+
tokenCipher: connection.tokenCipher,
|
|
495
|
+
tokenIv: connection.tokenIv,
|
|
496
|
+
status: connection.status,
|
|
497
|
+
}
|
|
498
|
+
: null;
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The path every token takes in: who it is (`/me`), its ad accounts and their
|
|
504
|
+
* pixels, then sealed and saved for the owner.
|
|
505
|
+
*/
|
|
506
|
+
async function connectToken(
|
|
507
|
+
ctx: ActionCtx,
|
|
508
|
+
ownerKey: string,
|
|
509
|
+
token: string,
|
|
510
|
+
client: MetaGraph,
|
|
511
|
+
/**
|
|
512
|
+
* Facebook Login: when the token expires (null for never), and what went
|
|
513
|
+
* wrong on the way that did not stop it (no sensitive data).
|
|
514
|
+
*/
|
|
515
|
+
login: { expiresAt: number | null; lastError: string | null } | null,
|
|
516
|
+
) {
|
|
517
|
+
let businessName: string | null;
|
|
518
|
+
let authorizedBy: MetaAuthorizer | null = null;
|
|
519
|
+
let adAccounts: MetaAdAccount[];
|
|
520
|
+
let pixels: MetaPixel[];
|
|
521
|
+
let pixelsComplete: boolean;
|
|
522
|
+
try {
|
|
523
|
+
const me = await client.me(token);
|
|
524
|
+
businessName = me.name?.slice(0, 200) ?? null;
|
|
525
|
+
if (login) {
|
|
526
|
+
authorizedBy = { id: me.id.slice(0, 64), name: businessName };
|
|
527
|
+
}
|
|
528
|
+
adAccounts = (await client.adAccounts(token)).map(toAccount);
|
|
529
|
+
({ pixels, complete: pixelsComplete } = await listPixels(
|
|
530
|
+
client,
|
|
531
|
+
token,
|
|
532
|
+
adAccounts,
|
|
533
|
+
));
|
|
534
|
+
} catch (error) {
|
|
535
|
+
metaFailure(error);
|
|
536
|
+
}
|
|
537
|
+
const sealed = await encryptSecret(env.RASTRO_SECRETS_KEY, token);
|
|
538
|
+
const connectionId: Id<"metaConnections"> = await ctx.runMutation(
|
|
539
|
+
internal.meta.saveConnection,
|
|
540
|
+
{
|
|
541
|
+
ownerKey,
|
|
542
|
+
tokenCipher: sealed.cipher,
|
|
543
|
+
tokenIv: sealed.iv,
|
|
544
|
+
businessName,
|
|
545
|
+
adAccounts,
|
|
546
|
+
pixels,
|
|
547
|
+
pixelsComplete,
|
|
548
|
+
authorizedBy,
|
|
549
|
+
tokenExpiresAt: login?.expiresAt ?? null,
|
|
550
|
+
lastError: login?.lastError ?? null,
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
return { connectionId, businessName, adAccounts, pixels };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Validates a Meta token (a system user token with `ads_read`), lists its ad
|
|
558
|
+
* accounts and their pixels and stores it sealed for `ownerKey`. Pasting a new
|
|
559
|
+
* token for the same owner replaces the old one and resumes any account it
|
|
560
|
+
* had stopped.
|
|
561
|
+
*/
|
|
562
|
+
export const connect = action({
|
|
563
|
+
args: { ownerKey: v.string(), accessToken: v.string() },
|
|
564
|
+
returns: metaConnectResultValidator,
|
|
565
|
+
handler: async (ctx, args) => {
|
|
566
|
+
const ownerKey = ownerKeyOf(args.ownerKey);
|
|
567
|
+
const token = tokenOf(args.accessToken);
|
|
568
|
+
requireKey();
|
|
569
|
+
return await connectToken(ctx, ownerKey, token, graph(), null);
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Facebook Login for Business: trades the authorization code Meta sent to
|
|
575
|
+
* `redirectUri` for a token (with RASTRO_META_APP_ID/SECRET, server to
|
|
576
|
+
* server), then connects it like a pasted one and records who authorized it.
|
|
577
|
+
* Without the app env it fails with `META_LOGIN_UNAVAILABLE`; a code Meta
|
|
578
|
+
* refuses fails with `META_TOKEN_INVALID`.
|
|
579
|
+
*/
|
|
580
|
+
export const connectWithCode = action({
|
|
581
|
+
args: { ownerKey: v.string(), code: v.string(), redirectUri: v.string() },
|
|
582
|
+
returns: metaConnectResultValidator,
|
|
583
|
+
handler: async (ctx, args) => {
|
|
584
|
+
const ownerKey = ownerKeyOf(args.ownerKey);
|
|
585
|
+
const code = codeOf(args.code);
|
|
586
|
+
const redirectUri = redirectUriOf(args.redirectUri);
|
|
587
|
+
const app = loginApp();
|
|
588
|
+
requireKey();
|
|
589
|
+
const client = graph();
|
|
590
|
+
let token: string;
|
|
591
|
+
let expiresAt: number | null;
|
|
592
|
+
try {
|
|
593
|
+
const exchanged = await client.exchangeCode(app, code, redirectUri);
|
|
594
|
+
token = exchanged.accessToken;
|
|
595
|
+
// Meta says how long a token lives in the exchange itself. Only when
|
|
596
|
+
// it does not is the token inspected (`debug_token`). A system user
|
|
597
|
+
// token (Login for Business with a system user configuration) never
|
|
598
|
+
// expires and is kept as it is.
|
|
599
|
+
expiresAt =
|
|
600
|
+
exchanged.expiresInMs !== null
|
|
601
|
+
? Date.now() + exchanged.expiresInMs
|
|
602
|
+
: (await client.debugToken(app, token)).expiresAt;
|
|
603
|
+
} catch (error) {
|
|
604
|
+
metaFailure(error);
|
|
605
|
+
}
|
|
606
|
+
// A token with under 7 days left (a user token from a login without a
|
|
607
|
+
// system user configuration lives for hours) is traded for a long-lived
|
|
608
|
+
// one, about 60 days. A failed trade never fails the login: the token
|
|
609
|
+
// that came is kept with its expiry, so the warning is up at once and
|
|
610
|
+
// the screen asks for a new login in time.
|
|
611
|
+
let exchangeError: string | null = null;
|
|
612
|
+
if (expiresAt !== null && expiresAt - Date.now() < EXPIRY_WARNING_MS) {
|
|
613
|
+
try {
|
|
614
|
+
const long = await client.exchangeLongLived(app, token);
|
|
615
|
+
const longExpiresAt =
|
|
616
|
+
long.expiresInMs !== null
|
|
617
|
+
? Date.now() + long.expiresInMs
|
|
618
|
+
: (await client.debugToken(app, long.accessToken)).expiresAt;
|
|
619
|
+
token = long.accessToken;
|
|
620
|
+
expiresAt = longExpiresAt;
|
|
621
|
+
} catch {
|
|
622
|
+
exchangeError = TOKEN_EXCHANGE_FAILED;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return await connectToken(ctx, ownerKey, tokenOf(token), client, {
|
|
626
|
+
expiresAt,
|
|
627
|
+
lastError: exchangeError,
|
|
628
|
+
});
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* The owner's ad accounts, read again from Meta with their pixels (and cached
|
|
634
|
+
* for linking and picking a pixel).
|
|
635
|
+
*/
|
|
636
|
+
export const listAdAccounts = action({
|
|
637
|
+
args: { ownerKey: v.string() },
|
|
638
|
+
returns: v.array(metaAdAccountValidator),
|
|
639
|
+
handler: async (ctx, args) => {
|
|
640
|
+
const ownerKey = ownerKeyOf(args.ownerKey);
|
|
641
|
+
const connection = await ctx.runQuery(internal.meta.loadConnection, {
|
|
642
|
+
ownerKey,
|
|
643
|
+
});
|
|
644
|
+
if (!connection) fail("NOT_FOUND", "no Meta connection for this owner");
|
|
645
|
+
const token = await decryptSecret(env.RASTRO_SECRETS_KEY, {
|
|
646
|
+
cipher: connection.tokenCipher,
|
|
647
|
+
iv: connection.tokenIv,
|
|
648
|
+
});
|
|
649
|
+
let adAccounts: MetaAdAccount[];
|
|
650
|
+
let pixels: MetaPixel[];
|
|
651
|
+
let pixelsComplete: boolean;
|
|
652
|
+
try {
|
|
653
|
+
const client = graph();
|
|
654
|
+
adAccounts = (await client.adAccounts(token)).map(toAccount);
|
|
655
|
+
({ pixels, complete: pixelsComplete } = await listPixels(
|
|
656
|
+
client,
|
|
657
|
+
token,
|
|
658
|
+
adAccounts,
|
|
659
|
+
));
|
|
660
|
+
} catch (error) {
|
|
661
|
+
if (error instanceof MetaGraphError && error.kind === "token") {
|
|
662
|
+
await ctx.runMutation(internal.meta.markConnectionInvalid, {
|
|
663
|
+
connectionId: connection.connectionId,
|
|
664
|
+
tokenIv: connection.tokenIv,
|
|
665
|
+
message: error.message,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
metaFailure(error);
|
|
669
|
+
}
|
|
670
|
+
await ctx.runMutation(internal.meta.updateConnectionAccounts, {
|
|
671
|
+
connectionId: connection.connectionId,
|
|
672
|
+
tokenIv: connection.tokenIv,
|
|
673
|
+
adAccounts,
|
|
674
|
+
pixels,
|
|
675
|
+
pixelsComplete,
|
|
676
|
+
});
|
|
677
|
+
return adAccounts;
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
async function connectionOf(ctx: QueryCtx, ownerKey: string) {
|
|
682
|
+
const key = ownerKeyOf(ownerKey);
|
|
683
|
+
return await ctx.db
|
|
684
|
+
.query("metaConnections")
|
|
685
|
+
.withIndex("by_ownerKey", (range) => range.eq("ownerKey", key))
|
|
686
|
+
.unique();
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function unlinkRow(
|
|
690
|
+
ctx: MutationCtx,
|
|
691
|
+
link: { _id: Id<"siteAdAccounts">; siteId: Id<"sites">; adAccountId: string },
|
|
692
|
+
) {
|
|
693
|
+
await ctx.db.delete("siteAdAccounts", link._id);
|
|
694
|
+
// The account's spend stops counting for the site; its history goes too.
|
|
695
|
+
await ctx.scheduler.runAfter(0, internal.metaSync.purgeSpend, {
|
|
696
|
+
siteId: link.siteId,
|
|
697
|
+
adAccountId: link.adAccountId,
|
|
698
|
+
cursor: null,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Forgets the owner: its token, every account it fed, and every pixel it set,
|
|
704
|
+
* the ones with their own token (plan B) included. The token goes at once (no
|
|
705
|
+
* run can load it after this), its links and pixels in scheduled batches. An
|
|
706
|
+
* owner with plan B pixels and no connection still loses those pixels.
|
|
707
|
+
*/
|
|
708
|
+
export const removeConnection = mutation({
|
|
709
|
+
args: { ownerKey: v.string() },
|
|
710
|
+
returns: v.object({ removed: v.boolean() }),
|
|
711
|
+
handler: async (ctx, args) => {
|
|
712
|
+
const ownerKey = ownerKeyOf(args.ownerKey);
|
|
713
|
+
const connection = await ctx.db
|
|
714
|
+
.query("metaConnections")
|
|
715
|
+
.withIndex("by_ownerKey", (range) => range.eq("ownerKey", ownerKey))
|
|
716
|
+
.unique();
|
|
717
|
+
if (connection) await ctx.db.delete("metaConnections", connection._id);
|
|
718
|
+
const removedPixels = await removeOwnerBatch(ctx, {
|
|
719
|
+
connectionId: connection?._id,
|
|
720
|
+
ownerKey,
|
|
721
|
+
before: Date.now(),
|
|
722
|
+
});
|
|
723
|
+
return { removed: connection !== null || removedPixels > 0 };
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* One batch of a removed owner: its connection's links and pixels, and the
|
|
729
|
+
* pixels it set (`setBy`) last changed by `before`, so a pixel the same owner
|
|
730
|
+
* sets again after the removal stays. The owner's pixels are walked in
|
|
731
|
+
* creation order from `after`, so every batch moves on even past the ones it
|
|
732
|
+
* keeps. Schedules the next batch while any is left and answers how many
|
|
733
|
+
* pixels this one removed.
|
|
734
|
+
*/
|
|
735
|
+
async function removeOwnerBatch(
|
|
736
|
+
ctx: MutationCtx,
|
|
737
|
+
owner: {
|
|
738
|
+
connectionId?: Id<"metaConnections"> | undefined;
|
|
739
|
+
ownerKey?: string | undefined;
|
|
740
|
+
before?: number | undefined;
|
|
741
|
+
after?: number | undefined;
|
|
742
|
+
},
|
|
743
|
+
) {
|
|
744
|
+
const { connectionId, ownerKey, before, after } = owner;
|
|
745
|
+
const links = connectionId
|
|
746
|
+
? await ctx.db
|
|
747
|
+
.query("siteAdAccounts")
|
|
748
|
+
.withIndex("by_connectionId", (range) =>
|
|
749
|
+
range.eq("connectionId", connectionId),
|
|
750
|
+
)
|
|
751
|
+
.take(UNLINK_BATCH)
|
|
752
|
+
: [];
|
|
753
|
+
for (const link of links) await unlinkRow(ctx, link);
|
|
754
|
+
// A pixel picked through the connection sent with its token: it goes too.
|
|
755
|
+
const connectionPixels = connectionId
|
|
756
|
+
? await ctx.db
|
|
757
|
+
.query("sitePixels")
|
|
758
|
+
.withIndex("by_connectionId", (range) =>
|
|
759
|
+
range.eq("connectionId", connectionId),
|
|
760
|
+
)
|
|
761
|
+
.take(UNLINK_BATCH)
|
|
762
|
+
: [];
|
|
763
|
+
for (const pixel of connectionPixels) {
|
|
764
|
+
await ctx.db.delete("sitePixels", pixel._id);
|
|
765
|
+
}
|
|
766
|
+
// So does every pixel the owner set with its own token (plan B), unless it
|
|
767
|
+
// was set again after the removal (`updatedAt` past `before`).
|
|
768
|
+
const ownerPixels =
|
|
769
|
+
ownerKey === undefined || before === undefined
|
|
770
|
+
? []
|
|
771
|
+
: await ctx.db
|
|
772
|
+
.query("sitePixels")
|
|
773
|
+
.withIndex("by_setBy", (range) =>
|
|
774
|
+
after === undefined
|
|
775
|
+
? range.eq("setBy", ownerKey)
|
|
776
|
+
: range.eq("setBy", ownerKey).gt("_creationTime", after),
|
|
777
|
+
)
|
|
778
|
+
.take(UNLINK_BATCH);
|
|
779
|
+
// `<=`: a pixel set in the removal's own millisecond, before it, must go;
|
|
780
|
+
// one set again in that same millisecond after it goes too (the rarer case).
|
|
781
|
+
const stale = ownerPixels.filter((pixel) => pixel.updatedAt <= before!);
|
|
782
|
+
for (const pixel of stale) await ctx.db.delete("sitePixels", pixel._id);
|
|
783
|
+
const cursor = ownerPixels.at(-1)?._creationTime;
|
|
784
|
+
if (
|
|
785
|
+
links.length === UNLINK_BATCH ||
|
|
786
|
+
connectionPixels.length === UNLINK_BATCH ||
|
|
787
|
+
ownerPixels.length === UNLINK_BATCH
|
|
788
|
+
) {
|
|
789
|
+
await ctx.scheduler.runAfter(0, internal.meta.unlinkConnection, {
|
|
790
|
+
...(connectionId ? { connectionId } : {}),
|
|
791
|
+
...(ownerKey === undefined || before === undefined
|
|
792
|
+
? {}
|
|
793
|
+
: { ownerKey, before }),
|
|
794
|
+
// A full owner batch goes on from its last row; otherwise from where
|
|
795
|
+
// this one started.
|
|
796
|
+
...(ownerPixels.length === UNLINK_BATCH && cursor !== undefined
|
|
797
|
+
? { after: cursor }
|
|
798
|
+
: after === undefined
|
|
799
|
+
? {}
|
|
800
|
+
: { after }),
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
return connectionPixels.length + stale.length;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** The next batch of a removed owner's links and pixels. */
|
|
807
|
+
export const unlinkConnection = internalMutation({
|
|
808
|
+
args: {
|
|
809
|
+
connectionId: v.optional(v.id("metaConnections")),
|
|
810
|
+
ownerKey: v.optional(v.string()),
|
|
811
|
+
/** The removal's time: the owner's pixels as they were then. */
|
|
812
|
+
before: v.optional(v.number()),
|
|
813
|
+
/** The `_creationTime` of the last owner pixel already walked. */
|
|
814
|
+
after: v.optional(v.number()),
|
|
815
|
+
},
|
|
816
|
+
returns: v.null(),
|
|
817
|
+
handler: async (ctx, args) => {
|
|
818
|
+
await removeOwnerBatch(ctx, args);
|
|
819
|
+
return null;
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
// ---------------------------------------------------------------------------
|
|
824
|
+
// Site ↔ ad account.
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Makes one of the owner's ad accounts a spend source for the site: a 90-day
|
|
828
|
+
* backfill with the catalog, then the live sync. The account must be in the
|
|
829
|
+
* connection's last listing. Linking an account another owner linked to the
|
|
830
|
+
* site moves the link to this owner, unless `onlyOwn` (the federated path,
|
|
831
|
+
* which never takes what another owner made: `CONFLICT`).
|
|
832
|
+
*/
|
|
833
|
+
export const linkAdAccount = mutation({
|
|
834
|
+
args: {
|
|
835
|
+
siteId: v.id("sites"),
|
|
836
|
+
ownerKey: v.string(),
|
|
837
|
+
adAccountId: v.string(),
|
|
838
|
+
onlyOwn: v.optional(v.boolean()),
|
|
839
|
+
},
|
|
840
|
+
returns: v.object({ linked: v.boolean() }),
|
|
841
|
+
handler: async (ctx, args) => {
|
|
842
|
+
const site = await ctx.db.get("sites", args.siteId);
|
|
843
|
+
if (!site) fail("NOT_FOUND", "site not found");
|
|
844
|
+
const adAccountId = adAccountIdOf(args.adAccountId);
|
|
845
|
+
const connection = await ctx.db
|
|
846
|
+
.query("metaConnections")
|
|
847
|
+
.withIndex("by_ownerKey", (range) =>
|
|
848
|
+
range.eq("ownerKey", ownerKeyOf(args.ownerKey)),
|
|
849
|
+
)
|
|
850
|
+
.unique();
|
|
851
|
+
if (!connection) fail("NOT_FOUND", "no Meta connection for this owner");
|
|
852
|
+
if (connection.status !== "active") {
|
|
853
|
+
fail(
|
|
854
|
+
"META_TOKEN_INVALID",
|
|
855
|
+
"the owner's Meta token is invalid; connect again",
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
const account = connection.adAccounts.find(
|
|
859
|
+
(row) => row.adAccountId === adAccountId,
|
|
860
|
+
);
|
|
861
|
+
if (!account) {
|
|
862
|
+
fail("NOT_FOUND", "the owner's Meta token does not list this ad account");
|
|
863
|
+
}
|
|
864
|
+
const now = Date.now();
|
|
865
|
+
// An ad account feeds one site: its spend is that client's.
|
|
866
|
+
const owners = await ctx.db
|
|
867
|
+
.query("siteAdAccounts")
|
|
868
|
+
.withIndex("by_adAccountId", (range) =>
|
|
869
|
+
range.eq("adAccountId", adAccountId),
|
|
870
|
+
)
|
|
871
|
+
.take(2);
|
|
872
|
+
if (owners.some((link) => link.siteId !== args.siteId)) {
|
|
873
|
+
fail("CONFLICT", "this ad account is already linked to another site");
|
|
874
|
+
}
|
|
875
|
+
const existing = owners.find((link) => link.siteId === args.siteId);
|
|
876
|
+
if (existing) {
|
|
877
|
+
if (existing.connectionId !== connection._id) {
|
|
878
|
+
if (args.onlyOwn)
|
|
879
|
+
fail(
|
|
880
|
+
"CONFLICT",
|
|
881
|
+
"this ad account is linked to the site by another owner on this host; unlink it there",
|
|
882
|
+
);
|
|
883
|
+
await ctx.db.patch("siteAdAccounts", existing._id, {
|
|
884
|
+
connectionId: connection._id,
|
|
885
|
+
...account,
|
|
886
|
+
lastError: undefined,
|
|
887
|
+
nextSyncAt: now,
|
|
888
|
+
});
|
|
889
|
+
await kickSyncLoop(ctx, now);
|
|
890
|
+
}
|
|
891
|
+
return { linked: true };
|
|
892
|
+
}
|
|
893
|
+
const links = await ctx.db
|
|
894
|
+
.query("siteAdAccounts")
|
|
895
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
896
|
+
.take(MAX_SITE_AD_ACCOUNTS);
|
|
897
|
+
if (links.length >= MAX_SITE_AD_ACCOUNTS) {
|
|
898
|
+
fail(
|
|
899
|
+
"LIMIT_EXCEEDED",
|
|
900
|
+
`a site links at most ${MAX_SITE_AD_ACCOUNTS} ad accounts`,
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
const siblings = await ctx.db
|
|
904
|
+
.query("siteAdAccounts")
|
|
905
|
+
.withIndex("by_connectionId", (range) =>
|
|
906
|
+
range.eq("connectionId", connection._id),
|
|
907
|
+
)
|
|
908
|
+
.take(MAX_LINKS_PER_CONNECTION);
|
|
909
|
+
if (siblings.length >= MAX_LINKS_PER_CONNECTION) {
|
|
910
|
+
fail(
|
|
911
|
+
"LIMIT_EXCEEDED",
|
|
912
|
+
`a Meta connection feeds at most ${MAX_LINKS_PER_CONNECTION} links`,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
await ctx.db.insert("siteAdAccounts", {
|
|
916
|
+
siteId: args.siteId,
|
|
917
|
+
connectionId: connection._id,
|
|
918
|
+
...account,
|
|
919
|
+
syncState: "backfill",
|
|
920
|
+
nextSyncAt: now,
|
|
921
|
+
createdAt: now,
|
|
922
|
+
});
|
|
923
|
+
await kickSyncLoop(ctx, now);
|
|
924
|
+
return { linked: true };
|
|
925
|
+
},
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
/** Stops an ad account feeding the site; its spend leaves the reports. */
|
|
929
|
+
export const unlinkAdAccount = mutation({
|
|
930
|
+
args: {
|
|
931
|
+
siteId: v.id("sites"),
|
|
932
|
+
adAccountId: v.string(),
|
|
933
|
+
/** Only this owner's link: another owner's is a `CONFLICT`. */
|
|
934
|
+
ownerKey: v.optional(v.string()),
|
|
935
|
+
},
|
|
936
|
+
returns: v.object({ unlinked: v.boolean() }),
|
|
937
|
+
handler: async (ctx, args) => {
|
|
938
|
+
const link = await ctx.db
|
|
939
|
+
.query("siteAdAccounts")
|
|
940
|
+
.withIndex("by_siteId_and_adAccountId", (range) =>
|
|
941
|
+
range
|
|
942
|
+
.eq("siteId", args.siteId)
|
|
943
|
+
.eq("adAccountId", adAccountIdOf(args.adAccountId)),
|
|
944
|
+
)
|
|
945
|
+
.unique();
|
|
946
|
+
if (!link) return { unlinked: false };
|
|
947
|
+
if (args.ownerKey !== undefined) {
|
|
948
|
+
const owner = await connectionOf(ctx, args.ownerKey);
|
|
949
|
+
if (owner?._id !== link.connectionId) {
|
|
950
|
+
fail(
|
|
951
|
+
"CONFLICT",
|
|
952
|
+
"this ad account is linked to the site by another owner on this host; unlink it there",
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
await unlinkRow(ctx, link);
|
|
957
|
+
return { unlinked: true };
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Asks the chain to sync every account of the site now. An account a run
|
|
963
|
+
* holds is marked, and runs again as soon as that run ends.
|
|
964
|
+
*/
|
|
965
|
+
export const syncNow = mutation({
|
|
966
|
+
args: { siteId: v.id("sites") },
|
|
967
|
+
returns: v.object({ scheduled: v.boolean() }),
|
|
968
|
+
handler: async (ctx, args) => {
|
|
969
|
+
const links = await ctx.db
|
|
970
|
+
.query("siteAdAccounts")
|
|
971
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
972
|
+
.take(MAX_SITE_AD_ACCOUNTS);
|
|
973
|
+
const now = Date.now();
|
|
974
|
+
let scheduled = false;
|
|
975
|
+
for (const link of links) {
|
|
976
|
+
// A dead token needs a new one, not a retry.
|
|
977
|
+
if (link.nextSyncAt === PAUSED_AT) continue;
|
|
978
|
+
scheduled = true;
|
|
979
|
+
if (link.runId !== undefined) {
|
|
980
|
+
await ctx.db.patch("siteAdAccounts", link._id, { syncRequested: true });
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
await ctx.db.patch("siteAdAccounts", link._id, { nextSyncAt: now });
|
|
984
|
+
}
|
|
985
|
+
if (scheduled) await kickSyncLoop(ctx, now);
|
|
986
|
+
return { scheduled };
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
// ---------------------------------------------------------------------------
|
|
991
|
+
// Pixel.
|
|
992
|
+
|
|
993
|
+
export const savePixel = internalMutation({
|
|
994
|
+
args: {
|
|
995
|
+
siteId: v.id("sites"),
|
|
996
|
+
pixelId: v.string(),
|
|
997
|
+
sealed: v.union(
|
|
998
|
+
v.object({ tokenCipher: v.string(), tokenIv: v.string() }),
|
|
999
|
+
v.object({ ownerKey: v.string() }),
|
|
1000
|
+
),
|
|
1001
|
+
enabled: v.boolean(),
|
|
1002
|
+
testEventCode: v.union(v.string(), v.null()),
|
|
1003
|
+
sendLeadEvent: v.union(v.boolean(), v.null()),
|
|
1004
|
+
/** The owner setting the pixel, if the host named one. */
|
|
1005
|
+
setBy: v.union(v.string(), v.null()),
|
|
1006
|
+
/** Refuse to replace a pixel another owner (or the host) set. */
|
|
1007
|
+
onlyOwn: v.boolean(),
|
|
1008
|
+
},
|
|
1009
|
+
returns: v.null(),
|
|
1010
|
+
handler: async (ctx, args) => {
|
|
1011
|
+
let source: Doc<"sitePixels">["source"] = "token";
|
|
1012
|
+
let fromConnection: Partial<Doc<"sitePixels">> = {};
|
|
1013
|
+
if ("ownerKey" in args.sealed) {
|
|
1014
|
+
// Checked again here: the connection may have changed since the
|
|
1015
|
+
// action read it.
|
|
1016
|
+
const ownerKey = args.sealed.ownerKey;
|
|
1017
|
+
const connection = await ctx.db
|
|
1018
|
+
.query("metaConnections")
|
|
1019
|
+
.withIndex("by_ownerKey", (range) => range.eq("ownerKey", ownerKey))
|
|
1020
|
+
.unique();
|
|
1021
|
+
const pixel = connection?.pixels?.find(
|
|
1022
|
+
(row) => row.pixelId === args.pixelId,
|
|
1023
|
+
);
|
|
1024
|
+
if (!connection) fail("NOT_FOUND", "no Meta connection for this owner");
|
|
1025
|
+
if (connection.status !== "active") {
|
|
1026
|
+
fail(
|
|
1027
|
+
"META_TOKEN_INVALID",
|
|
1028
|
+
"the owner's Meta token is invalid; connect again",
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
if (!pixel) {
|
|
1032
|
+
fail(
|
|
1033
|
+
"NOT_FOUND",
|
|
1034
|
+
"the owner's Meta connection does not list this pixel",
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
source = "connection";
|
|
1038
|
+
fromConnection = { connectionId: connection._id, name: pixel.name };
|
|
1039
|
+
}
|
|
1040
|
+
const existing = await ctx.db
|
|
1041
|
+
.query("sitePixels")
|
|
1042
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
1043
|
+
.unique();
|
|
1044
|
+
if (
|
|
1045
|
+
args.onlyOwn &&
|
|
1046
|
+
existing &&
|
|
1047
|
+
existing.setBy !== (args.setBy ?? undefined)
|
|
1048
|
+
) {
|
|
1049
|
+
fail(
|
|
1050
|
+
"CONFLICT",
|
|
1051
|
+
"the site's pixel was set by another owner on this host; change it there",
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
const pipeline = await ctx.db
|
|
1055
|
+
.query("sitePipelines")
|
|
1056
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
1057
|
+
.unique();
|
|
1058
|
+
const fields = {
|
|
1059
|
+
siteId: args.siteId,
|
|
1060
|
+
pixelId: args.pixelId,
|
|
1061
|
+
source,
|
|
1062
|
+
...fromConnection,
|
|
1063
|
+
...(args.setBy ? { setBy: args.setBy } : {}),
|
|
1064
|
+
...("tokenCipher" in args.sealed
|
|
1065
|
+
? { tokenCipher: args.sealed.tokenCipher, tokenIv: args.sealed.tokenIv }
|
|
1066
|
+
: {}),
|
|
1067
|
+
enabled: args.enabled,
|
|
1068
|
+
...(args.testEventCode ? { testEventCode: args.testEventCode } : {}),
|
|
1069
|
+
// Unsaid, `Lead` keeps what the site had (the event map's choice).
|
|
1070
|
+
sendLeadEvent:
|
|
1071
|
+
args.sendLeadEvent ??
|
|
1072
|
+
pipeline?.sendLeadEvent ??
|
|
1073
|
+
existing?.sendLeadEvent ??
|
|
1074
|
+
false,
|
|
1075
|
+
updatedAt: Date.now(),
|
|
1076
|
+
};
|
|
1077
|
+
if (existing) await ctx.db.replace("sitePixels", existing._id, fields);
|
|
1078
|
+
else await ctx.db.insert("sitePixels", fields);
|
|
1079
|
+
if (pipeline && pipeline.sendLeadEvent !== fields.sendLeadEvent) {
|
|
1080
|
+
await ctx.db.patch("sitePipelines", pipeline._id, {
|
|
1081
|
+
sendLeadEvent: fields.sendLeadEvent,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
return null;
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
export const siteExists = internalQuery({
|
|
1089
|
+
args: { siteId: v.id("sites") },
|
|
1090
|
+
returns: v.boolean(),
|
|
1091
|
+
handler: async (ctx, args) =>
|
|
1092
|
+
(await ctx.db.get("sites", args.siteId)) !== null,
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Sets the site's Meta pixel (dataset) one of two ways:
|
|
1097
|
+
* - `{ ownerKey }`: a pixel from that owner's connection listing, sending with
|
|
1098
|
+
* the connection's token (a new token for the connection carries over);
|
|
1099
|
+
* - `{ accessToken }`: a Conversions API token from Events Manager (plan B),
|
|
1100
|
+
* checked against the pixel and sealed like a connection's.
|
|
1101
|
+
* `Lead` for a new lead is off unless `sendLeadEvent` (or the event map) says
|
|
1102
|
+
* so.
|
|
1103
|
+
*/
|
|
1104
|
+
export const setPixel = action({
|
|
1105
|
+
args: {
|
|
1106
|
+
siteId: v.id("sites"),
|
|
1107
|
+
pixelId: v.string(),
|
|
1108
|
+
accessToken: v.optional(v.string()),
|
|
1109
|
+
ownerKey: v.optional(v.string()),
|
|
1110
|
+
enabled: v.boolean(),
|
|
1111
|
+
testEventCode: v.optional(v.string()),
|
|
1112
|
+
sendLeadEvent: v.optional(v.boolean()),
|
|
1113
|
+
/**
|
|
1114
|
+
* The owner setting a token-sourced pixel (a connection-sourced one is
|
|
1115
|
+
* its `ownerKey`'s). With `onlyOwn`, a pixel another owner set is a
|
|
1116
|
+
* `CONFLICT` instead of being replaced.
|
|
1117
|
+
*/
|
|
1118
|
+
setBy: v.optional(v.string()),
|
|
1119
|
+
onlyOwn: v.optional(v.boolean()),
|
|
1120
|
+
},
|
|
1121
|
+
returns: v.object({ ok: v.literal(true) }),
|
|
1122
|
+
handler: async (ctx, args) => {
|
|
1123
|
+
const pixelId = args.pixelId.trim();
|
|
1124
|
+
if (!PIXEL_ID.test(pixelId)) {
|
|
1125
|
+
fail("INVALID_ARGUMENT", "pixelId must be the dataset's numeric id");
|
|
1126
|
+
}
|
|
1127
|
+
if ((args.accessToken === undefined) === (args.ownerKey === undefined)) {
|
|
1128
|
+
fail("INVALID_ARGUMENT", "pass either accessToken or ownerKey");
|
|
1129
|
+
}
|
|
1130
|
+
const testEventCode = args.testEventCode?.trim() || null;
|
|
1131
|
+
if (
|
|
1132
|
+
testEventCode !== null &&
|
|
1133
|
+
(testEventCode.length > MAX_TEST_EVENT_CODE ||
|
|
1134
|
+
!/^[A-Za-z0-9_-]+$/.test(testEventCode))
|
|
1135
|
+
) {
|
|
1136
|
+
fail("INVALID_ARGUMENT", "testEventCode is invalid");
|
|
1137
|
+
}
|
|
1138
|
+
const save = {
|
|
1139
|
+
siteId: args.siteId,
|
|
1140
|
+
pixelId,
|
|
1141
|
+
enabled: args.enabled,
|
|
1142
|
+
testEventCode,
|
|
1143
|
+
sendLeadEvent: args.sendLeadEvent ?? null,
|
|
1144
|
+
setBy:
|
|
1145
|
+
args.ownerKey !== undefined
|
|
1146
|
+
? ownerKeyOf(args.ownerKey)
|
|
1147
|
+
: args.setBy !== undefined
|
|
1148
|
+
? ownerKeyOf(args.setBy)
|
|
1149
|
+
: null,
|
|
1150
|
+
onlyOwn: args.onlyOwn ?? false,
|
|
1151
|
+
};
|
|
1152
|
+
if (args.ownerKey !== undefined) {
|
|
1153
|
+
const ownerKey = ownerKeyOf(args.ownerKey);
|
|
1154
|
+
if (
|
|
1155
|
+
!(await ctx.runQuery(internal.meta.siteExists, { siteId: args.siteId }))
|
|
1156
|
+
) {
|
|
1157
|
+
fail("NOT_FOUND", "site not found");
|
|
1158
|
+
}
|
|
1159
|
+
const connection = await ctx.runQuery(internal.meta.loadConnection, {
|
|
1160
|
+
ownerKey,
|
|
1161
|
+
});
|
|
1162
|
+
if (!connection) fail("NOT_FOUND", "no Meta connection for this owner");
|
|
1163
|
+
if (connection.status !== "active") {
|
|
1164
|
+
fail(
|
|
1165
|
+
"META_TOKEN_INVALID",
|
|
1166
|
+
"the owner's Meta token is invalid; connect again",
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
await ctx.runMutation(internal.meta.savePixel, {
|
|
1170
|
+
...save,
|
|
1171
|
+
sealed: { ownerKey },
|
|
1172
|
+
});
|
|
1173
|
+
return { ok: true as const };
|
|
1174
|
+
}
|
|
1175
|
+
const token = tokenOf(args.accessToken ?? "");
|
|
1176
|
+
requireKey();
|
|
1177
|
+
if (
|
|
1178
|
+
!(await ctx.runQuery(internal.meta.siteExists, { siteId: args.siteId }))
|
|
1179
|
+
) {
|
|
1180
|
+
fail("NOT_FOUND", "site not found");
|
|
1181
|
+
}
|
|
1182
|
+
try {
|
|
1183
|
+
await graph().pixel(token, pixelId);
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
metaFailure(error);
|
|
1186
|
+
}
|
|
1187
|
+
const sealed = await encryptSecret(env.RASTRO_SECRETS_KEY, token);
|
|
1188
|
+
await ctx.runMutation(internal.meta.savePixel, {
|
|
1189
|
+
...save,
|
|
1190
|
+
sealed: { tokenCipher: sealed.cipher, tokenIv: sealed.iv },
|
|
1191
|
+
});
|
|
1192
|
+
return { ok: true as const };
|
|
1193
|
+
},
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* The sealed token a site's pixel sends with, for the Conversions API sender:
|
|
1198
|
+
* its own (`token`), or its connection's while that connection is active
|
|
1199
|
+
* (`connection`). Null when there is none to send with: no pixel, a disabled
|
|
1200
|
+
* one, a token Meta refused, or a connection Meta refused or that was
|
|
1201
|
+
* removed. When Meta refuses a `connection` token, mark it with
|
|
1202
|
+
* `internal.meta.markConnectionInvalid({ connectionId, message })`.
|
|
1203
|
+
*/
|
|
1204
|
+
export const loadPixelToken = internalQuery({
|
|
1205
|
+
args: { siteId: v.id("sites") },
|
|
1206
|
+
returns: v.union(
|
|
1207
|
+
v.null(),
|
|
1208
|
+
v.object({
|
|
1209
|
+
pixelId: v.string(),
|
|
1210
|
+
tokenCipher: v.string(),
|
|
1211
|
+
tokenIv: v.string(),
|
|
1212
|
+
source: metaPixelSourceValidator,
|
|
1213
|
+
/** For `connection`: the one to mark invalid when Meta refuses it. */
|
|
1214
|
+
connectionId: v.optional(v.id("metaConnections")),
|
|
1215
|
+
}),
|
|
1216
|
+
),
|
|
1217
|
+
handler: async (ctx, args) => {
|
|
1218
|
+
const pixel = await ctx.db
|
|
1219
|
+
.query("sitePixels")
|
|
1220
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
1221
|
+
.unique();
|
|
1222
|
+
if (!pixel?.enabled) return null;
|
|
1223
|
+
if (pixel.source === "connection") {
|
|
1224
|
+
const connection = pixel.connectionId
|
|
1225
|
+
? await ctx.db.get("metaConnections", pixel.connectionId)
|
|
1226
|
+
: null;
|
|
1227
|
+
if (connection?.status !== "active") return null;
|
|
1228
|
+
// The token no longer lists the pixel (another business reconnected).
|
|
1229
|
+
if (!connection.pixels?.some((row) => row.pixelId === pixel.pixelId)) {
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1232
|
+
return {
|
|
1233
|
+
pixelId: pixel.pixelId,
|
|
1234
|
+
tokenCipher: connection.tokenCipher,
|
|
1235
|
+
tokenIv: connection.tokenIv,
|
|
1236
|
+
source: "connection" as const,
|
|
1237
|
+
connectionId: connection._id,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
// A pixel whose own token Meta refused waits for a new one.
|
|
1241
|
+
if (pixel.invalidSince !== undefined) return null;
|
|
1242
|
+
if (!pixel.tokenCipher || !pixel.tokenIv) return null;
|
|
1243
|
+
return {
|
|
1244
|
+
pixelId: pixel.pixelId,
|
|
1245
|
+
tokenCipher: pixel.tokenCipher,
|
|
1246
|
+
tokenIv: pixel.tokenIv,
|
|
1247
|
+
source: "token" as const,
|
|
1248
|
+
};
|
|
1249
|
+
},
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
export const removePixel = mutation({
|
|
1253
|
+
args: {
|
|
1254
|
+
siteId: v.id("sites"),
|
|
1255
|
+
/** Only this owner's pixel: another owner's is a `CONFLICT`. */
|
|
1256
|
+
ownerKey: v.optional(v.string()),
|
|
1257
|
+
},
|
|
1258
|
+
returns: v.object({ removed: v.boolean() }),
|
|
1259
|
+
handler: async (ctx, args) => {
|
|
1260
|
+
const pixel = await ctx.db
|
|
1261
|
+
.query("sitePixels")
|
|
1262
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
1263
|
+
.unique();
|
|
1264
|
+
if (!pixel) return { removed: false };
|
|
1265
|
+
if (
|
|
1266
|
+
args.ownerKey !== undefined &&
|
|
1267
|
+
pixel.setBy !== ownerKeyOf(args.ownerKey)
|
|
1268
|
+
) {
|
|
1269
|
+
fail(
|
|
1270
|
+
"CONFLICT",
|
|
1271
|
+
"the site's pixel was set by another owner on this host; remove it there",
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
await ctx.db.delete("sitePixels", pixel._id);
|
|
1275
|
+
return { removed: true };
|
|
1276
|
+
},
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
// ---------------------------------------------------------------------------
|
|
1280
|
+
// Reads.
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Where each site stands with Meta. A site with nothing linked says so. With
|
|
1284
|
+
* `ownerKey`, each link and the pixel say whether that owner made them
|
|
1285
|
+
* (`own`), so a screen can lock what another owner made.
|
|
1286
|
+
*/
|
|
1287
|
+
export const status = query({
|
|
1288
|
+
args: {
|
|
1289
|
+
siteIds: v.array(v.id("sites")),
|
|
1290
|
+
ownerKey: v.optional(v.string()),
|
|
1291
|
+
/** The reader's clock, for the sent counts of the last 30 days. */
|
|
1292
|
+
now: v.optional(v.number()),
|
|
1293
|
+
},
|
|
1294
|
+
returns: metaStatusValidator,
|
|
1295
|
+
handler: async (ctx, args) => {
|
|
1296
|
+
const owner =
|
|
1297
|
+
args.ownerKey === undefined
|
|
1298
|
+
? undefined
|
|
1299
|
+
: ((await connectionOf(ctx, args.ownerKey)) ?? null);
|
|
1300
|
+
const ownerKey =
|
|
1301
|
+
args.ownerKey === undefined ? undefined : ownerKeyOf(args.ownerKey);
|
|
1302
|
+
const siteIds = [...new Set(args.siteIds)];
|
|
1303
|
+
if (
|
|
1304
|
+
siteIds.length < 1 ||
|
|
1305
|
+
siteIds.length > MAX_REPORT_SITES ||
|
|
1306
|
+
siteIds.length !== args.siteIds.length
|
|
1307
|
+
) {
|
|
1308
|
+
fail(
|
|
1309
|
+
"INVALID_ARGUMENT",
|
|
1310
|
+
`siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`,
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
const rows = [];
|
|
1314
|
+
for (const siteId of siteIds) {
|
|
1315
|
+
const site = await ctx.db.get("sites", siteId);
|
|
1316
|
+
if (!site) fail("NOT_FOUND", "site not found");
|
|
1317
|
+
const links = await ctx.db
|
|
1318
|
+
.query("siteAdAccounts")
|
|
1319
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1320
|
+
.take(MAX_SITE_AD_ACCOUNTS);
|
|
1321
|
+
const pixel = await ctx.db
|
|
1322
|
+
.query("sitePixels")
|
|
1323
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1324
|
+
.unique();
|
|
1325
|
+
rows.push({
|
|
1326
|
+
siteId,
|
|
1327
|
+
linked: links.length > 0,
|
|
1328
|
+
adAccounts: links.map((link) => ({
|
|
1329
|
+
adAccountId: link.adAccountId,
|
|
1330
|
+
name: link.name,
|
|
1331
|
+
currency: link.currency,
|
|
1332
|
+
timezone: link.timezone,
|
|
1333
|
+
syncState: link.syncState,
|
|
1334
|
+
lastSyncAt: link.lastSyncAt ?? null,
|
|
1335
|
+
lastError: link.lastError ?? null,
|
|
1336
|
+
backfill: backfillProgress(link),
|
|
1337
|
+
...(owner === undefined
|
|
1338
|
+
? {}
|
|
1339
|
+
: { own: owner?._id === link.connectionId }),
|
|
1340
|
+
})),
|
|
1341
|
+
pixel: {
|
|
1342
|
+
enabled: pixel?.enabled ?? false,
|
|
1343
|
+
pixelId: pixel?.pixelId ?? null,
|
|
1344
|
+
source: pixel ? (pixel.source ?? "token") : null,
|
|
1345
|
+
name: pixel?.name ?? null,
|
|
1346
|
+
lastError: pixel?.lastError ?? null,
|
|
1347
|
+
...(ownerKey === undefined || !pixel
|
|
1348
|
+
? {}
|
|
1349
|
+
: { own: pixel.setBy === ownerKey }),
|
|
1350
|
+
testMode: pixel?.testEventCode !== undefined,
|
|
1351
|
+
},
|
|
1352
|
+
conversions: await conversionCounts(ctx, siteId, args.now),
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
return rows;
|
|
1356
|
+
},
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
/** The site's latest conversions for its Meta pixel, newest first. */
|
|
1360
|
+
export const conversions = query({
|
|
1361
|
+
args: {
|
|
1362
|
+
siteId: v.id("sites"),
|
|
1363
|
+
limit: v.optional(v.number()),
|
|
1364
|
+
before: v.optional(v.number()),
|
|
1365
|
+
/** The last row's `eventId`, with its `createdAt` as `before`. */
|
|
1366
|
+
beforeEventId: v.optional(v.string()),
|
|
1367
|
+
},
|
|
1368
|
+
returns: v.array(metaConversionValidator),
|
|
1369
|
+
handler: async (ctx, args) => {
|
|
1370
|
+
const limit = args.limit ?? MAX_LEAD_LIST;
|
|
1371
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LEAD_LIST) {
|
|
1372
|
+
fail(
|
|
1373
|
+
"INVALID_ARGUMENT",
|
|
1374
|
+
`limit must be an integer between 1 and ${MAX_LEAD_LIST}`,
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
const site = await ctx.db.get("sites", args.siteId);
|
|
1378
|
+
if (!site) fail("NOT_FOUND", "site not found");
|
|
1379
|
+
return await listConversions(ctx, {
|
|
1380
|
+
siteId: args.siteId,
|
|
1381
|
+
limit,
|
|
1382
|
+
...(args.before !== undefined ? { before: args.before } : {}),
|
|
1383
|
+
...(args.beforeEventId !== undefined
|
|
1384
|
+
? { beforeEventId: args.beforeEventId }
|
|
1385
|
+
: {}),
|
|
1386
|
+
});
|
|
1387
|
+
},
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* The owner's connection as a screen shows it: status, who authorized it, the
|
|
1392
|
+
* listed ad accounts (each with the site it already feeds, whoever linked it,
|
|
1393
|
+
* since an account feeds one site) and the listed pixels. Never a token.
|
|
1394
|
+
*/
|
|
1395
|
+
export const connection = query({
|
|
1396
|
+
args: { ownerKey: v.string() },
|
|
1397
|
+
returns: metaConnectionValidator,
|
|
1398
|
+
handler: async (ctx, args) => {
|
|
1399
|
+
const connection = await ctx.db
|
|
1400
|
+
.query("metaConnections")
|
|
1401
|
+
.withIndex("by_ownerKey", (range) =>
|
|
1402
|
+
range.eq("ownerKey", ownerKeyOf(args.ownerKey)),
|
|
1403
|
+
)
|
|
1404
|
+
.unique();
|
|
1405
|
+
if (!connection) return null;
|
|
1406
|
+
const adAccounts = [];
|
|
1407
|
+
// One index read per listed account (the listing is capped by Meta's).
|
|
1408
|
+
for (const account of connection.adAccounts) {
|
|
1409
|
+
const link = await ctx.db
|
|
1410
|
+
.query("siteAdAccounts")
|
|
1411
|
+
.withIndex("by_adAccountId", (range) =>
|
|
1412
|
+
range.eq("adAccountId", account.adAccountId),
|
|
1413
|
+
)
|
|
1414
|
+
.first();
|
|
1415
|
+
adAccounts.push({ ...account, linkedSiteId: link?.siteId ?? null });
|
|
1416
|
+
}
|
|
1417
|
+
return {
|
|
1418
|
+
status: connection.status,
|
|
1419
|
+
businessName: connection.businessName ?? null,
|
|
1420
|
+
authorizedBy: connection.authorizedBy
|
|
1421
|
+
? { name: connection.authorizedBy.name }
|
|
1422
|
+
: null,
|
|
1423
|
+
authorizedAt: connection.authorizedAt ?? null,
|
|
1424
|
+
invalidSince:
|
|
1425
|
+
connection.status === "invalid"
|
|
1426
|
+
? (connection.invalidSince ?? connection.lastCheckedAt)
|
|
1427
|
+
: null,
|
|
1428
|
+
lastError: connection.lastError ?? null,
|
|
1429
|
+
expiresAt: connection.tokenExpiresAt ?? null,
|
|
1430
|
+
expiringSoon: connection.expiringSoon === true,
|
|
1431
|
+
adAccounts,
|
|
1432
|
+
pixels: connection.pixels ?? [],
|
|
1433
|
+
};
|
|
1434
|
+
},
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* What unlinking the account takes out of the site's reports: its spend, over
|
|
1439
|
+
* how many days, across how many campaigns (the unlink dialog's figures).
|
|
1440
|
+
*/
|
|
1441
|
+
export const unlinkPreview = query({
|
|
1442
|
+
args: { siteId: v.id("sites"), adAccountId: v.string() },
|
|
1443
|
+
returns: metaUnlinkPreviewValidator,
|
|
1444
|
+
handler: async (ctx, args) => {
|
|
1445
|
+
const adAccountId = adAccountIdOf(args.adAccountId);
|
|
1446
|
+
const link = await ctx.db
|
|
1447
|
+
.query("siteAdAccounts")
|
|
1448
|
+
.withIndex("by_siteId_and_adAccountId", (range) =>
|
|
1449
|
+
range.eq("siteId", args.siteId).eq("adAccountId", adAccountId),
|
|
1450
|
+
)
|
|
1451
|
+
.unique();
|
|
1452
|
+
if (!link) fail("NOT_FOUND", "this ad account is not linked to the site");
|
|
1453
|
+
// One row per account-local day: a few hundred at most.
|
|
1454
|
+
const days = await ctx.db
|
|
1455
|
+
.query("siteSpendDaily")
|
|
1456
|
+
.withIndex("by_siteId_and_adAccountId_and_dayStart", (range) =>
|
|
1457
|
+
range.eq("siteId", args.siteId).eq("adAccountId", adAccountId),
|
|
1458
|
+
)
|
|
1459
|
+
.take(MAX_PREVIEW_DAYS);
|
|
1460
|
+
let spendCents = 0;
|
|
1461
|
+
let first: number | null = null;
|
|
1462
|
+
let last: number | null = null;
|
|
1463
|
+
for (const day of days) {
|
|
1464
|
+
if (day.spendCents <= 0) continue;
|
|
1465
|
+
spendCents += day.spendCents;
|
|
1466
|
+
first = first === null ? day.dayStart : Math.min(first, day.dayStart);
|
|
1467
|
+
last = last === null ? day.dayStart : Math.max(last, day.dayStart);
|
|
1468
|
+
}
|
|
1469
|
+
const catalog = await ctx.db
|
|
1470
|
+
.query("adCatalog")
|
|
1471
|
+
.withIndex("by_siteId_and_adId", (range) =>
|
|
1472
|
+
range.eq("siteId", args.siteId),
|
|
1473
|
+
)
|
|
1474
|
+
.take(MAX_PREVIEW_CATALOG);
|
|
1475
|
+
const campaigns = new Set(
|
|
1476
|
+
catalog
|
|
1477
|
+
.filter((ad) => ad.adAccountId === adAccountId && ad.campaignId)
|
|
1478
|
+
.map((ad) => ad.campaignId),
|
|
1479
|
+
);
|
|
1480
|
+
return {
|
|
1481
|
+
spendCents,
|
|
1482
|
+
currency: link.currency,
|
|
1483
|
+
days:
|
|
1484
|
+
first === null || last === null
|
|
1485
|
+
? 0
|
|
1486
|
+
: Math.round((last - first) / 86_400_000) + 1,
|
|
1487
|
+
campaigns: campaigns.size,
|
|
1488
|
+
// A read cap was hit: the figures are a lower bound.
|
|
1489
|
+
partial:
|
|
1490
|
+
days.length === MAX_PREVIEW_DAYS ||
|
|
1491
|
+
catalog.length === MAX_PREVIEW_CATALOG,
|
|
1492
|
+
};
|
|
1493
|
+
},
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* The sites an owner's connection reaches: those its links feed and those
|
|
1498
|
+
* whose pixel it set. The federated whole-owner writes refuse while any of
|
|
1499
|
+
* them is outside the caller's grant.
|
|
1500
|
+
*/
|
|
1501
|
+
export const connectionSites = query({
|
|
1502
|
+
args: { ownerKey: v.string() },
|
|
1503
|
+
returns: v.array(v.string()),
|
|
1504
|
+
handler: async (ctx, args) => {
|
|
1505
|
+
const connection = await connectionOf(ctx, args.ownerKey);
|
|
1506
|
+
if (!connection) return [];
|
|
1507
|
+
const ownerKey = connection.ownerKey;
|
|
1508
|
+
const links = await ctx.db
|
|
1509
|
+
.query("siteAdAccounts")
|
|
1510
|
+
.withIndex("by_connectionId", (range) =>
|
|
1511
|
+
range.eq("connectionId", connection._id),
|
|
1512
|
+
)
|
|
1513
|
+
.take(MAX_LINKS_PER_CONNECTION);
|
|
1514
|
+
const pixels = await ctx.db
|
|
1515
|
+
.query("sitePixels")
|
|
1516
|
+
.withIndex("by_setBy", (range) => range.eq("setBy", ownerKey))
|
|
1517
|
+
.take(MAX_LINKS_PER_CONNECTION);
|
|
1518
|
+
return [
|
|
1519
|
+
...new Set([
|
|
1520
|
+
...links.map((link) => link.siteId),
|
|
1521
|
+
...pixels.map((pixel) => pixel.siteId),
|
|
1522
|
+
]),
|
|
1523
|
+
];
|
|
1524
|
+
},
|
|
1525
|
+
});
|