@iann29/rastro 0.9.0 → 0.10.0

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