@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.
Files changed (265) 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 +642 -8
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +391 -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 +278 -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 +358 -0
  35. package/dist/component/meta.d.ts.map +1 -0
  36. package/dist/component/meta.js +1295 -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 +99 -0
  55. package/dist/component/pipeline.d.ts.map +1 -0
  56. package/dist/component/pipeline.js +337 -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 +20 -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 +101 -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 +42 -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 +143 -0
  137. package/dist/ui/integrations/ClientsTable.js.map +1 -0
  138. package/dist/ui/integrations/Connect.d.ts +35 -0
  139. package/dist/ui/integrations/Connect.d.ts.map +1 -0
  140. package/dist/ui/integrations/Connect.js +62 -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 +108 -0
  145. package/dist/ui/integrations/Connection.js.map +1 -0
  146. package/dist/ui/integrations/Dialogs.d.ts +21 -0
  147. package/dist/ui/integrations/Dialogs.d.ts.map +1 -0
  148. package/dist/ui/integrations/Dialogs.js +34 -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 +61 -0
  153. package/dist/ui/integrations/EventsFeed.js.map +1 -0
  154. package/dist/ui/integrations/EventsSheet.d.ts +34 -0
  155. package/dist/ui/integrations/EventsSheet.d.ts.map +1 -0
  156. package/dist/ui/integrations/EventsSheet.js +136 -0
  157. package/dist/ui/integrations/EventsSheet.js.map +1 -0
  158. package/dist/ui/integrations/Icons.d.ts +38 -0
  159. package/dist/ui/integrations/Icons.d.ts.map +1 -0
  160. package/dist/ui/integrations/Icons.js +40 -0
  161. package/dist/ui/integrations/Icons.js.map +1 -0
  162. package/dist/ui/integrations/IntegrationsView.d.ts +15 -0
  163. package/dist/ui/integrations/IntegrationsView.d.ts.map +1 -0
  164. package/dist/ui/integrations/IntegrationsView.js +141 -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/Partner.d.ts +14 -0
  171. package/dist/ui/integrations/Partner.d.ts.map +1 -0
  172. package/dist/ui/integrations/Partner.js +38 -0
  173. package/dist/ui/integrations/Partner.js.map +1 -0
  174. package/dist/ui/integrations/RowMenu.d.ts +15 -0
  175. package/dist/ui/integrations/RowMenu.d.ts.map +1 -0
  176. package/dist/ui/integrations/RowMenu.js +47 -0
  177. package/dist/ui/integrations/RowMenu.js.map +1 -0
  178. package/dist/ui/integrations/UnlinkDialog.d.ts +18 -0
  179. package/dist/ui/integrations/UnlinkDialog.d.ts.map +1 -0
  180. package/dist/ui/integrations/UnlinkDialog.js +29 -0
  181. package/dist/ui/integrations/UnlinkDialog.js.map +1 -0
  182. package/dist/ui/integrations/audience.d.ts +12 -0
  183. package/dist/ui/integrations/audience.d.ts.map +1 -0
  184. package/dist/ui/integrations/audience.js +12 -0
  185. package/dist/ui/integrations/audience.js.map +1 -0
  186. package/dist/ui/integrations/format.d.ts +28 -0
  187. package/dist/ui/integrations/format.d.ts.map +1 -0
  188. package/dist/ui/integrations/format.js +101 -0
  189. package/dist/ui/integrations/format.js.map +1 -0
  190. package/dist/ui/integrations/index.d.ts +5 -0
  191. package/dist/ui/integrations/index.d.ts.map +1 -0
  192. package/dist/ui/integrations/index.js +3 -0
  193. package/dist/ui/integrations/index.js.map +1 -0
  194. package/dist/ui/integrations/modal.d.ts +10 -0
  195. package/dist/ui/integrations/modal.d.ts.map +1 -0
  196. package/dist/ui/integrations/modal.js +77 -0
  197. package/dist/ui/integrations/modal.js.map +1 -0
  198. package/dist/ui/integrations/model.d.ts +99 -0
  199. package/dist/ui/integrations/model.d.ts.map +1 -0
  200. package/dist/ui/integrations/model.js +164 -0
  201. package/dist/ui/integrations/model.js.map +1 -0
  202. package/dist/ui/integrations/parts.d.ts +36 -0
  203. package/dist/ui/integrations/parts.d.ts.map +1 -0
  204. package/dist/ui/integrations/parts.js +85 -0
  205. package/dist/ui/integrations/parts.js.map +1 -0
  206. package/dist/ui/integrations/types.d.ts +146 -0
  207. package/dist/ui/integrations/types.d.ts.map +1 -0
  208. package/dist/ui/integrations/types.js +2 -0
  209. package/dist/ui/integrations/types.js.map +1 -0
  210. package/dist/ui/metrics.d.ts +106 -0
  211. package/dist/ui/metrics.d.ts.map +1 -0
  212. package/dist/ui/metrics.js +166 -0
  213. package/dist/ui/metrics.js.map +1 -0
  214. package/dist/ui/portfolio/PortfolioView.d.ts +26 -0
  215. package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -0
  216. package/dist/ui/portfolio/PortfolioView.js +216 -0
  217. package/dist/ui/portfolio/PortfolioView.js.map +1 -0
  218. package/dist/ui/portfolio/SpendChart.d.ts +10 -0
  219. package/dist/ui/portfolio/SpendChart.d.ts.map +1 -0
  220. package/dist/ui/portfolio/SpendChart.js +196 -0
  221. package/dist/ui/portfolio/SpendChart.js.map +1 -0
  222. package/dist/ui/portfolio/alerts.d.ts +74 -0
  223. package/dist/ui/portfolio/alerts.d.ts.map +1 -0
  224. package/dist/ui/portfolio/alerts.js +291 -0
  225. package/dist/ui/portfolio/alerts.js.map +1 -0
  226. package/dist/ui/portfolio/figures.d.ts +79 -0
  227. package/dist/ui/portfolio/figures.d.ts.map +1 -0
  228. package/dist/ui/portfolio/figures.js +103 -0
  229. package/dist/ui/portfolio/figures.js.map +1 -0
  230. package/dist/ui/portfolio/format.d.ts +8 -0
  231. package/dist/ui/portfolio/format.d.ts.map +1 -0
  232. package/dist/ui/portfolio/format.js +28 -0
  233. package/dist/ui/portfolio/format.js.map +1 -0
  234. package/dist/ui/sample.d.ts +536 -0
  235. package/dist/ui/sample.d.ts.map +1 -0
  236. package/dist/ui/sample.js +574 -0
  237. package/dist/ui/sample.js.map +1 -0
  238. package/dist/ui/seed.d.ts +188 -0
  239. package/dist/ui/seed.d.ts.map +1 -0
  240. package/dist/ui/seed.js +563 -0
  241. package/dist/ui/seed.js.map +1 -0
  242. package/dist/ui/ui.css +3424 -0
  243. package/docs/federation.md +46 -1
  244. package/docs/meta-ads.md +795 -0
  245. package/docs/upgrading.md +116 -0
  246. package/llms.txt +3 -0
  247. package/package.json +15 -3
  248. package/src/component/_generated/api.ts +14 -0
  249. package/src/component/_generated/component.ts +324 -1
  250. package/src/component/_generated/server.ts +4 -0
  251. package/src/component/constants.ts +10 -0
  252. package/src/component/convex.config.ts +9 -0
  253. package/src/component/errors.ts +5 -1
  254. package/src/component/leads.ts +517 -17
  255. package/src/component/meta.ts +1525 -0
  256. package/src/component/metaCapi.ts +921 -0
  257. package/src/component/metaFake.ts +277 -0
  258. package/src/component/metaGraph.ts +722 -0
  259. package/src/component/metaSync.ts +1164 -0
  260. package/src/component/pipeline.ts +410 -0
  261. package/src/component/schema.ts +197 -1
  262. package/src/component/secrets.ts +98 -0
  263. package/src/component/validators.ts +376 -3
  264. package/src/tracker/generated.ts +1 -1
  265. package/src/ui/seed.ts +755 -0
@@ -9,14 +9,29 @@ import {
9
9
  import {
10
10
  DAY_MS,
11
11
  MAX_AD_CATALOG_BATCH,
12
+ MAX_LEAD_DAILY_DAYS,
12
13
  MAX_LEAD_EVENTS_PER_LEAD,
13
14
  MAX_LEAD_LIST,
14
15
  MAX_LEAD_REPORT_ADS,
15
16
  MAX_LEAD_REPORT_ROWS,
16
17
  MAX_REPORT_RANGE_DAYS,
17
18
  MAX_REPORT_SITES,
19
+ MAX_SITE_AD_ACCOUNTS,
20
+ MAX_AD_SPEND_ROWS,
21
+ MAX_SPEND_SUM_ROWS,
18
22
  } from "./constants.js";
19
23
  import { fail } from "./errors.js";
24
+ import {
25
+ enterColumn,
26
+ forgetLeadColumns,
27
+ recordColumnMove,
28
+ } from "./pipeline.js";
29
+ import {
30
+ enqueueNewLead,
31
+ enqueuePurchase,
32
+ parseMetaUserData,
33
+ removeLeadConversions,
34
+ } from "./metaCapi.js";
20
35
  import { cleanString, isValidMoney, sanitizeOpaqueId } from "./sanitize.js";
21
36
  import {
22
37
  leadAdRowValidator,
@@ -25,6 +40,7 @@ import {
25
40
  leadCampaignRowValidator,
26
41
  leadChannelValidator,
27
42
  leadJourneyValidator,
43
+ leadsDailyValidator,
28
44
  leadsReportValidator,
29
45
  leadStageChangeValidator,
30
46
  leadSummaryValidator,
@@ -208,7 +224,16 @@ export const record = mutation({
208
224
  ad: v.optional(leadAdValidator),
209
225
  label: v.optional(v.string()),
210
226
  visitorId: v.optional(v.string()),
227
+ /** The host's board column the lead is in (see `setPipeline`). */
228
+ column: v.optional(v.string()),
211
229
  occurredAt: v.number(),
230
+ /**
231
+ * The contact's hashes, for the site's Meta pixel: `{ ph, externalId? }`
232
+ * (see metaCapi.ts). Any shape is accepted here and checked by
233
+ * `parseMetaUserData`, so a bad value never fails the call nor shows up
234
+ * in an error.
235
+ */
236
+ meta: v.optional(v.any()),
212
237
  },
213
238
  returns: v.object({ leadId: v.id("leads"), created: v.boolean() }),
214
239
  handler: async (ctx, args) => {
@@ -216,6 +241,7 @@ export const record = mutation({
216
241
  if (!site) fail("NOT_FOUND", "site not found");
217
242
  const now = Date.now();
218
243
  const leadKey = opaque(args.leadKey, "leadKey");
244
+ const meta = parseMetaUserData(args.meta) ?? undefined;
219
245
  const label = optionalText(args.label, MAX_LABEL);
220
246
  const visitorId =
221
247
  args.visitorId === undefined
@@ -223,10 +249,18 @@ export const record = mutation({
223
249
  : opaque(args.visitorId, "visitorId");
224
250
  const existing = await findLead(ctx, args.siteId, leadKey);
225
251
  if (existing) {
226
- const patch: { label?: string; visitorId?: string; updatedAt?: number } =
227
- {};
252
+ const patch: Partial<Doc<"leads">> = {};
228
253
  if (label && label !== existing.label) patch.label = label;
229
254
  if (visitorId && !existing.visitorId) patch.visitorId = visitorId;
255
+ // The column is where the lead is now, so a later call may move it.
256
+ const moved = await enterColumn(
257
+ ctx,
258
+ existing,
259
+ args.column,
260
+ eventTime(args.occurredAt, now),
261
+ meta,
262
+ );
263
+ if (moved) Object.assign(patch, moved);
230
264
  if (Object.keys(patch).length > 0) {
231
265
  await ctx.db.patch("leads", existing._id, { ...patch, updatedAt: now });
232
266
  }
@@ -252,6 +286,14 @@ export const record = mutation({
252
286
  updatedAt: now,
253
287
  };
254
288
  const leadId = await ctx.db.insert("leads", lead);
289
+ const placed = await enterColumn(
290
+ ctx,
291
+ { _id: leadId, siteId: args.siteId, column: undefined },
292
+ args.column,
293
+ firstAt,
294
+ meta,
295
+ );
296
+ if (placed) await ctx.db.patch("leads", leadId, placed);
255
297
  await ctx.db.insert("leadEvents", {
256
298
  siteId: args.siteId,
257
299
  leadId,
@@ -265,6 +307,12 @@ export const record = mutation({
265
307
  won: 0,
266
308
  revenueCents: 0,
267
309
  });
310
+ await enqueueNewLead(ctx, {
311
+ siteId: args.siteId,
312
+ leadId,
313
+ eventTime: firstAt,
314
+ meta,
315
+ });
268
316
  return { leadId, created: true };
269
317
  },
270
318
  });
@@ -272,23 +320,35 @@ export const record = mutation({
272
320
  /**
273
321
  * Moves a lead through the host's pipeline, idempotent by `eventId`. A lead the
274
322
  * host never recorded (a conversation older than the integration) is not
275
- * invented here: the call returns `applied: false`.
323
+ * invented here: the call returns `applied: false`. `column` is the host's
324
+ * board column the lead is now in; a call may carry the column alone, when the
325
+ * lead moves on the board without changing stage.
276
326
  */
277
327
  export const setStage = mutation({
278
328
  args: {
279
329
  siteId: v.id("sites"),
280
330
  leadKey: v.string(),
281
- stage: leadStageChangeValidator,
331
+ stage: v.optional(leadStageChangeValidator),
332
+ /** The host's board column the lead is now in (see `setPipeline`). */
333
+ column: v.optional(v.string()),
282
334
  /** The sale value, in the site's currency, for `won`. */
283
335
  revenueCents: v.optional(v.number()),
284
336
  eventId: v.string(),
285
337
  occurredAt: v.number(),
338
+ /**
339
+ * The contact's hashes, for the site's Meta pixel: `{ ph, externalId? }`
340
+ * (see metaCapi.ts). Any shape is accepted here and checked by
341
+ * `parseMetaUserData`, so a bad value never fails the call nor shows up
342
+ * in an error.
343
+ */
344
+ meta: v.optional(v.any()),
286
345
  },
287
346
  returns: v.object({ applied: v.boolean() }),
288
347
  handler: async (ctx, args) => {
289
348
  const now = Date.now();
290
349
  const leadKey = opaque(args.leadKey, "leadKey");
291
350
  const eventId = opaque(args.eventId, "eventId");
351
+ const meta = parseMetaUserData(args.meta) ?? undefined;
292
352
  const at = eventTime(args.occurredAt, now);
293
353
  const revenueCents = args.revenueCents ?? 0;
294
354
  if (!isValidMoney(revenueCents)) {
@@ -300,6 +360,9 @@ export const setStage = mutation({
300
360
  if (args.stage !== "won" && args.revenueCents !== undefined) {
301
361
  fail("INVALID_ARGUMENT", "revenueCents is only accepted with won");
302
362
  }
363
+ if (args.stage === undefined && args.column === undefined) {
364
+ fail("INVALID_ARGUMENT", "pass a stage, a column, or both");
365
+ }
303
366
  const lead = await findLead(ctx, args.siteId, leadKey);
304
367
  if (!lead) return { applied: false };
305
368
  const seen = await ctx.db
@@ -309,6 +372,25 @@ export const setStage = mutation({
309
372
  )
310
373
  .unique();
311
374
  if (seen) return { applied: seen.leadId === lead._id };
375
+ if (args.stage === undefined) {
376
+ // A board move alone, idempotent by its own eventId. A move older than
377
+ // the lead's last one is recorded (so a retry stays a no-op) but never
378
+ // moves the lead back.
379
+ const column = args.column ?? "";
380
+ const move = await recordColumnMove(ctx, {
381
+ siteId: args.siteId,
382
+ leadId: lead._id,
383
+ eventId,
384
+ column,
385
+ at,
386
+ });
387
+ if (!move.fresh) return { applied: move.leadId === lead._id };
388
+ const moved = await enterColumn(ctx, lead, column, at, meta);
389
+ if (moved) {
390
+ await ctx.db.patch("leads", lead._id, { ...moved, updatedAt: now });
391
+ }
392
+ return { applied: true };
393
+ }
312
394
 
313
395
  const delta: Counters = { leads: 0, qualified: 0, won: 0, revenueCents: 0 };
314
396
  const patch: Partial<Doc<"leads">> = { stageAt: at, updatedAt: now };
@@ -352,7 +434,8 @@ export const setStage = mutation({
352
434
  patch.stage = lead.qualifiedAt !== undefined ? "qualified" : "lead";
353
435
  break;
354
436
  }
355
- await ctx.db.patch("leads", lead._id, patch);
437
+ const moved = await enterColumn(ctx, lead, args.column, at, meta);
438
+ await ctx.db.patch("leads", lead._id, { ...patch, ...moved });
356
439
  await ctx.db.insert("leadEvents", {
357
440
  siteId: args.siteId,
358
441
  leadId: lead._id,
@@ -362,6 +445,18 @@ export const setStage = mutation({
362
445
  ...(args.stage === "won" ? { revenueCents } : {}),
363
446
  });
364
447
  await bumpLead(ctx, lead, delta);
448
+ // Purchase only ever comes from a recorded sale, never from a column.
449
+ if (args.stage === "won" && meta) {
450
+ const site = await ctx.db.get("sites", args.siteId);
451
+ await enqueuePurchase(ctx, {
452
+ siteId: args.siteId,
453
+ leadId: lead._id,
454
+ eventTime: at,
455
+ valueCents: revenueCents,
456
+ currency: site?.currency ?? "USD",
457
+ meta,
458
+ });
459
+ }
365
460
  return { applied: true };
366
461
  },
367
462
  });
@@ -385,12 +480,14 @@ export const remove = mutation({
385
480
  .withIndex("by_leadId_and_at", (range) => range.eq("leadId", lead._id))
386
481
  .take(MAX_LEAD_EVENTS_PER_LEAD);
387
482
  for (const event of events) await ctx.db.delete("leadEvents", event._id);
483
+ await removeLeadConversions(ctx, lead);
388
484
  await bumpLead(ctx, lead, {
389
485
  leads: -1,
390
486
  qualified: lead.qualifiedAt !== undefined ? -1 : 0,
391
487
  won: lead.stage === "won" ? -1 : 0,
392
488
  revenueCents: lead.stage === "won" ? -lead.revenueCents : 0,
393
489
  });
490
+ await forgetLeadColumns(ctx, lead);
394
491
  await ctx.db.delete("leads", lead._id);
395
492
  return { removed: true };
396
493
  },
@@ -498,6 +595,128 @@ function cohortDays(from: number, to: number) {
498
595
  return { first: Math.floor(from / DAY_MS) * DAY_MS, last: to };
499
596
  }
500
597
 
598
+ type Spend = { spendCents: number; impressions: number; clicks: number };
599
+ type NullableSpend = { [K in keyof Spend]: number | null };
600
+ // Unknown spend (no synced ad account covers the row), never zero.
601
+ const NO_SPEND: NullableSpend = {
602
+ spendCents: null,
603
+ impressions: null,
604
+ clicks: null,
605
+ };
606
+ const zeroSpend = (): Spend => ({ spendCents: 0, impressions: 0, clicks: 0 });
607
+ /** Only the three spend figures of a row, whatever else it carries. */
608
+ const spendOf = (row: Spend): Spend => ({
609
+ spendCents: row.spendCents,
610
+ impressions: row.impressions,
611
+ clicks: row.clicks,
612
+ });
613
+ const addSpend = (into: Spend, row: Spend) => {
614
+ into.spendCents += row.spendCents;
615
+ into.impressions += row.impressions;
616
+ into.clicks += row.clicks;
617
+ };
618
+ /** Sums two nullable spends: unknown only when both are. */
619
+ function plusSpend(left: NullableSpend, right: NullableSpend): NullableSpend {
620
+ if (left.spendCents === null) return { ...NO_SPEND, ...pickSpend(right) };
621
+ if (right.spendCents === null) return pickSpend(left);
622
+ return {
623
+ spendCents: left.spendCents + right.spendCents,
624
+ impressions: (left.impressions ?? 0) + (right.impressions ?? 0),
625
+ clicks: (left.clicks ?? 0) + (right.clicks ?? 0),
626
+ };
627
+ }
628
+ const pickSpend = (row: NullableSpend): NullableSpend => ({
629
+ spendCents: row.spendCents,
630
+ impressions: row.impressions,
631
+ clicks: row.clicks,
632
+ });
633
+
634
+ /**
635
+ * What a site's Meta spend can be read from: its linked ad accounts that have
636
+ * finished a sync. An account still backfilling reports nothing yet, so its
637
+ * spend reads as unknown ("syncing"), never as zero.
638
+ */
639
+ async function spendSources(ctx: QueryCtx, siteId: Id<"sites">) {
640
+ const links = await ctx.db
641
+ .query("siteAdAccounts")
642
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
643
+ .take(MAX_SITE_AD_ACCOUNTS);
644
+ const synced = links.filter((link) => link.lastSyncAt !== undefined);
645
+ return {
646
+ accounts: new Set(synced.map((link) => link.adAccountId)),
647
+ currencies: new Set(synced.map((link) => link.currency)),
648
+ };
649
+ }
650
+
651
+ /**
652
+ * A site's spend per account and day in the range, from the site-day sums.
653
+ * Its budget is its own, so Meta rows never cost the lead report its leads;
654
+ * past it the answer is `null` (the caller reports spend as incomplete).
655
+ */
656
+ async function readSiteSpend(
657
+ ctx: QueryCtx,
658
+ budget: { rows: number },
659
+ siteId: Id<"sites">,
660
+ first: number,
661
+ lastDay: number,
662
+ ) {
663
+ const rows = await ctx.db
664
+ .query("siteSpendDaily")
665
+ .withIndex("by_siteId_and_dayStart", (range) =>
666
+ range
667
+ .eq("siteId", siteId)
668
+ .gte("dayStart", first)
669
+ .lte("dayStart", lastDay),
670
+ )
671
+ .take(budget.rows + 1);
672
+ if (rows.length > budget.rows) {
673
+ budget.rows = 0;
674
+ return null;
675
+ }
676
+ budget.rows -= rows.length;
677
+ return rows;
678
+ }
679
+
680
+ /**
681
+ * A site's spend per ad and day in the range (or one ad's), for the campaign
682
+ * tree. A budget of its own: past it the answer is `null` and the tree's
683
+ * spend is unknown, while the leads and the site totals stay exact.
684
+ */
685
+ async function readAdSpend(
686
+ ctx: QueryCtx,
687
+ budget: { rows: number },
688
+ siteId: Id<"sites">,
689
+ first: number,
690
+ lastDay: number,
691
+ adId?: string,
692
+ ) {
693
+ const query = adId
694
+ ? ctx.db
695
+ .query("adSpendDaily")
696
+ .withIndex("by_siteId_and_adId_and_dayStart", (range) =>
697
+ range
698
+ .eq("siteId", siteId)
699
+ .eq("adId", adId)
700
+ .gte("dayStart", first)
701
+ .lte("dayStart", lastDay),
702
+ )
703
+ : ctx.db
704
+ .query("adSpendDaily")
705
+ .withIndex("by_siteId_and_dayStart", (range) =>
706
+ range
707
+ .eq("siteId", siteId)
708
+ .gte("dayStart", first)
709
+ .lte("dayStart", lastDay),
710
+ );
711
+ const rows = await query.take(budget.rows + 1);
712
+ if (rows.length > budget.rows) {
713
+ budget.rows = 0;
714
+ return null;
715
+ }
716
+ budget.rows -= rows.length;
717
+ return rows;
718
+ }
719
+
501
720
  const zero = (): Counters => ({
502
721
  leads: 0,
503
722
  qualified: 0,
@@ -560,15 +779,52 @@ export const report = query({
560
779
  handler: async (ctx, args) => {
561
780
  const siteIds = validateSites(args.siteIds);
562
781
  const { first, last } = cohortDays(args.from, args.to);
782
+ const lastDay = Math.floor(last / DAY_MS) * DAY_MS;
563
783
  const currencies = new Set<string>();
564
784
  const totals = zero();
565
785
  const origins = new Map<string, Counters>();
566
- const ads = new Map<string, { siteId: Id<"sites">; counters: Counters }>();
786
+ type AdEntry = {
787
+ siteId: Id<"sites">;
788
+ counters: Counters;
789
+ spend: Spend | null;
790
+ accounts: Set<string>;
791
+ };
792
+ const ads = new Map<string, AdEntry>();
793
+ type SiteEntry = {
794
+ siteId: Id<"sites">;
795
+ counters: Counters;
796
+ paid: Counters;
797
+ spend: NullableSpend;
798
+ spendCurrency: string | null;
799
+ currencyMismatch: boolean;
800
+ };
801
+ const bySite: SiteEntry[] = [];
567
802
  const budget = { rows: MAX_LEAD_REPORT_ROWS };
803
+ const sumBudget = { rows: MAX_SPEND_SUM_ROWS };
804
+ const adBudget = { rows: MAX_AD_SPEND_ROWS };
805
+ // Every currency the synced accounts of the requested sites bill in.
806
+ const spendCurrencies = new Set<string>();
807
+ let spendIncomplete = false;
808
+ let treeSpendKnown = true;
809
+ const adEntry = (
810
+ adId: string,
811
+ siteId: Id<"sites">,
812
+ accounts: Set<string>,
813
+ ): AdEntry => {
814
+ let entry = ads.get(adId);
815
+ if (!entry) {
816
+ entry = { siteId, counters: zero(), spend: null, accounts };
817
+ ads.set(adId, entry);
818
+ }
819
+ return entry;
820
+ };
568
821
  for (const siteId of siteIds) {
569
822
  const site = await ctx.db.get("sites", siteId);
570
823
  if (!site) fail("NOT_FOUND", "site not found");
571
- currencies.add(site.currency ?? "USD");
824
+ const siteCurrency = site.currency ?? "USD";
825
+ currencies.add(siteCurrency);
826
+ const siteCounters = zero();
827
+ const paidCounters = zero();
572
828
  for (const row of await readRollups(
573
829
  ctx,
574
830
  budget,
@@ -578,10 +834,13 @@ export const report = query({
578
834
  last,
579
835
  )) {
580
836
  add(totals, row);
837
+ add(siteCounters, row);
838
+ if (row.key === AD_ORIGIN) add(paidCounters, row);
581
839
  const counters = origins.get(row.key) ?? zero();
582
840
  add(counters, row);
583
841
  origins.set(row.key, counters);
584
842
  }
843
+ const sources = await spendSources(ctx, siteId);
585
844
  for (const row of await readRollups(
586
845
  ctx,
587
846
  budget,
@@ -590,11 +849,67 @@ export const report = query({
590
849
  first,
591
850
  last,
592
851
  )) {
593
- const entry = ads.get(row.key) ?? { siteId, counters: zero() };
594
- add(entry.counters, row);
595
- ads.set(row.key, entry);
852
+ add(adEntry(row.key, siteId, sources.accounts).counters, row);
853
+ }
854
+ for (const currency of sources.currencies) spendCurrencies.add(currency);
855
+
856
+ // The site's spend, from the site-day sums of its synced accounts.
857
+ let spend: NullableSpend = NO_SPEND;
858
+ if (sources.accounts.size > 0) {
859
+ const sums = await readSiteSpend(
860
+ ctx,
861
+ sumBudget,
862
+ siteId,
863
+ first,
864
+ lastDay,
865
+ );
866
+ if (sums === null) {
867
+ spendIncomplete = true;
868
+ } else {
869
+ const total = zeroSpend();
870
+ for (const row of sums) {
871
+ if (sources.accounts.has(row.adAccountId)) addSpend(total, row);
872
+ }
873
+ spend = total;
874
+ }
875
+ }
876
+ const mixed = sources.currencies.size > 1;
877
+ const spendCurrency =
878
+ sources.currencies.size === 1 ? [...sources.currencies][0]! : null;
879
+ bySite.push({
880
+ siteId,
881
+ counters: siteCounters,
882
+ paid: paidCounters,
883
+ // Two currencies cannot be summed: the site's spend is unknown.
884
+ spend: mixed ? NO_SPEND : spend,
885
+ spendCurrency,
886
+ currencyMismatch:
887
+ mixed || (spendCurrency !== null && spendCurrency !== siteCurrency),
888
+ });
889
+
890
+ // The campaign tree's spend, per ad, on a budget of its own.
891
+ if (sources.accounts.size > 0 && treeSpendKnown) {
892
+ const rows = await readAdSpend(ctx, adBudget, siteId, first, lastDay);
893
+ if (rows === null) {
894
+ spendIncomplete = true;
895
+ treeSpendKnown = false;
896
+ } else {
897
+ for (const row of rows) {
898
+ if (!sources.accounts.has(row.adAccountId)) continue;
899
+ const entry = adEntry(row.adId, siteId, sources.accounts);
900
+ entry.spend ??= zeroSpend();
901
+ addSpend(entry.spend, spendOf(row));
902
+ }
903
+ }
596
904
  }
597
905
  }
906
+ // Spend adds up only in one currency.
907
+ const oneCurrency = spendCurrencies.size <= 1;
908
+ if (!oneCurrency) treeSpendKnown = false;
909
+ let totalSpend: NullableSpend = NO_SPEND;
910
+ if (oneCurrency) {
911
+ for (const site of bySite) totalSpend = plusSpend(totalSpend, site.spend);
912
+ }
598
913
  if (ads.size > MAX_LEAD_REPORT_ADS) {
599
914
  fail(
600
915
  "REPORT_INCOMPLETE",
@@ -609,10 +924,28 @@ export const report = query({
609
924
  type AdsetRow = Infer<typeof leadAdsetRowValidator>;
610
925
  type CampaignRow = Infer<typeof leadCampaignRowValidator>;
611
926
  const campaigns = new Map<string, CampaignRow>();
612
- for (const [adId, { siteId, counters }] of ads) {
927
+ const sum = (
928
+ row: { [K in keyof Spend]?: number | null },
929
+ spend: NullableSpend,
930
+ ) => {
931
+ const next = plusSpend(
932
+ {
933
+ spendCents: row.spendCents ?? null,
934
+ impressions: row.impressions ?? null,
935
+ clicks: row.clicks ?? null,
936
+ },
937
+ spend,
938
+ );
939
+ row.spendCents = next.spendCents;
940
+ row.impressions = next.impressions;
941
+ row.clicks = next.clicks;
942
+ };
943
+ for (const [adId, entry] of ads) {
944
+ const { siteId, counters } = entry;
613
945
  // Every stage counts on the lead's own cohort row, so no leads means
614
- // nothing left there (removed leads leave zeroed rows behind).
615
- if (counters.leads === 0) continue;
946
+ // nothing left there (removed leads leave zeroed rows behind), unless
947
+ // the ad spent: an ad burning budget without leads is the point.
948
+ if (counters.leads === 0 && entry.spend === null) continue;
616
949
  const catalog = await ctx.db
617
950
  .query("adCatalog")
618
951
  .withIndex("by_siteId_and_adId", (range) =>
@@ -628,23 +961,41 @@ export const report = query({
628
961
  )
629
962
  .order("desc")
630
963
  .first();
964
+ // An ad of a synced account that spent nothing in the range spent 0;
965
+ // one no synced account covers has unknown spend.
966
+ const spend: NullableSpend = !treeSpendKnown
967
+ ? NO_SPEND
968
+ : entry.spend
969
+ ? spendOf(entry.spend)
970
+ : catalog?.adAccountId && entry.accounts.has(catalog.adAccountId)
971
+ ? zeroSpend()
972
+ : NO_SPEND;
973
+ const thumbnailUrl = catalog?.thumbnailStorageId
974
+ ? await ctx.storage.getUrl(catalog.thumbnailStorageId)
975
+ : null;
631
976
  const ad: AdRow = {
632
977
  adId,
978
+ siteId,
633
979
  adName: catalog?.adName ?? null,
634
980
  sourceApp: latest?.ad?.sourceApp ?? null,
635
981
  sourceUrl: latest?.ad?.sourceUrl ?? null,
636
982
  title: latest?.ad?.title ?? null,
637
- thumbnailUrl: catalog?.thumbnailUrl ?? null,
983
+ thumbnailUrl: thumbnailUrl ?? catalog?.thumbnailUrl ?? null,
984
+ creativeType: catalog?.creativeType ?? null,
985
+ adStatus: catalog?.adStatus ?? null,
638
986
  ...counters,
987
+ ...spend,
639
988
  };
640
989
  const campaignKey = catalog?.campaignId ?? "";
641
990
  const campaign = campaigns.get(campaignKey) ?? {
642
991
  campaignId: catalog?.campaignId ?? null,
643
992
  campaignName: catalog?.campaignName ?? null,
644
993
  ...zero(),
994
+ ...NO_SPEND,
645
995
  adsets: [],
646
996
  };
647
997
  add(campaign, counters);
998
+ sum(campaign, spend);
648
999
  const adsetId = catalog?.adsetId ?? null;
649
1000
  let adset: AdsetRow | undefined = campaign.adsets.find(
650
1001
  (row) => row.adsetId === adsetId,
@@ -654,11 +1005,13 @@ export const report = query({
654
1005
  adsetId,
655
1006
  adsetName: catalog?.adsetName ?? null,
656
1007
  ...zero(),
1008
+ ...NO_SPEND,
657
1009
  ads: [],
658
1010
  };
659
1011
  campaign.adsets.push(adset);
660
1012
  }
661
1013
  add(adset, counters);
1014
+ sum(adset, spend);
662
1015
  adset.ads.push(ad);
663
1016
  campaigns.set(campaignKey, campaign);
664
1017
  }
@@ -684,20 +1037,167 @@ export const report = query({
684
1037
  right,
685
1038
  ),
686
1039
  );
1040
+ // Spend buys the ad origin only; the organic ones stay unknown.
1041
+ if (!origins.has(AD_ORIGIN) && (totalSpend.spendCents ?? 0) > 0) {
1042
+ origins.set(AD_ORIGIN, zero());
1043
+ }
687
1044
  return {
688
1045
  from: args.from,
689
1046
  to: args.to,
690
1047
  currency: currencies.size === 1 ? [...currencies][0] : null,
691
- totals,
1048
+ totals: { ...totals, ...totalSpend },
692
1049
  origins: [...origins]
693
- .map(([key, counters]) => ({ key, ...counters }))
694
- .filter((row) => row.leads > 0)
1050
+ .map(([key, counters]) => ({
1051
+ key,
1052
+ ...counters,
1053
+ ...(key === AD_ORIGIN ? totalSpend : NO_SPEND),
1054
+ }))
1055
+ .filter(
1056
+ (row) =>
1057
+ row.leads > 0 || (row.spendCents !== null && row.spendCents > 0),
1058
+ )
695
1059
  .sort(byLeadsThenKey((row) => row.key)),
696
1060
  campaigns: campaignRows,
1061
+ bySite: bySite.map((site) => ({
1062
+ siteId: site.siteId,
1063
+ ...site.counters,
1064
+ paid: site.paid,
1065
+ spendCents: site.spend.spendCents,
1066
+ spendCurrency: site.spendCurrency,
1067
+ currencyMismatch: site.currencyMismatch,
1068
+ })),
1069
+ spendCurrency:
1070
+ oneCurrency && totalSpend.spendCents !== null
1071
+ ? ([...spendCurrencies][0] ?? null)
1072
+ : null,
1073
+ currencyMismatch:
1074
+ !oneCurrency || bySite.some((site) => site.currencyMismatch),
1075
+ spendIncomplete,
697
1076
  };
698
1077
  },
699
1078
  });
700
1079
 
1080
+ /**
1081
+ * The lead funnel per cohort day, for sparklines and the sales × spend chart:
1082
+ * every UTC day in the range, empty days included. With `adId`, only that
1083
+ * ad's leads; with `origin`, only that origin's (`ctwa_ad` for the leads the
1084
+ * ads bought). Spend is the day's Meta spend for the same scope, null where
1085
+ * no synced ad account covers it, for an organic origin, or when the sites'
1086
+ * accounts bill in more than one currency.
1087
+ */
1088
+ export const daily = query({
1089
+ args: {
1090
+ siteIds: v.array(v.id("sites")),
1091
+ from: v.number(),
1092
+ to: v.number(),
1093
+ adId: v.optional(v.string()),
1094
+ origin: v.optional(v.string()),
1095
+ },
1096
+ returns: leadsDailyValidator,
1097
+ handler: async (ctx, args) => {
1098
+ const siteIds = validateSites(args.siteIds);
1099
+ const { first, last } = cohortDays(args.from, args.to);
1100
+ const lastDay = Math.floor(last / DAY_MS) * DAY_MS;
1101
+ if ((lastDay - first) / DAY_MS + 1 > MAX_LEAD_DAILY_DAYS) {
1102
+ fail(
1103
+ "INVALID_ARGUMENT",
1104
+ `a daily series spans at most ${MAX_LEAD_DAILY_DAYS} UTC days`,
1105
+ );
1106
+ }
1107
+ if (args.adId !== undefined && args.origin !== undefined) {
1108
+ fail("INVALID_ARGUMENT", "pass adId or origin, not both");
1109
+ }
1110
+ if (args.origin !== undefined && !ENTRY_POINT.test(args.origin)) {
1111
+ invalid("origin");
1112
+ }
1113
+ const adId =
1114
+ args.adId === undefined ? undefined : opaque(args.adId, "adId");
1115
+ const key = adId ?? args.origin;
1116
+ const days = new Map<number, Counters>();
1117
+ for (let day = first; day <= lastDay; day += DAY_MS) days.set(day, zero());
1118
+ const budget = { rows: MAX_LEAD_REPORT_ROWS };
1119
+ const spendBudget = { rows: MAX_SPEND_SUM_ROWS };
1120
+ const spend = new Map<number, number>();
1121
+ const spendCurrencies = new Set<string>();
1122
+ let spendKnown = false;
1123
+ let spendBroken = false;
1124
+ for (const siteId of siteIds) {
1125
+ const site = await ctx.db.get("sites", siteId);
1126
+ if (!site) fail("NOT_FOUND", "site not found");
1127
+ for (const row of await readRollups(
1128
+ ctx,
1129
+ budget,
1130
+ siteId,
1131
+ adId === undefined ? "origin" : "ad",
1132
+ first,
1133
+ last,
1134
+ )) {
1135
+ if (key !== undefined && row.key !== key) continue;
1136
+ const counters = days.get(row.dayStart);
1137
+ if (counters) add(counters, row);
1138
+ }
1139
+ // Spend buys the ad leads: an organic origin has none to show.
1140
+ if (args.origin !== undefined && args.origin !== AD_ORIGIN) continue;
1141
+ const sources = await spendSources(ctx, siteId);
1142
+ if (sources.accounts.size === 0) continue;
1143
+ let rows: Array<{
1144
+ dayStart: number;
1145
+ spendCents: number;
1146
+ adAccountId: string;
1147
+ }> | null;
1148
+ if (adId === undefined) {
1149
+ rows = await readSiteSpend(ctx, spendBudget, siteId, first, lastDay);
1150
+ } else {
1151
+ rows = await readAdSpend(
1152
+ ctx,
1153
+ spendBudget,
1154
+ siteId,
1155
+ first,
1156
+ lastDay,
1157
+ adId,
1158
+ );
1159
+ const own = rows?.some((row) => sources.accounts.has(row.adAccountId));
1160
+ if (!own) {
1161
+ // One ad: its spend is known only if a synced account of this
1162
+ // site runs it.
1163
+ const catalog = await ctx.db
1164
+ .query("adCatalog")
1165
+ .withIndex("by_siteId_and_adId", (range) =>
1166
+ range.eq("siteId", siteId).eq("adId", adId),
1167
+ )
1168
+ .unique();
1169
+ if (
1170
+ !catalog?.adAccountId ||
1171
+ !sources.accounts.has(catalog.adAccountId)
1172
+ ) {
1173
+ continue;
1174
+ }
1175
+ }
1176
+ }
1177
+ if (rows === null) {
1178
+ spendBroken = true;
1179
+ continue;
1180
+ }
1181
+ spendKnown = true;
1182
+ for (const currency of sources.currencies) spendCurrencies.add(currency);
1183
+ for (const row of rows) {
1184
+ if (!sources.accounts.has(row.adAccountId)) continue;
1185
+ spend.set(
1186
+ row.dayStart,
1187
+ (spend.get(row.dayStart) ?? 0) + row.spendCents,
1188
+ );
1189
+ }
1190
+ }
1191
+ // Two currencies do not add up; a series past its budget is not whole.
1192
+ const showSpend = spendKnown && !spendBroken && spendCurrencies.size <= 1;
1193
+ return [...days].map(([dayStart, counters]) => ({
1194
+ dayStart,
1195
+ ...counters,
1196
+ spendCents: showSpend ? (spend.get(dayStart) ?? 0) : null,
1197
+ }));
1198
+ },
1199
+ });
1200
+
701
1201
  // The click id stays in the host's deployment: it is for the Conversions API,
702
1202
  // not for a dashboard.
703
1203
  function summarize(lead: Doc<"leads">): Infer<typeof leadSummaryValidator> {