@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
@@ -0,0 +1,410 @@
1
+ import { v, type Infer } from "convex/values";
2
+ import type { Doc, Id } from "./_generated/dataModel.js";
3
+ import {
4
+ mutation,
5
+ query,
6
+ type MutationCtx,
7
+ type QueryCtx,
8
+ } from "./_generated/server.js";
9
+ import { fail } from "./errors.js";
10
+ import { ownerKeyOf } from "./meta.js";
11
+ import { enqueueColumnEvent, type MetaUserData } from "./metaCapi.js";
12
+ import { cleanString } from "./sanitize.js";
13
+ import {
14
+ metaColumnEventValidator,
15
+ metaEventMapValidator,
16
+ pipelineColumnValidator,
17
+ } from "./validators.js";
18
+
19
+ /**
20
+ * The host's board for a site and the Meta event each column sends. The host
21
+ * declares its columns (`setPipeline`), moves leads between them (`column` on
22
+ * `leads.record` / `leads.setStage`), and an owner maps columns to events
23
+ * (`setEventMap`). Rastro counts the entries per column per day for the map's
24
+ * monthly figures. `Purchase` never comes from a column: it is sent when the
25
+ * host records the sale (`won` with a value). See docs/meta-ads.md.
26
+ */
27
+
28
+ export type MetaColumnEvent = Infer<typeof metaColumnEventValidator>;
29
+
30
+ export const MAX_PIPELINE_COLUMNS = 40;
31
+ const COLUMN_KEY = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/;
32
+ const COLOR = /^[#A-Za-z0-9(),.% -]{1,32}$/;
33
+ const MAX_COLUMN_NAME = 80;
34
+ const MAX_GROUP_NAME = 60;
35
+ const DAY_MS = 86_400_000;
36
+ const MONTH_DAYS = 30;
37
+ /** The column moves `leads.remove` clears per lead in one transaction. */
38
+ const MAX_COLUMN_MOVES_PER_LEAD = 1_000;
39
+
40
+ const dayOf = (at: number) => Math.floor(at / DAY_MS) * DAY_MS;
41
+
42
+ function columnKeyOf(value: string): string {
43
+ const key = value.trim();
44
+ if (!COLUMN_KEY.test(key)) {
45
+ fail(
46
+ "INVALID_ARGUMENT",
47
+ "a column key is 1-64 letters, digits, '_', '.', ':' or '-'",
48
+ );
49
+ }
50
+ return key;
51
+ }
52
+
53
+ async function pipelineOf(ctx: MutationCtx, siteId: Id<"sites">) {
54
+ return await ctx.db
55
+ .query("sitePipelines")
56
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
57
+ .unique();
58
+ }
59
+
60
+ async function requireSite(ctx: QueryCtx, siteId: Id<"sites">): Promise<void> {
61
+ if (!(await ctx.db.get("sites", siteId))) fail("NOT_FOUND", "site not found");
62
+ }
63
+
64
+ /**
65
+ * Declares the host's board columns, in order. Declaring again replaces names,
66
+ * colors, groups and order; a column that is gone leaves the event map, and
67
+ * one that stays keeps its event.
68
+ */
69
+ export const setPipeline = mutation({
70
+ args: { siteId: v.id("sites"), columns: v.array(pipelineColumnValidator) },
71
+ returns: v.null(),
72
+ handler: async (ctx, args) => {
73
+ await requireSite(ctx, args.siteId);
74
+ if (args.columns.length > MAX_PIPELINE_COLUMNS) {
75
+ fail(
76
+ "LIMIT_EXCEEDED",
77
+ `a pipeline has at most ${MAX_PIPELINE_COLUMNS} columns`,
78
+ );
79
+ }
80
+ const existing = await pipelineOf(ctx, args.siteId);
81
+ const events = new Map(
82
+ (existing?.columns ?? []).map((column) => [column.key, column.event]),
83
+ );
84
+ const seen = new Set<string>();
85
+ const columns = args.columns.map((column) => {
86
+ const key = columnKeyOf(column.key);
87
+ if (seen.has(key)) fail("INVALID_ARGUMENT", `column ${key} is repeated`);
88
+ seen.add(key);
89
+ const name = cleanString(column.name, MAX_COLUMN_NAME);
90
+ if (!name) fail("INVALID_ARGUMENT", `column ${key} needs a name`);
91
+ const color = column.color?.trim();
92
+ if (color !== undefined && color !== "" && !COLOR.test(color)) {
93
+ fail("INVALID_ARGUMENT", `column ${key} has an invalid color`);
94
+ }
95
+ const group = column.group
96
+ ? cleanString(column.group, MAX_GROUP_NAME)
97
+ : "";
98
+ const event = events.get(key);
99
+ return {
100
+ key,
101
+ name,
102
+ ...(color ? { color } : {}),
103
+ ...(group ? { group } : {}),
104
+ ...(event ? { event } : {}),
105
+ };
106
+ });
107
+ const now = Date.now();
108
+ if (existing) {
109
+ await ctx.db.patch("sitePipelines", existing._id, {
110
+ columns,
111
+ updatedAt: now,
112
+ });
113
+ } else {
114
+ await ctx.db.insert("sitePipelines", {
115
+ siteId: args.siteId,
116
+ columns,
117
+ sendLeadEvent: await pixelSendsLead(ctx, args.siteId),
118
+ updatedAt: now,
119
+ });
120
+ }
121
+ return null;
122
+ },
123
+ });
124
+
125
+ async function pixelSendsLead(ctx: MutationCtx, siteId: Id<"sites">) {
126
+ const pixel = await ctx.db
127
+ .query("sitePixels")
128
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
129
+ .unique();
130
+ return pixel?.sendLeadEvent ?? false;
131
+ }
132
+
133
+ /**
134
+ * Which event each declared column sends, replacing the whole map: a column
135
+ * left out, or mapped to null, sends nothing. `sendLeadEvent` turns `Lead` on
136
+ * for every new lead (kept on the pixel too, where the sender reads it).
137
+ */
138
+ export const setEventMap = mutation({
139
+ args: {
140
+ siteId: v.id("sites"),
141
+ columns: v.record(v.string(), v.union(metaColumnEventValidator, v.null())),
142
+ sendLeadEvent: v.boolean(),
143
+ /**
144
+ * The owner saving the map. The map feeds the site's pixel, so whoever
145
+ * set the pixel decides it: with `ownerKey`, a pixel another owner (or
146
+ * the host) set is a `CONFLICT`. A site without a pixel takes anyone's.
147
+ */
148
+ ownerKey: v.optional(v.string()),
149
+ },
150
+ returns: v.null(),
151
+ handler: async (ctx, args) => {
152
+ await requireSite(ctx, args.siteId);
153
+ const pixel = await ctx.db
154
+ .query("sitePixels")
155
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
156
+ .unique();
157
+ if (
158
+ args.ownerKey !== undefined &&
159
+ pixel &&
160
+ pixel.setBy !== ownerKeyOf(args.ownerKey)
161
+ ) {
162
+ fail(
163
+ "CONFLICT",
164
+ "the site's pixel was set by another owner on this host; change its events there",
165
+ );
166
+ }
167
+ const existing = await pipelineOf(ctx, args.siteId);
168
+ const declared = new Set((existing?.columns ?? []).map((row) => row.key));
169
+ for (const key of Object.keys(args.columns)) {
170
+ if (!declared.has(key)) {
171
+ fail(
172
+ "INVALID_ARGUMENT",
173
+ `column ${key.slice(0, 64)} is not on the site's pipeline`,
174
+ );
175
+ }
176
+ }
177
+ const columns = (existing?.columns ?? []).map(
178
+ ({ event: _event, ...column }) => {
179
+ const event = args.columns[column.key];
180
+ return event ? { ...column, event } : column;
181
+ },
182
+ );
183
+ const now = Date.now();
184
+ if (existing) {
185
+ await ctx.db.patch("sitePipelines", existing._id, {
186
+ columns,
187
+ sendLeadEvent: args.sendLeadEvent,
188
+ updatedAt: now,
189
+ });
190
+ } else {
191
+ await ctx.db.insert("sitePipelines", {
192
+ siteId: args.siteId,
193
+ columns,
194
+ sendLeadEvent: args.sendLeadEvent,
195
+ updatedAt: now,
196
+ });
197
+ }
198
+ // `updatedAt` stays: it says when the pixel itself was set, which is what
199
+ // removing its owner compares against.
200
+ if (pixel && pixel.sendLeadEvent !== args.sendLeadEvent) {
201
+ await ctx.db.patch("sitePixels", pixel._id, {
202
+ sendLeadEvent: args.sendLeadEvent,
203
+ });
204
+ }
205
+ return null;
206
+ },
207
+ });
208
+
209
+ /**
210
+ * The site's columns with their events and how many leads entered each in the
211
+ * 30 UTC days up to `now` (the caller's clock, so the query stays cacheable;
212
+ * without it, or before the host ever sent a column, the counts are null).
213
+ */
214
+ export const eventMap = query({
215
+ args: { siteId: v.id("sites"), now: v.optional(v.number()) },
216
+ returns: metaEventMapValidator,
217
+ handler: async (ctx, args) => {
218
+ await requireSite(ctx, args.siteId);
219
+ if (
220
+ args.now !== undefined &&
221
+ (!Number.isSafeInteger(args.now) || args.now < 0)
222
+ ) {
223
+ fail("INVALID_ARGUMENT", "now must be a Unix time in milliseconds");
224
+ }
225
+ const pipeline = await ctx.db
226
+ .query("sitePipelines")
227
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
228
+ .unique();
229
+ const pixel = await ctx.db
230
+ .query("sitePixels")
231
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
232
+ .unique();
233
+ const counted =
234
+ args.now !== undefined && pipeline?.columnsSince !== undefined;
235
+ const to = dayOf(args.now ?? 0);
236
+ const from = to - (MONTH_DAYS - 1) * DAY_MS;
237
+ const columns = [];
238
+ for (const column of pipeline?.columns ?? []) {
239
+ let monthCount: number | null = null;
240
+ if (counted) {
241
+ // At most 30 rows: one per day.
242
+ const rows = await ctx.db
243
+ .query("leadColumnDaily")
244
+ .withIndex("by_siteId_and_column_and_dayStart", (range) =>
245
+ range
246
+ .eq("siteId", args.siteId)
247
+ .eq("column", column.key)
248
+ .gte("dayStart", from)
249
+ .lte("dayStart", to),
250
+ )
251
+ .take(MONTH_DAYS);
252
+ monthCount = rows.reduce((sum, row) => sum + row.entries, 0);
253
+ }
254
+ columns.push({
255
+ key: column.key,
256
+ name: column.name,
257
+ color: column.color ?? null,
258
+ group: column.group ?? null,
259
+ event: column.event ?? null,
260
+ monthCount,
261
+ });
262
+ }
263
+ return {
264
+ columns,
265
+ sendLeadEvent: pipeline?.sendLeadEvent ?? pixel?.sendLeadEvent ?? false,
266
+ purchase: { enabled: pixel?.enabled ?? false },
267
+ };
268
+ },
269
+ });
270
+
271
+ async function bumpDay(
272
+ ctx: MutationCtx,
273
+ siteId: Id<"sites">,
274
+ column: string,
275
+ dayStart: number,
276
+ delta: 1 | -1,
277
+ ) {
278
+ const row = await ctx.db
279
+ .query("leadColumnDaily")
280
+ .withIndex("by_siteId_and_column_and_dayStart", (range) =>
281
+ range.eq("siteId", siteId).eq("column", column).eq("dayStart", dayStart),
282
+ )
283
+ .unique();
284
+ if (!row) {
285
+ if (delta > 0) {
286
+ await ctx.db.insert("leadColumnDaily", {
287
+ siteId,
288
+ column,
289
+ dayStart,
290
+ entries: 1,
291
+ });
292
+ }
293
+ return;
294
+ }
295
+ const entries = row.entries + delta;
296
+ if (entries <= 0) await ctx.db.delete("leadColumnDaily", row._id);
297
+ else await ctx.db.patch("leadColumnDaily", row._id, { entries });
298
+ }
299
+
300
+ /**
301
+ * Moves a lead into the host's column `key`, from `leads.record` or
302
+ * `leads.setStage`. A key the site's pipeline does not declare is ignored,
303
+ * never an error, and so is a move into the column the lead is already in or
304
+ * one older than the lead's last move (`columnAt`): a late retry never moves
305
+ * a lead back. Only an accepted move counts and reaches the event map's seam.
306
+ * Returns the fields to patch on the lead, or null when nothing moved.
307
+ *
308
+ * The monthly counts are distinct leads: each lead keeps one mark per column
309
+ * on the day it last entered it, and the day's count follows the mark, so a
310
+ * lead that leaves and comes back is counted once, on its latest entry.
311
+ */
312
+ export async function enterColumn(
313
+ ctx: MutationCtx,
314
+ lead: Pick<Doc<"leads">, "_id" | "siteId" | "column" | "columnAt">,
315
+ key: string | undefined,
316
+ at: number,
317
+ meta?: MetaUserData,
318
+ ): Promise<{ column: string; columnAt: number } | null> {
319
+ const column = key?.trim();
320
+ if (!column || column === lead.column) return null;
321
+ if (lead.columnAt !== undefined && at < lead.columnAt) return null;
322
+ const pipeline = await pipelineOf(ctx, lead.siteId);
323
+ const declared = pipeline?.columns.find((row) => row.key === column);
324
+ if (!pipeline || !declared) return null;
325
+ const dayStart = dayOf(at);
326
+ const mark = await ctx.db
327
+ .query("leadColumnMarks")
328
+ .withIndex("by_leadId_and_column", (range) =>
329
+ range.eq("leadId", lead._id).eq("column", column),
330
+ )
331
+ .unique();
332
+ if (!mark) {
333
+ await ctx.db.insert("leadColumnMarks", {
334
+ siteId: lead.siteId,
335
+ leadId: lead._id,
336
+ column,
337
+ dayStart,
338
+ });
339
+ await bumpDay(ctx, lead.siteId, column, dayStart, 1);
340
+ } else if (mark.dayStart < dayStart) {
341
+ await bumpDay(ctx, lead.siteId, column, mark.dayStart, -1);
342
+ await bumpDay(ctx, lead.siteId, column, dayStart, 1);
343
+ await ctx.db.patch("leadColumnMarks", mark._id, { dayStart });
344
+ }
345
+ if (pipeline.columnsSince === undefined) {
346
+ await ctx.db.patch("sitePipelines", pipeline._id, { columnsSince: at });
347
+ }
348
+ if (declared.event) {
349
+ await enqueueColumnEvent(ctx, {
350
+ siteId: lead.siteId,
351
+ leadId: lead._id,
352
+ eventName: declared.event,
353
+ at,
354
+ meta,
355
+ });
356
+ }
357
+ return { column, columnAt: at };
358
+ }
359
+
360
+ /**
361
+ * A column move the host sent alone (`setLeadStage` with a column and no
362
+ * stage), idempotent by its `eventId` like a stage change. Returns whether
363
+ * the eventId was new, and the lead it was first recorded for.
364
+ */
365
+ export async function recordColumnMove(
366
+ ctx: MutationCtx,
367
+ move: {
368
+ siteId: Id<"sites">;
369
+ leadId: Id<"leads">;
370
+ eventId: string;
371
+ column: string;
372
+ at: number;
373
+ },
374
+ ): Promise<{ fresh: boolean; leadId: Id<"leads"> }> {
375
+ const seen = await ctx.db
376
+ .query("leadColumnMoves")
377
+ .withIndex("by_siteId_and_eventId", (range) =>
378
+ range.eq("siteId", move.siteId).eq("eventId", move.eventId),
379
+ )
380
+ .unique();
381
+ if (seen) return { fresh: false, leadId: seen.leadId };
382
+ await ctx.db.insert("leadColumnMoves", {
383
+ ...move,
384
+ column: move.column.slice(0, 64),
385
+ });
386
+ return { fresh: true, leadId: move.leadId };
387
+ }
388
+
389
+ /**
390
+ * Takes a removed lead out of the monthly counts and forgets its column
391
+ * marks and moves (both bounded per lead).
392
+ */
393
+ export async function forgetLeadColumns(
394
+ ctx: MutationCtx,
395
+ lead: Pick<Doc<"leads">, "_id" | "siteId">,
396
+ ): Promise<void> {
397
+ const marks = await ctx.db
398
+ .query("leadColumnMarks")
399
+ .withIndex("by_leadId_and_column", (range) => range.eq("leadId", lead._id))
400
+ .take(MAX_PIPELINE_COLUMNS * 2);
401
+ for (const mark of marks) {
402
+ await bumpDay(ctx, lead.siteId, mark.column, mark.dayStart, -1);
403
+ await ctx.db.delete("leadColumnMarks", mark._id);
404
+ }
405
+ const moves = await ctx.db
406
+ .query("leadColumnMoves")
407
+ .withIndex("by_leadId", (range) => range.eq("leadId", lead._id))
408
+ .take(MAX_COLUMN_MOVES_PER_LEAD);
409
+ for (const move of moves) await ctx.db.delete("leadColumnMoves", move._id);
410
+ }
@@ -6,6 +6,13 @@ import {
6
6
  aggregateFieldsValidator,
7
7
  leadEventTypeValidator,
8
8
  leadFieldsValidator,
9
+ metaColumnEventValidator,
10
+ metaConnectionFieldsValidator,
11
+ metaConversionEventNameValidator,
12
+ metaUserDataValidator,
13
+ pipelineColumnValidator,
14
+ siteAdAccountFieldsValidator,
15
+ sitePixelFieldsValidator,
9
16
  batchedEventValidator,
10
17
  funnelFieldsValidator,
11
18
  goalFieldsValidator,
@@ -430,12 +437,132 @@ export default defineSchema({
430
437
  "key",
431
438
  ]),
432
439
 
433
- // Names the host fetches from the Meta Marketing API for the ads it saw.
440
+ // Names the Meta sync (or the host) fetches from the Marketing API.
434
441
  adCatalog: defineTable(adCatalogFieldsValidator).index("by_siteId_and_adId", [
435
442
  "siteId",
436
443
  "adId",
437
444
  ]),
438
445
 
446
+ // A Meta token the host pasted for one of its owners (see meta.ts),
447
+ // encrypted with RASTRO_SECRETS_KEY. No query returns the cipher.
448
+ metaConnections: defineTable(metaConnectionFieldsValidator).index(
449
+ "by_ownerKey",
450
+ ["ownerKey"],
451
+ ),
452
+
453
+ // A site's spend source: one Meta ad account, synced by the chain in
454
+ // metaSync.ts. `nextSyncAt` is when the chain takes it next.
455
+ siteAdAccounts: defineTable(siteAdAccountFieldsValidator)
456
+ .index("by_siteId", ["siteId"])
457
+ .index("by_connectionId", ["connectionId"])
458
+ .index("by_nextSyncAt", ["nextSyncAt"])
459
+ .index("by_siteId_and_adAccountId", ["siteId", "adAccountId"])
460
+ // An ad account feeds one site: linking checks it here.
461
+ .index("by_adAccountId", ["adAccountId"]),
462
+
463
+ // What one ad spent on one account-local day (`dayStart` is that date's
464
+ // UTC midnight). Meta's figures replace the row on every resync.
465
+ adSpendDaily: defineTable({
466
+ siteId: v.id("sites"),
467
+ adAccountId: v.string(),
468
+ adId: v.string(),
469
+ dayStart: v.number(),
470
+ spendCents: v.number(),
471
+ impressions: v.number(),
472
+ clicks: v.number(),
473
+ metaConversations: v.optional(v.number()),
474
+ updatedAt: v.number(),
475
+ })
476
+ .index("by_siteId_and_dayStart", ["siteId", "dayStart"])
477
+ .index("by_siteId_and_adId_and_dayStart", ["siteId", "adId", "dayStart"])
478
+ .index("by_siteId_and_adAccountId_and_dayStart", [
479
+ "siteId",
480
+ "adAccountId",
481
+ "dayStart",
482
+ ]),
483
+
484
+ // `adSpendDaily` summed per site, ad account and day, kept by every write
485
+ // to it: the site totals read these few rows, so linking Meta never eats the
486
+ // lead report's row budget.
487
+ siteSpendDaily: defineTable({
488
+ siteId: v.id("sites"),
489
+ adAccountId: v.string(),
490
+ dayStart: v.number(),
491
+ spendCents: v.number(),
492
+ impressions: v.number(),
493
+ clicks: v.number(),
494
+ })
495
+ .index("by_siteId_and_dayStart", ["siteId", "dayStart"])
496
+ .index("by_siteId_and_adAccountId_and_dayStart", [
497
+ "siteId",
498
+ "adAccountId",
499
+ "dayStart",
500
+ ]),
501
+
502
+ // The site's Meta pixel (dataset) for the Conversions API.
503
+ sitePixels: defineTable(sitePixelFieldsValidator)
504
+ .index("by_siteId", ["siteId"])
505
+ // A connection's pixels go with it when it is removed.
506
+ .index("by_connectionId", ["connectionId"])
507
+ // The pixels an owner set, for the federated scope check.
508
+ .index("by_setBy", ["setBy"]),
509
+
510
+ // The host's board for a site (its columns in order) and which Meta event
511
+ // each column sends. One row per site; see pipeline.ts.
512
+ sitePipelines: defineTable({
513
+ siteId: v.id("sites"),
514
+ columns: v.array(
515
+ pipelineColumnValidator.extend({
516
+ event: v.optional(metaColumnEventValidator),
517
+ }),
518
+ ),
519
+ /** Whether a new lead is sent as `Lead` (mirrored on the pixel). */
520
+ sendLeadEvent: v.boolean(),
521
+ /** When the host first sent a lead's column: counts start there. */
522
+ columnsSince: v.optional(v.number()),
523
+ updatedAt: v.number(),
524
+ }).index("by_siteId", ["siteId"]),
525
+
526
+ // Leads whose latest entry into a column fell on each UTC day, for the
527
+ // event map's monthly counts of distinct leads. Lead volume is human
528
+ // scale, so a row is never a hot spot.
529
+ leadColumnDaily: defineTable({
530
+ siteId: v.id("sites"),
531
+ column: v.string(),
532
+ dayStart: v.number(),
533
+ entries: v.number(),
534
+ }).index("by_siteId_and_column_and_dayStart", [
535
+ "siteId",
536
+ "column",
537
+ "dayStart",
538
+ ]),
539
+
540
+ // One per lead and column: the UTC day the lead last entered it.
541
+ leadColumnMarks: defineTable({
542
+ siteId: v.id("sites"),
543
+ leadId: v.id("leads"),
544
+ column: v.string(),
545
+ dayStart: v.number(),
546
+ }).index("by_leadId_and_column", ["leadId", "column"]),
547
+
548
+ // Column moves the host sent alone, so a retried `eventId` is a no-op.
549
+ leadColumnMoves: defineTable({
550
+ siteId: v.id("sites"),
551
+ leadId: v.id("leads"),
552
+ eventId: v.string(),
553
+ column: v.string(),
554
+ at: v.number(),
555
+ })
556
+ .index("by_siteId_and_eventId", ["siteId", "eventId"])
557
+ .index("by_leadId", ["leadId"]),
558
+
559
+ // The single scheduler chain that syncs every linked ad account.
560
+ metaSyncLoop: defineTable({
561
+ key: v.literal("loop"),
562
+ scheduledFor: v.number(),
563
+ jobId: v.id("_scheduled_functions"),
564
+ }).index("by_key", ["key"]),
565
+
439
566
  visitorSecrets: defineTable({
440
567
  key: v.literal("current"),
441
568
  value: v.string(),
@@ -569,4 +696,73 @@ export default defineSchema({
569
696
  "windowStart",
570
697
  ])
571
698
  .index("by_siteId_and_updatedAt", ["siteId", "updatedAt"]),
699
+
700
+ // Conversions waiting for (or done with) the site's Meta pixel, sent by
701
+ // metaCapi.ts. `userData` holds the host's hashes only until the final
702
+ // attempt, then it is erased.
703
+ metaConversionOutbox: defineTable({
704
+ siteId: v.id("sites"),
705
+ leadId: v.id("leads"),
706
+ eventId: v.string(),
707
+ eventName: metaConversionEventNameValidator,
708
+ eventTime: v.number(),
709
+ valueCents: v.optional(v.number()),
710
+ currency: v.optional(v.string()),
711
+ userData: v.optional(metaUserDataValidator),
712
+ status: v.union(
713
+ v.literal("pending"),
714
+ v.literal("sent"),
715
+ v.literal("failed"),
716
+ ),
717
+ attempts: v.number(),
718
+ /** When the current attempt is due; the sweep reads it to find stalls. */
719
+ nextAttemptAt: v.number(),
720
+ /** When a send took the current attempt: its lease, for the sweep. */
721
+ attemptStartedAt: v.optional(v.number()),
722
+ lastError: v.optional(v.string()),
723
+ fbtraceId: v.optional(v.string()),
724
+ sentAt: v.optional(v.number()),
725
+ createdAt: v.number(),
726
+ })
727
+ .index("by_siteId_and_eventId", ["siteId", "eventId"])
728
+ .index("by_siteId_and_createdAt", ["siteId", "createdAt"])
729
+ .index("by_siteId_and_status_and_createdAt", [
730
+ "siteId",
731
+ "status",
732
+ "createdAt",
733
+ ])
734
+ .index("by_leadId", ["leadId"])
735
+ .index("by_status_and_nextAttemptAt", ["status", "nextAttemptAt"]),
736
+
737
+ // The single scheduler chain that re-sends stalled outbox rows; it stops
738
+ // by itself when nothing is pending.
739
+ metaCapiSweep: defineTable({
740
+ key: v.literal("sweep"),
741
+ scheduledFor: v.number(),
742
+ jobId: v.id("_scheduled_functions"),
743
+ }).index("by_key", ["key"]),
744
+
745
+ // The outbox counted per site and status, kept by every write to it, so
746
+ // `meta.status` never scans the history. Split in shards by event (see
747
+ // metaCapi.ts) so concurrent sends rarely write the same document.
748
+ metaConversionCounts: defineTable({
749
+ siteId: v.id("sites"),
750
+ shard: v.number(),
751
+ pending: v.number(),
752
+ sent: v.number(),
753
+ failed: v.number(),
754
+ }).index("by_siteId_and_shard", ["siteId", "shard"]),
755
+
756
+ // Conversions Meta accepted, per site, UTC day and event, in the same
757
+ // shards as the counts: `meta.status` reads the last 30 days through the
758
+ // index, at most 31 days times the shards.
759
+ metaConversionDaily: defineTable({
760
+ siteId: v.id("sites"),
761
+ dayStart: v.number(),
762
+ shard: v.number(),
763
+ Purchase: v.number(),
764
+ QualifiedLead: v.number(),
765
+ Lead: v.number(),
766
+ Schedule: v.number(),
767
+ }).index("by_siteId_and_dayStart_and_shard", ["siteId", "dayStart", "shard"]),
572
768
  });