@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
@@ -1,9 +1,11 @@
1
1
  import { v } from "convex/values";
2
2
  import { mutation, query, } from "./_generated/server.js";
3
- import { DAY_MS, MAX_AD_CATALOG_BATCH, MAX_LEAD_EVENTS_PER_LEAD, MAX_LEAD_LIST, MAX_LEAD_REPORT_ADS, MAX_LEAD_REPORT_ROWS, MAX_REPORT_RANGE_DAYS, MAX_REPORT_SITES, } from "./constants.js";
3
+ import { DAY_MS, MAX_AD_CATALOG_BATCH, MAX_LEAD_DAILY_DAYS, MAX_LEAD_EVENTS_PER_LEAD, MAX_LEAD_LIST, MAX_LEAD_REPORT_ADS, MAX_LEAD_REPORT_ROWS, MAX_REPORT_RANGE_DAYS, MAX_REPORT_SITES, MAX_SITE_AD_ACCOUNTS, MAX_AD_SPEND_ROWS, MAX_SPEND_SUM_ROWS, } from "./constants.js";
4
4
  import { fail } from "./errors.js";
5
+ import { enterColumn, forgetLeadColumns, recordColumnMove, } from "./pipeline.js";
6
+ import { enqueueNewLead, enqueuePurchase, parseMetaUserData, removeLeadConversions, } from "./metaCapi.js";
5
7
  import { cleanString, isValidMoney, sanitizeOpaqueId } from "./sanitize.js";
6
- import { leadAdRowValidator, leadAdValidator, leadAdsetRowValidator, leadCampaignRowValidator, leadChannelValidator, leadJourneyValidator, leadsReportValidator, leadStageChangeValidator, leadSummaryValidator, } from "./validators.js";
8
+ import { leadAdRowValidator, leadAdValidator, leadAdsetRowValidator, leadCampaignRowValidator, leadChannelValidator, leadJourneyValidator, leadsDailyValidator, leadsReportValidator, leadStageChangeValidator, leadSummaryValidator, } from "./validators.js";
7
9
  /*
8
10
  * Leads: conversations the host's server reports, with no web session behind
9
11
  * them. A WhatsApp lead that came from a Click-to-WhatsApp ad never touched the
@@ -157,7 +159,16 @@ export const record = mutation({
157
159
  ad: v.optional(leadAdValidator),
158
160
  label: v.optional(v.string()),
159
161
  visitorId: v.optional(v.string()),
162
+ /** The host's board column the lead is in (see `setPipeline`). */
163
+ column: v.optional(v.string()),
160
164
  occurredAt: v.number(),
165
+ /**
166
+ * The contact's hashes, for the site's Meta pixel: `{ ph, externalId? }`
167
+ * (see metaCapi.ts). Any shape is accepted here and checked by
168
+ * `parseMetaUserData`, so a bad value never fails the call nor shows up
169
+ * in an error.
170
+ */
171
+ meta: v.optional(v.any()),
161
172
  },
162
173
  returns: v.object({ leadId: v.id("leads"), created: v.boolean() }),
163
174
  handler: async (ctx, args) => {
@@ -166,6 +177,7 @@ export const record = mutation({
166
177
  fail("NOT_FOUND", "site not found");
167
178
  const now = Date.now();
168
179
  const leadKey = opaque(args.leadKey, "leadKey");
180
+ const meta = parseMetaUserData(args.meta) ?? undefined;
169
181
  const label = optionalText(args.label, MAX_LABEL);
170
182
  const visitorId = args.visitorId === undefined
171
183
  ? undefined
@@ -177,6 +189,10 @@ export const record = mutation({
177
189
  patch.label = label;
178
190
  if (visitorId && !existing.visitorId)
179
191
  patch.visitorId = visitorId;
192
+ // The column is where the lead is now, so a later call may move it.
193
+ const moved = await enterColumn(ctx, existing, args.column, eventTime(args.occurredAt, now), meta);
194
+ if (moved)
195
+ Object.assign(patch, moved);
180
196
  if (Object.keys(patch).length > 0) {
181
197
  await ctx.db.patch("leads", existing._id, { ...patch, updatedAt: now });
182
198
  }
@@ -202,6 +218,9 @@ export const record = mutation({
202
218
  updatedAt: now,
203
219
  };
204
220
  const leadId = await ctx.db.insert("leads", lead);
221
+ const placed = await enterColumn(ctx, { _id: leadId, siteId: args.siteId, column: undefined }, args.column, firstAt, meta);
222
+ if (placed)
223
+ await ctx.db.patch("leads", leadId, placed);
205
224
  await ctx.db.insert("leadEvents", {
206
225
  siteId: args.siteId,
207
226
  leadId,
@@ -215,29 +234,47 @@ export const record = mutation({
215
234
  won: 0,
216
235
  revenueCents: 0,
217
236
  });
237
+ await enqueueNewLead(ctx, {
238
+ siteId: args.siteId,
239
+ leadId,
240
+ eventTime: firstAt,
241
+ meta,
242
+ });
218
243
  return { leadId, created: true };
219
244
  },
220
245
  });
221
246
  /**
222
247
  * Moves a lead through the host's pipeline, idempotent by `eventId`. A lead the
223
248
  * host never recorded (a conversation older than the integration) is not
224
- * invented here: the call returns `applied: false`.
249
+ * invented here: the call returns `applied: false`. `column` is the host's
250
+ * board column the lead is now in; a call may carry the column alone, when the
251
+ * lead moves on the board without changing stage.
225
252
  */
226
253
  export const setStage = mutation({
227
254
  args: {
228
255
  siteId: v.id("sites"),
229
256
  leadKey: v.string(),
230
- stage: leadStageChangeValidator,
257
+ stage: v.optional(leadStageChangeValidator),
258
+ /** The host's board column the lead is now in (see `setPipeline`). */
259
+ column: v.optional(v.string()),
231
260
  /** The sale value, in the site's currency, for `won`. */
232
261
  revenueCents: v.optional(v.number()),
233
262
  eventId: v.string(),
234
263
  occurredAt: v.number(),
264
+ /**
265
+ * The contact's hashes, for the site's Meta pixel: `{ ph, externalId? }`
266
+ * (see metaCapi.ts). Any shape is accepted here and checked by
267
+ * `parseMetaUserData`, so a bad value never fails the call nor shows up
268
+ * in an error.
269
+ */
270
+ meta: v.optional(v.any()),
235
271
  },
236
272
  returns: v.object({ applied: v.boolean() }),
237
273
  handler: async (ctx, args) => {
238
274
  const now = Date.now();
239
275
  const leadKey = opaque(args.leadKey, "leadKey");
240
276
  const eventId = opaque(args.eventId, "eventId");
277
+ const meta = parseMetaUserData(args.meta) ?? undefined;
241
278
  const at = eventTime(args.occurredAt, now);
242
279
  const revenueCents = args.revenueCents ?? 0;
243
280
  if (!isValidMoney(revenueCents)) {
@@ -246,6 +283,9 @@ export const setStage = mutation({
246
283
  if (args.stage !== "won" && args.revenueCents !== undefined) {
247
284
  fail("INVALID_ARGUMENT", "revenueCents is only accepted with won");
248
285
  }
286
+ if (args.stage === undefined && args.column === undefined) {
287
+ fail("INVALID_ARGUMENT", "pass a stage, a column, or both");
288
+ }
249
289
  const lead = await findLead(ctx, args.siteId, leadKey);
250
290
  if (!lead)
251
291
  return { applied: false };
@@ -255,6 +295,26 @@ export const setStage = mutation({
255
295
  .unique();
256
296
  if (seen)
257
297
  return { applied: seen.leadId === lead._id };
298
+ if (args.stage === undefined) {
299
+ // A board move alone, idempotent by its own eventId. A move older than
300
+ // the lead's last one is recorded (so a retry stays a no-op) but never
301
+ // moves the lead back.
302
+ const column = args.column ?? "";
303
+ const move = await recordColumnMove(ctx, {
304
+ siteId: args.siteId,
305
+ leadId: lead._id,
306
+ eventId,
307
+ column,
308
+ at,
309
+ });
310
+ if (!move.fresh)
311
+ return { applied: move.leadId === lead._id };
312
+ const moved = await enterColumn(ctx, lead, column, at, meta);
313
+ if (moved) {
314
+ await ctx.db.patch("leads", lead._id, { ...moved, updatedAt: now });
315
+ }
316
+ return { applied: true };
317
+ }
258
318
  const delta = { leads: 0, qualified: 0, won: 0, revenueCents: 0 };
259
319
  const patch = { stageAt: at, updatedAt: now };
260
320
  const unsell = () => {
@@ -300,7 +360,8 @@ export const setStage = mutation({
300
360
  patch.stage = lead.qualifiedAt !== undefined ? "qualified" : "lead";
301
361
  break;
302
362
  }
303
- await ctx.db.patch("leads", lead._id, patch);
363
+ const moved = await enterColumn(ctx, lead, args.column, at, meta);
364
+ await ctx.db.patch("leads", lead._id, { ...patch, ...moved });
304
365
  await ctx.db.insert("leadEvents", {
305
366
  siteId: args.siteId,
306
367
  leadId: lead._id,
@@ -310,6 +371,18 @@ export const setStage = mutation({
310
371
  ...(args.stage === "won" ? { revenueCents } : {}),
311
372
  });
312
373
  await bumpLead(ctx, lead, delta);
374
+ // Purchase only ever comes from a recorded sale, never from a column.
375
+ if (args.stage === "won" && meta) {
376
+ const site = await ctx.db.get("sites", args.siteId);
377
+ await enqueuePurchase(ctx, {
378
+ siteId: args.siteId,
379
+ leadId: lead._id,
380
+ eventTime: at,
381
+ valueCents: revenueCents,
382
+ currency: site?.currency ?? "USD",
383
+ meta,
384
+ });
385
+ }
313
386
  return { applied: true };
314
387
  },
315
388
  });
@@ -330,12 +403,14 @@ export const remove = mutation({
330
403
  .take(MAX_LEAD_EVENTS_PER_LEAD);
331
404
  for (const event of events)
332
405
  await ctx.db.delete("leadEvents", event._id);
406
+ await removeLeadConversions(ctx, lead);
333
407
  await bumpLead(ctx, lead, {
334
408
  leads: -1,
335
409
  qualified: lead.qualifiedAt !== undefined ? -1 : 0,
336
410
  won: lead.stage === "won" ? -1 : 0,
337
411
  revenueCents: lead.stage === "won" ? -lead.revenueCents : 0,
338
412
  });
413
+ await forgetLeadColumns(ctx, lead);
339
414
  await ctx.db.delete("leads", lead._id);
340
415
  return { removed: true };
341
416
  },
@@ -427,6 +502,105 @@ function cohortDays(from, to) {
427
502
  }
428
503
  return { first: Math.floor(from / DAY_MS) * DAY_MS, last: to };
429
504
  }
505
+ // Unknown spend (no synced ad account covers the row), never zero.
506
+ const NO_SPEND = {
507
+ spendCents: null,
508
+ impressions: null,
509
+ clicks: null,
510
+ };
511
+ const zeroSpend = () => ({ spendCents: 0, impressions: 0, clicks: 0 });
512
+ /** Only the three spend figures of a row, whatever else it carries. */
513
+ const spendOf = (row) => ({
514
+ spendCents: row.spendCents,
515
+ impressions: row.impressions,
516
+ clicks: row.clicks,
517
+ });
518
+ const addSpend = (into, row) => {
519
+ into.spendCents += row.spendCents;
520
+ into.impressions += row.impressions;
521
+ into.clicks += row.clicks;
522
+ };
523
+ /** Sums two nullable spends: unknown only when both are. */
524
+ function plusSpend(left, right) {
525
+ if (left.spendCents === null)
526
+ return { ...NO_SPEND, ...pickSpend(right) };
527
+ if (right.spendCents === null)
528
+ return pickSpend(left);
529
+ return {
530
+ spendCents: left.spendCents + right.spendCents,
531
+ impressions: (left.impressions ?? 0) + (right.impressions ?? 0),
532
+ clicks: (left.clicks ?? 0) + (right.clicks ?? 0),
533
+ };
534
+ }
535
+ const pickSpend = (row) => ({
536
+ spendCents: row.spendCents,
537
+ impressions: row.impressions,
538
+ clicks: row.clicks,
539
+ });
540
+ /**
541
+ * What a site's Meta spend can be read from: its linked ad accounts that have
542
+ * finished a sync. An account still backfilling reports nothing yet, so its
543
+ * spend reads as unknown ("syncing"), never as zero.
544
+ */
545
+ async function spendSources(ctx, siteId) {
546
+ const links = await ctx.db
547
+ .query("siteAdAccounts")
548
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
549
+ .take(MAX_SITE_AD_ACCOUNTS);
550
+ const synced = links.filter((link) => link.lastSyncAt !== undefined);
551
+ return {
552
+ accounts: new Set(synced.map((link) => link.adAccountId)),
553
+ currencies: new Set(synced.map((link) => link.currency)),
554
+ };
555
+ }
556
+ /**
557
+ * A site's spend per account and day in the range, from the site-day sums.
558
+ * Its budget is its own, so Meta rows never cost the lead report its leads;
559
+ * past it the answer is `null` (the caller reports spend as incomplete).
560
+ */
561
+ async function readSiteSpend(ctx, budget, siteId, first, lastDay) {
562
+ const rows = await ctx.db
563
+ .query("siteSpendDaily")
564
+ .withIndex("by_siteId_and_dayStart", (range) => range
565
+ .eq("siteId", siteId)
566
+ .gte("dayStart", first)
567
+ .lte("dayStart", lastDay))
568
+ .take(budget.rows + 1);
569
+ if (rows.length > budget.rows) {
570
+ budget.rows = 0;
571
+ return null;
572
+ }
573
+ budget.rows -= rows.length;
574
+ return rows;
575
+ }
576
+ /**
577
+ * A site's spend per ad and day in the range (or one ad's), for the campaign
578
+ * tree. A budget of its own: past it the answer is `null` and the tree's
579
+ * spend is unknown, while the leads and the site totals stay exact.
580
+ */
581
+ async function readAdSpend(ctx, budget, siteId, first, lastDay, adId) {
582
+ const query = adId
583
+ ? ctx.db
584
+ .query("adSpendDaily")
585
+ .withIndex("by_siteId_and_adId_and_dayStart", (range) => range
586
+ .eq("siteId", siteId)
587
+ .eq("adId", adId)
588
+ .gte("dayStart", first)
589
+ .lte("dayStart", lastDay))
590
+ : ctx.db
591
+ .query("adSpendDaily")
592
+ .withIndex("by_siteId_and_dayStart", (range) => range
593
+ .eq("siteId", siteId)
594
+ .gte("dayStart", first)
595
+ .lte("dayStart", lastDay));
596
+ const rows = await query.take(budget.rows + 1);
597
+ if (rows.length > budget.rows) {
598
+ budget.rows = 0;
599
+ return null;
600
+ }
601
+ budget.rows -= rows.length;
602
+ return rows;
603
+ }
430
604
  const zero = () => ({
431
605
  leads: 0,
432
606
  qualified: 0,
@@ -474,27 +648,103 @@ export const report = query({
474
648
  handler: async (ctx, args) => {
475
649
  const siteIds = validateSites(args.siteIds);
476
650
  const { first, last } = cohortDays(args.from, args.to);
651
+ const lastDay = Math.floor(last / DAY_MS) * DAY_MS;
477
652
  const currencies = new Set();
478
653
  const totals = zero();
479
654
  const origins = new Map();
480
655
  const ads = new Map();
656
+ const bySite = [];
481
657
  const budget = { rows: MAX_LEAD_REPORT_ROWS };
658
+ const sumBudget = { rows: MAX_SPEND_SUM_ROWS };
659
+ const adBudget = { rows: MAX_AD_SPEND_ROWS };
660
+ // Every currency the synced accounts of the requested sites bill in.
661
+ const spendCurrencies = new Set();
662
+ let spendIncomplete = false;
663
+ let treeSpendKnown = true;
664
+ const adEntry = (adId, siteId, accounts) => {
665
+ let entry = ads.get(adId);
666
+ if (!entry) {
667
+ entry = { siteId, counters: zero(), spend: null, accounts };
668
+ ads.set(adId, entry);
669
+ }
670
+ return entry;
671
+ };
482
672
  for (const siteId of siteIds) {
483
673
  const site = await ctx.db.get("sites", siteId);
484
674
  if (!site)
485
675
  fail("NOT_FOUND", "site not found");
486
- currencies.add(site.currency ?? "USD");
676
+ const siteCurrency = site.currency ?? "USD";
677
+ currencies.add(siteCurrency);
678
+ const siteCounters = zero();
679
+ const paidCounters = zero();
487
680
  for (const row of await readRollups(ctx, budget, siteId, "origin", first, last)) {
488
681
  add(totals, row);
682
+ add(siteCounters, row);
683
+ if (row.key === AD_ORIGIN)
684
+ add(paidCounters, row);
489
685
  const counters = origins.get(row.key) ?? zero();
490
686
  add(counters, row);
491
687
  origins.set(row.key, counters);
492
688
  }
689
+ const sources = await spendSources(ctx, siteId);
493
690
  for (const row of await readRollups(ctx, budget, siteId, "ad", first, last)) {
494
- const entry = ads.get(row.key) ?? { siteId, counters: zero() };
495
- add(entry.counters, row);
496
- ads.set(row.key, entry);
691
+ add(adEntry(row.key, siteId, sources.accounts).counters, row);
692
+ }
693
+ for (const currency of sources.currencies)
694
+ spendCurrencies.add(currency);
695
+ // The site's spend, from the site-day sums of its synced accounts.
696
+ let spend = NO_SPEND;
697
+ if (sources.accounts.size > 0) {
698
+ const sums = await readSiteSpend(ctx, sumBudget, siteId, first, lastDay);
699
+ if (sums === null) {
700
+ spendIncomplete = true;
701
+ }
702
+ else {
703
+ const total = zeroSpend();
704
+ for (const row of sums) {
705
+ if (sources.accounts.has(row.adAccountId))
706
+ addSpend(total, row);
707
+ }
708
+ spend = total;
709
+ }
497
710
  }
711
+ const mixed = sources.currencies.size > 1;
712
+ const spendCurrency = sources.currencies.size === 1 ? [...sources.currencies][0] : null;
713
+ bySite.push({
714
+ siteId,
715
+ counters: siteCounters,
716
+ paid: paidCounters,
717
+ // Two currencies cannot be summed: the site's spend is unknown.
718
+ spend: mixed ? NO_SPEND : spend,
719
+ spendCurrency,
720
+ currencyMismatch: mixed || (spendCurrency !== null && spendCurrency !== siteCurrency),
721
+ });
722
+ // The campaign tree's spend, per ad, on a budget of its own.
723
+ if (sources.accounts.size > 0 && treeSpendKnown) {
724
+ const rows = await readAdSpend(ctx, adBudget, siteId, first, lastDay);
725
+ if (rows === null) {
726
+ spendIncomplete = true;
727
+ treeSpendKnown = false;
728
+ }
729
+ else {
730
+ for (const row of rows) {
731
+ if (!sources.accounts.has(row.adAccountId))
732
+ continue;
733
+ const entry = adEntry(row.adId, siteId, sources.accounts);
734
+ entry.spend ??= zeroSpend();
735
+ addSpend(entry.spend, spendOf(row));
736
+ }
737
+ }
738
+ }
739
+ }
740
+ // Spend adds up only in one currency.
741
+ const oneCurrency = spendCurrencies.size <= 1;
742
+ if (!oneCurrency)
743
+ treeSpendKnown = false;
744
+ let totalSpend = NO_SPEND;
745
+ if (oneCurrency) {
746
+ for (const site of bySite)
747
+ totalSpend = plusSpend(totalSpend, site.spend);
498
748
  }
499
749
  if (ads.size > MAX_LEAD_REPORT_ADS) {
500
750
  fail("REPORT_INCOMPLETE", "lead report names more ads than it can resolve", {
@@ -502,10 +752,22 @@ export const report = query({
502
752
  });
503
753
  }
504
754
  const campaigns = new Map();
505
- for (const [adId, { siteId, counters }] of ads) {
755
+ const sum = (row, spend) => {
756
+ const next = plusSpend({
757
+ spendCents: row.spendCents ?? null,
758
+ impressions: row.impressions ?? null,
759
+ clicks: row.clicks ?? null,
760
+ }, spend);
761
+ row.spendCents = next.spendCents;
762
+ row.impressions = next.impressions;
763
+ row.clicks = next.clicks;
764
+ };
765
+ for (const [adId, entry] of ads) {
766
+ const { siteId, counters } = entry;
506
767
  // Every stage counts on the lead's own cohort row, so no leads means
507
- // nothing left there (removed leads leave zeroed rows behind).
508
- if (counters.leads === 0)
768
+ // nothing left there (removed leads leave zeroed rows behind), unless
769
+ // the ad spent: an ad burning budget without leads is the point.
770
+ if (counters.leads === 0 && entry.spend === null)
509
771
  continue;
510
772
  const catalog = await ctx.db
511
773
  .query("adCatalog")
@@ -518,23 +780,41 @@ export const report = query({
518
780
  .withIndex("by_siteId_and_ad_and_firstAt", (range) => range.eq("siteId", siteId).eq("ad.adId", adId))
519
781
  .order("desc")
520
782
  .first();
783
+ // An ad of a synced account that spent nothing in the range spent 0;
784
+ // one no synced account covers has unknown spend.
785
+ const spend = !treeSpendKnown
786
+ ? NO_SPEND
787
+ : entry.spend
788
+ ? spendOf(entry.spend)
789
+ : catalog?.adAccountId && entry.accounts.has(catalog.adAccountId)
790
+ ? zeroSpend()
791
+ : NO_SPEND;
792
+ const thumbnailUrl = catalog?.thumbnailStorageId
793
+ ? await ctx.storage.getUrl(catalog.thumbnailStorageId)
794
+ : null;
521
795
  const ad = {
522
796
  adId,
797
+ siteId,
523
798
  adName: catalog?.adName ?? null,
524
799
  sourceApp: latest?.ad?.sourceApp ?? null,
525
800
  sourceUrl: latest?.ad?.sourceUrl ?? null,
526
801
  title: latest?.ad?.title ?? null,
527
- thumbnailUrl: catalog?.thumbnailUrl ?? null,
802
+ thumbnailUrl: thumbnailUrl ?? catalog?.thumbnailUrl ?? null,
803
+ creativeType: catalog?.creativeType ?? null,
804
+ adStatus: catalog?.adStatus ?? null,
528
805
  ...counters,
806
+ ...spend,
529
807
  };
530
808
  const campaignKey = catalog?.campaignId ?? "";
531
809
  const campaign = campaigns.get(campaignKey) ?? {
532
810
  campaignId: catalog?.campaignId ?? null,
533
811
  campaignName: catalog?.campaignName ?? null,
534
812
  ...zero(),
813
+ ...NO_SPEND,
535
814
  adsets: [],
536
815
  };
537
816
  add(campaign, counters);
817
+ sum(campaign, spend);
538
818
  const adsetId = catalog?.adsetId ?? null;
539
819
  let adset = campaign.adsets.find((row) => row.adsetId === adsetId);
540
820
  if (!adset) {
@@ -542,11 +822,13 @@ export const report = query({
542
822
  adsetId,
543
823
  adsetName: catalog?.adsetName ?? null,
544
824
  ...zero(),
825
+ ...NO_SPEND,
545
826
  ads: [],
546
827
  };
547
828
  campaign.adsets.push(adset);
548
829
  }
549
830
  add(adset, counters);
831
+ sum(adset, spend);
550
832
  adset.ads.push(ad);
551
833
  campaigns.set(campaignKey, campaign);
552
834
  }
@@ -566,19 +848,140 @@ export const report = query({
566
848
  : right.campaignId === null
567
849
  ? -1
568
850
  : byLeadsThenKey((row) => row.campaignId ?? "")(left, right));
851
+ // Spend buys the ad origin only; the organic ones stay unknown.
852
+ if (!origins.has(AD_ORIGIN) && (totalSpend.spendCents ?? 0) > 0) {
853
+ origins.set(AD_ORIGIN, zero());
854
+ }
569
855
  return {
570
856
  from: args.from,
571
857
  to: args.to,
572
858
  currency: currencies.size === 1 ? [...currencies][0] : null,
573
- totals,
859
+ totals: { ...totals, ...totalSpend },
574
860
  origins: [...origins]
575
- .map(([key, counters]) => ({ key, ...counters }))
576
- .filter((row) => row.leads > 0)
861
+ .map(([key, counters]) => ({
862
+ key,
863
+ ...counters,
864
+ ...(key === AD_ORIGIN ? totalSpend : NO_SPEND),
865
+ }))
866
+ .filter((row) => row.leads > 0 || (row.spendCents !== null && row.spendCents > 0))
577
867
  .sort(byLeadsThenKey((row) => row.key)),
578
868
  campaigns: campaignRows,
869
+ bySite: bySite.map((site) => ({
870
+ siteId: site.siteId,
871
+ ...site.counters,
872
+ paid: site.paid,
873
+ spendCents: site.spend.spendCents,
874
+ spendCurrency: site.spendCurrency,
875
+ currencyMismatch: site.currencyMismatch,
876
+ })),
877
+ spendCurrency: oneCurrency && totalSpend.spendCents !== null
878
+ ? ([...spendCurrencies][0] ?? null)
879
+ : null,
880
+ currencyMismatch: !oneCurrency || bySite.some((site) => site.currencyMismatch),
881
+ spendIncomplete,
579
882
  };
580
883
  },
581
884
  });
885
+ /**
886
+ * The lead funnel per cohort day, for sparklines and the sales × spend chart:
887
+ * every UTC day in the range, empty days included. With `adId`, only that
888
+ * ad's leads; with `origin`, only that origin's (`ctwa_ad` for the leads the
889
+ * ads bought). Spend is the day's Meta spend for the same scope, null where
890
+ * no synced ad account covers it, for an organic origin, or when the sites'
891
+ * accounts bill in more than one currency.
892
+ */
893
+ export const daily = query({
894
+ args: {
895
+ siteIds: v.array(v.id("sites")),
896
+ from: v.number(),
897
+ to: v.number(),
898
+ adId: v.optional(v.string()),
899
+ origin: v.optional(v.string()),
900
+ },
901
+ returns: leadsDailyValidator,
902
+ handler: async (ctx, args) => {
903
+ const siteIds = validateSites(args.siteIds);
904
+ const { first, last } = cohortDays(args.from, args.to);
905
+ const lastDay = Math.floor(last / DAY_MS) * DAY_MS;
906
+ if ((lastDay - first) / DAY_MS + 1 > MAX_LEAD_DAILY_DAYS) {
907
+ fail("INVALID_ARGUMENT", `a daily series spans at most ${MAX_LEAD_DAILY_DAYS} UTC days`);
908
+ }
909
+ if (args.adId !== undefined && args.origin !== undefined) {
910
+ fail("INVALID_ARGUMENT", "pass adId or origin, not both");
911
+ }
912
+ if (args.origin !== undefined && !ENTRY_POINT.test(args.origin)) {
913
+ invalid("origin");
914
+ }
915
+ const adId = args.adId === undefined ? undefined : opaque(args.adId, "adId");
916
+ const key = adId ?? args.origin;
917
+ const days = new Map();
918
+ for (let day = first; day <= lastDay; day += DAY_MS)
919
+ days.set(day, zero());
920
+ const budget = { rows: MAX_LEAD_REPORT_ROWS };
921
+ const spendBudget = { rows: MAX_SPEND_SUM_ROWS };
922
+ const spend = new Map();
923
+ const spendCurrencies = new Set();
924
+ let spendKnown = false;
925
+ let spendBroken = false;
926
+ for (const siteId of siteIds) {
927
+ const site = await ctx.db.get("sites", siteId);
928
+ if (!site)
929
+ fail("NOT_FOUND", "site not found");
930
+ for (const row of await readRollups(ctx, budget, siteId, adId === undefined ? "origin" : "ad", first, last)) {
931
+ if (key !== undefined && row.key !== key)
932
+ continue;
933
+ const counters = days.get(row.dayStart);
934
+ if (counters)
935
+ add(counters, row);
936
+ }
937
+ // Spend buys the ad leads: an organic origin has none to show.
938
+ if (args.origin !== undefined && args.origin !== AD_ORIGIN)
939
+ continue;
940
+ const sources = await spendSources(ctx, siteId);
941
+ if (sources.accounts.size === 0)
942
+ continue;
943
+ let rows;
944
+ if (adId === undefined) {
945
+ rows = await readSiteSpend(ctx, spendBudget, siteId, first, lastDay);
946
+ }
947
+ else {
948
+ rows = await readAdSpend(ctx, spendBudget, siteId, first, lastDay, adId);
949
+ const own = rows?.some((row) => sources.accounts.has(row.adAccountId));
950
+ if (!own) {
951
+ // One ad: its spend is known only if a synced account of this
952
+ // site runs it.
953
+ const catalog = await ctx.db
954
+ .query("adCatalog")
955
+ .withIndex("by_siteId_and_adId", (range) => range.eq("siteId", siteId).eq("adId", adId))
956
+ .unique();
957
+ if (!catalog?.adAccountId ||
958
+ !sources.accounts.has(catalog.adAccountId)) {
959
+ continue;
960
+ }
961
+ }
962
+ }
963
+ if (rows === null) {
964
+ spendBroken = true;
965
+ continue;
966
+ }
967
+ spendKnown = true;
968
+ for (const currency of sources.currencies)
969
+ spendCurrencies.add(currency);
970
+ for (const row of rows) {
971
+ if (!sources.accounts.has(row.adAccountId))
972
+ continue;
973
+ spend.set(row.dayStart, (spend.get(row.dayStart) ?? 0) + row.spendCents);
974
+ }
975
+ }
976
+ // Two currencies do not add up; a series past its budget is not whole.
977
+ const showSpend = spendKnown && !spendBroken && spendCurrencies.size <= 1;
978
+ return [...days].map(([dayStart, counters]) => ({
979
+ dayStart,
980
+ ...counters,
981
+ spendCents: showSpend ? (spend.get(dayStart) ?? 0) : null,
982
+ }));
983
+ },
984
+ });
582
985
  // The click id stays in the host's deployment: it is for the Conversions API,
583
986
  // not for a dashboard.
584
987
  function summarize(lead) {