lytx 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/.env.example +37 -0
  2. package/README.md +486 -0
  3. package/alchemy.run.ts +155 -0
  4. package/cli/bootstrap-admin.ts +284 -0
  5. package/cli/deploy-staging.ts +692 -0
  6. package/cli/import-events.ts +628 -0
  7. package/cli/import-sites.ts +518 -0
  8. package/cli/index.ts +609 -0
  9. package/cli/init-db.ts +269 -0
  10. package/cli/migrate-to-durable-objects.ts +564 -0
  11. package/cli/migration-worker.ts +300 -0
  12. package/cli/performance-test.ts +588 -0
  13. package/cli/pg/client.ts +4 -0
  14. package/cli/pg/new-site.ts +153 -0
  15. package/cli/rollback-durable-objects.ts +622 -0
  16. package/cli/seed-data.ts +459 -0
  17. package/cli/setup.js +18 -0
  18. package/cli/setup.ts +463 -0
  19. package/cli/validate-migration.ts +200 -0
  20. package/cli/wrangler-migration.jsonc +28 -0
  21. package/db/adapter.ts +166 -0
  22. package/db/analytics_engine/client.ts +0 -0
  23. package/db/analytics_engine/sites.ts +0 -0
  24. package/db/client.ts +16 -0
  25. package/db/d1/client.ts +8 -0
  26. package/db/d1/drizzle.config.ts +35 -0
  27. package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
  28. package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
  29. package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
  30. package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
  31. package/db/d1/migrations/0004_mute_stardust.sql +1 -0
  32. package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
  33. package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
  34. package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
  35. package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
  36. package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
  37. package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
  38. package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
  39. package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
  40. package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
  41. package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
  42. package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
  43. package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
  44. package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
  45. package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
  46. package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
  47. package/db/d1/migrations/meta/_journal.json +76 -0
  48. package/db/d1/schema.ts +407 -0
  49. package/db/d1/sites.ts +374 -0
  50. package/db/d1/teamAiUsage.ts +101 -0
  51. package/db/d1/teams.ts +127 -0
  52. package/db/durable/drizzle.config.ts +8 -0
  53. package/db/durable/durableObjectClient.ts +480 -0
  54. package/db/durable/events.ts +100 -0
  55. package/db/durable/migrations/0000_fair_bucky.sql +38 -0
  56. package/db/durable/migrations/meta/0000_snapshot.json +278 -0
  57. package/db/durable/migrations/meta/_journal.json +13 -0
  58. package/db/durable/migrations/migrations.js +10 -0
  59. package/db/durable/schema.ts +5 -0
  60. package/db/durable/siteDurableObject.ts +1352 -0
  61. package/db/durable/types.ts +53 -0
  62. package/db/postgres/client.ts +13 -0
  63. package/db/postgres/drizzle.config.ts +12 -0
  64. package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
  65. package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
  66. package/db/postgres/migrations/meta/_journal.json +13 -0
  67. package/db/postgres/schema.ts +145 -0
  68. package/db/postgres/sites.ts +118 -0
  69. package/db/tranformReports.ts +595 -0
  70. package/db/types.ts +55 -0
  71. package/endpoints/api_worker.tsx +1854 -0
  72. package/endpoints/site_do_worker.ts +11 -0
  73. package/index.d.ts +63 -0
  74. package/index.ts +83 -0
  75. package/lib/auth.ts +279 -0
  76. package/lib/geojson/world_countries.json +45307 -0
  77. package/lib/random_name.ts +41 -0
  78. package/lib/sendMail.ts +252 -0
  79. package/package.json +142 -0
  80. package/public/favicon.ico +0 -0
  81. package/public/images/android-chrome-192x192.png +0 -0
  82. package/public/images/android-chrome-512x512.png +0 -0
  83. package/public/images/apple-touch-icon.png +0 -0
  84. package/public/images/favicon-16x16.png +0 -0
  85. package/public/images/favicon-32x32.png +0 -0
  86. package/public/images/lytx_dark_dashboard.png +0 -0
  87. package/public/images/lytx_light_dashboard.png +0 -0
  88. package/public/images/safari-pinned-tab.svg +4 -0
  89. package/public/logo.png +0 -0
  90. package/public/site.webmanifest +26 -0
  91. package/public/sw.js +107 -0
  92. package/src/Document.tsx +86 -0
  93. package/src/api/ai_api.ts +1156 -0
  94. package/src/api/authMiddleware.ts +45 -0
  95. package/src/api/auth_api.ts +465 -0
  96. package/src/api/event_labels_api.ts +193 -0
  97. package/src/api/events_api.ts +210 -0
  98. package/src/api/queueWorker.ts +303 -0
  99. package/src/api/reports_api.ts +278 -0
  100. package/src/api/seed_api.ts +288 -0
  101. package/src/api/sites_api.ts +904 -0
  102. package/src/api/tag_api.ts +458 -0
  103. package/src/api/tag_api_v2.ts +289 -0
  104. package/src/api/team_api.ts +456 -0
  105. package/src/app/Dashboard.tsx +1339 -0
  106. package/src/app/Events.tsx +974 -0
  107. package/src/app/Explore.tsx +312 -0
  108. package/src/app/Layout.tsx +58 -0
  109. package/src/app/Settings.tsx +1302 -0
  110. package/src/app/components/DashboardCard.tsx +118 -0
  111. package/src/app/components/EditableCell.tsx +123 -0
  112. package/src/app/components/EventForm.tsx +93 -0
  113. package/src/app/components/MarketingFooter.tsx +49 -0
  114. package/src/app/components/MarketingNav.tsx +150 -0
  115. package/src/app/components/Nav.tsx +755 -0
  116. package/src/app/components/NewSiteSetup.tsx +298 -0
  117. package/src/app/components/SQLEditor.tsx +740 -0
  118. package/src/app/components/SiteSelector.tsx +126 -0
  119. package/src/app/components/SiteTag.tsx +42 -0
  120. package/src/app/components/SiteTagInstallCard.tsx +241 -0
  121. package/src/app/components/WorldMapCard.tsx +337 -0
  122. package/src/app/components/charts/ChartComponents.tsx +1481 -0
  123. package/src/app/components/charts/EventFunnel.tsx +45 -0
  124. package/src/app/components/charts/EventSummary.tsx +194 -0
  125. package/src/app/components/charts/SankeyFlows.tsx +72 -0
  126. package/src/app/components/marketing/CheckIcon.tsx +16 -0
  127. package/src/app/components/marketing/MarketingLayout.tsx +23 -0
  128. package/src/app/components/marketing/SectionHeading.tsx +35 -0
  129. package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
  130. package/src/app/components/reports/CreateReportStarter.tsx +74 -0
  131. package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
  132. package/src/app/components/reports/DashboardToolbar.tsx +154 -0
  133. package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
  134. package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
  135. package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
  136. package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
  137. package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
  138. package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
  139. package/src/app/components/reports/custom/chartPalettes.ts +18 -0
  140. package/src/app/components/reports/custom/types.ts +50 -0
  141. package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
  142. package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
  143. package/src/app/components/ui/AlertBanner.tsx +101 -0
  144. package/src/app/components/ui/Button.tsx +55 -0
  145. package/src/app/components/ui/Card.tsx +80 -0
  146. package/src/app/components/ui/Input.tsx +72 -0
  147. package/src/app/components/ui/Link.tsx +23 -0
  148. package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
  149. package/src/app/components/ui/ThemeToggle.tsx +54 -0
  150. package/src/app/constants.ts +6 -0
  151. package/src/app/headers.ts +33 -0
  152. package/src/app/providers/AuthProvider.tsx +189 -0
  153. package/src/app/providers/ClientProviders.tsx +18 -0
  154. package/src/app/providers/QueryProvider.tsx +23 -0
  155. package/src/app/providers/ThemeProvider.tsx +88 -0
  156. package/src/app/utils/chartThemes.ts +146 -0
  157. package/src/app/utils/keybinds.ts +96 -0
  158. package/src/app/utils/media.tsx +24 -0
  159. package/src/client.tsx +114 -0
  160. package/src/config/createLytxAppConfig.ts +252 -0
  161. package/src/config/resourceNames.ts +88 -0
  162. package/src/db/index.ts +67 -0
  163. package/src/index.css +285 -0
  164. package/src/lib/featureFlags.ts +69 -0
  165. package/src/pages/GetStarted.tsx +290 -0
  166. package/src/pages/Home.tsx +268 -0
  167. package/src/pages/Login.tsx +283 -0
  168. package/src/pages/PrivacyPolicy.tsx +120 -0
  169. package/src/pages/Signup.tsx +267 -0
  170. package/src/pages/TermsOfService.tsx +126 -0
  171. package/src/pages/VerifyEmail.tsx +56 -0
  172. package/src/session/durableObject.ts +7 -0
  173. package/src/session/siteSchema.ts +86 -0
  174. package/src/session/types.ts +36 -0
  175. package/src/templates/README.md +80 -0
  176. package/src/templates/cleanFunctions.js +44 -0
  177. package/src/templates/embedFunctions.js +52 -0
  178. package/src/templates/lytx-shared.ts +662 -0
  179. package/src/templates/lytxpixel-core.ts +144 -0
  180. package/src/templates/lytxpixel.ts +267 -0
  181. package/src/templates/lytxpixelBrowser.js +634 -0
  182. package/src/templates/lytxpixelBrowser.mjs +634 -0
  183. package/src/templates/parseData.js +12 -0
  184. package/src/templates/script.ts +31 -0
  185. package/src/templates/template.tsx +50 -0
  186. package/src/templates/test.js +3 -0
  187. package/src/templates/trackWebEvents.ts +177 -0
  188. package/src/templates/vendors/clickcease.ts +8 -0
  189. package/src/templates/vendors/google.ts +174 -0
  190. package/src/templates/vendors/linkedin.ts +23 -0
  191. package/src/templates/vendors/meta.ts +56 -0
  192. package/src/templates/vendors/quantcast.ts +22 -0
  193. package/src/templates/vendors/simplfi.ts +7 -0
  194. package/src/types/app-context.ts +16 -0
  195. package/src/utilities/dashboardParams.ts +188 -0
  196. package/src/utilities/dashboardQueries.ts +537 -0
  197. package/src/utilities/dashboardTransforms.ts +167 -0
  198. package/src/utilities/dataValidation.ts +414 -0
  199. package/src/utilities/detector.ts +73 -0
  200. package/src/utilities/encrypt.ts +103 -0
  201. package/src/utilities/index.ts +13 -0
  202. package/src/utilities/parser.ts +117 -0
  203. package/src/utilities/performanceMonitoring.ts +570 -0
  204. package/src/utilities/route_interuptors.ts +24 -0
  205. package/src/worker.tsx +675 -0
  206. package/tsconfig.json +78 -0
  207. package/types/env.d.ts +16 -0
  208. package/types/rw.d.ts +7 -0
  209. package/types/shims.d.ts +53 -0
  210. package/types/vite.d.ts +19 -0
  211. package/vite/vite-plugin-pixel-bundle.ts +126 -0
  212. package/vite.config.ts +53 -0
  213. package/worker-configuration.d.ts +8401 -0
@@ -0,0 +1,278 @@
1
+ import { prefix, route } from "rwsdk/router";
2
+ import { d1_client } from "@db/d1/client";
3
+ import { customReports } from "@db/d1/schema";
4
+ import { and, desc, eq } from "drizzle-orm";
5
+ import { getSiteFromContext } from "@/api/authMiddleware";
6
+
7
+ const parseSiteId = (value: unknown) => {
8
+ const numeric = Number(value);
9
+ if (!Number.isFinite(numeric) || numeric <= 0) return null;
10
+ return Math.floor(numeric);
11
+ };
12
+
13
+ const parseReportUuidFromPath = (requestUrl: string) => {
14
+ const pathname = new URL(requestUrl).pathname;
15
+ const marker = "/api/reports/custom/";
16
+ const markerIndex = pathname.indexOf(marker);
17
+ if (markerIndex < 0) return null;
18
+
19
+ const raw = pathname.slice(markerIndex + marker.length).trim();
20
+ if (!raw || raw.includes("/")) return null;
21
+ return decodeURIComponent(raw);
22
+ };
23
+
24
+ const listOrCreateCustomReports = route("/reports/custom", [
25
+ async ({ request, ctx }) => {
26
+ if (request.method === "GET") {
27
+ const url = new URL(request.url);
28
+ const siteIdParam = url.searchParams.get("site_id");
29
+ const parsedSiteId = siteIdParam ? parseSiteId(siteIdParam) : null;
30
+
31
+ if (siteIdParam && !parsedSiteId) {
32
+ return new Response(JSON.stringify({ error: "site_id must be a valid number" }), {
33
+ status: 400,
34
+ headers: { "Content-Type": "application/json" },
35
+ });
36
+ }
37
+
38
+ if (parsedSiteId) {
39
+ const site = getSiteFromContext(ctx, parsedSiteId);
40
+ if (!site) {
41
+ return new Response(JSON.stringify({ error: "Site not found" }), {
42
+ status: 404,
43
+ headers: { "Content-Type": "application/json" },
44
+ });
45
+ }
46
+ }
47
+
48
+ const reports = parsedSiteId
49
+ ? await d1_client
50
+ .select()
51
+ .from(customReports)
52
+ .where(and(eq(customReports.team_id, ctx.team.id), eq(customReports.site_id, parsedSiteId)))
53
+ .orderBy(desc(customReports.updatedAt))
54
+ : await d1_client
55
+ .select()
56
+ .from(customReports)
57
+ .where(eq(customReports.team_id, ctx.team.id))
58
+ .orderBy(desc(customReports.updatedAt));
59
+
60
+ return new Response(JSON.stringify({ reports }), {
61
+ headers: { "Content-Type": "application/json" },
62
+ });
63
+ }
64
+
65
+ if (request.method !== "POST") {
66
+ return new Response(JSON.stringify({ error: "Method not allowed" }), {
67
+ status: 405,
68
+ headers: { "Content-Type": "application/json" },
69
+ });
70
+ }
71
+
72
+ if (ctx.user_role === "viewer") {
73
+ return new Response(JSON.stringify({ error: "You need editor or admin permissions to create reports" }), {
74
+ status: 403,
75
+ headers: { "Content-Type": "application/json" },
76
+ });
77
+ }
78
+
79
+ const payload = (await request.json().catch(() => null)) as
80
+ | {
81
+ site_id?: unknown;
82
+ name?: unknown;
83
+ description?: unknown;
84
+ config?: unknown;
85
+ }
86
+ | null;
87
+
88
+ const siteId = parseSiteId(payload?.site_id);
89
+ const name = typeof payload?.name === "string" ? payload.name.trim() : "";
90
+ const description = typeof payload?.description === "string" ? payload.description.trim() : null;
91
+ const config = payload?.config;
92
+
93
+ if (!siteId) {
94
+ return new Response(JSON.stringify({ error: "site_id is required" }), {
95
+ status: 400,
96
+ headers: { "Content-Type": "application/json" },
97
+ });
98
+ }
99
+
100
+ if (!name) {
101
+ return new Response(JSON.stringify({ error: "name is required" }), {
102
+ status: 400,
103
+ headers: { "Content-Type": "application/json" },
104
+ });
105
+ }
106
+
107
+ if (!config || typeof config !== "object") {
108
+ return new Response(JSON.stringify({ error: "config is required" }), {
109
+ status: 400,
110
+ headers: { "Content-Type": "application/json" },
111
+ });
112
+ }
113
+
114
+ const site = getSiteFromContext(ctx, siteId);
115
+ if (!site) {
116
+ return new Response(JSON.stringify({ error: "Site not found" }), {
117
+ status: 404,
118
+ headers: { "Content-Type": "application/json" },
119
+ });
120
+ }
121
+
122
+ const uuid = crypto.randomUUID();
123
+ const userId = ((ctx.session as { user?: { id?: string } })?.user?.id || "unknown").toString();
124
+
125
+ await d1_client.insert(customReports).values({
126
+ uuid,
127
+ team_id: ctx.team.id,
128
+ site_id: siteId,
129
+ name,
130
+ description,
131
+ config: config as Record<string, unknown>,
132
+ created_by: userId,
133
+ });
134
+
135
+ return new Response(JSON.stringify({ uuid }), {
136
+ status: 201,
137
+ headers: { "Content-Type": "application/json" },
138
+ });
139
+ },
140
+ ]);
141
+
142
+ const getOrUpdateCustomReport = route("/reports/custom/*", [
143
+ async ({ request, ctx }) => {
144
+ const reportUuid = parseReportUuidFromPath(request.url);
145
+ if (!reportUuid) {
146
+ return new Response(JSON.stringify({ error: "Invalid report id" }), {
147
+ status: 400,
148
+ headers: { "Content-Type": "application/json" },
149
+ });
150
+ }
151
+
152
+ const existing = await d1_client
153
+ .select()
154
+ .from(customReports)
155
+ .where(and(eq(customReports.uuid, reportUuid), eq(customReports.team_id, ctx.team.id)))
156
+ .limit(1);
157
+
158
+ if (existing.length === 0) {
159
+ return new Response(JSON.stringify({ error: "Report not found" }), {
160
+ status: 404,
161
+ headers: { "Content-Type": "application/json" },
162
+ });
163
+ }
164
+
165
+ const report = existing[0];
166
+ if (request.method !== "DELETE") {
167
+ const site = getSiteFromContext(ctx, report.site_id);
168
+ if (!site) {
169
+ return new Response(JSON.stringify({ error: "Site not found" }), {
170
+ status: 404,
171
+ headers: { "Content-Type": "application/json" },
172
+ });
173
+ }
174
+ }
175
+
176
+ if (request.method === "GET") {
177
+ return new Response(JSON.stringify({ report }), {
178
+ headers: { "Content-Type": "application/json" },
179
+ });
180
+ }
181
+
182
+ if (request.method === "DELETE") {
183
+ if (ctx.user_role === "viewer") {
184
+ return new Response(JSON.stringify({ error: "You need editor or admin permissions to delete reports" }), {
185
+ status: 403,
186
+ headers: { "Content-Type": "application/json" },
187
+ });
188
+ }
189
+
190
+ await d1_client
191
+ .delete(customReports)
192
+ .where(eq(customReports.id, report.id));
193
+
194
+ return new Response(JSON.stringify({ uuid: reportUuid }), {
195
+ status: 200,
196
+ headers: { "Content-Type": "application/json" },
197
+ });
198
+ }
199
+
200
+ if (request.method !== "POST") {
201
+ return new Response(JSON.stringify({ error: "Method not allowed" }), {
202
+ status: 405,
203
+ headers: { "Content-Type": "application/json" },
204
+ });
205
+ }
206
+
207
+ if (ctx.user_role === "viewer") {
208
+ return new Response(JSON.stringify({ error: "You need editor or admin permissions to update reports" }), {
209
+ status: 403,
210
+ headers: { "Content-Type": "application/json" },
211
+ });
212
+ }
213
+
214
+ const payload = (await request.json().catch(() => null)) as
215
+ | {
216
+ site_id?: unknown;
217
+ name?: unknown;
218
+ description?: unknown;
219
+ config?: unknown;
220
+ }
221
+ | null;
222
+
223
+ const siteId = parseSiteId(payload?.site_id);
224
+ const name = typeof payload?.name === "string" ? payload.name.trim() : "";
225
+ const description = typeof payload?.description === "string" ? payload.description.trim() : null;
226
+ const config = payload?.config;
227
+
228
+ if (!siteId) {
229
+ return new Response(JSON.stringify({ error: "site_id is required" }), {
230
+ status: 400,
231
+ headers: { "Content-Type": "application/json" },
232
+ });
233
+ }
234
+
235
+ if (!name) {
236
+ return new Response(JSON.stringify({ error: "name is required" }), {
237
+ status: 400,
238
+ headers: { "Content-Type": "application/json" },
239
+ });
240
+ }
241
+
242
+ if (!config || typeof config !== "object") {
243
+ return new Response(JSON.stringify({ error: "config is required" }), {
244
+ status: 400,
245
+ headers: { "Content-Type": "application/json" },
246
+ });
247
+ }
248
+
249
+ const targetSite = getSiteFromContext(ctx, siteId);
250
+ if (!targetSite) {
251
+ return new Response(JSON.stringify({ error: "Site not found" }), {
252
+ status: 404,
253
+ headers: { "Content-Type": "application/json" },
254
+ });
255
+ }
256
+
257
+ await d1_client
258
+ .update(customReports)
259
+ .set({
260
+ site_id: siteId,
261
+ name,
262
+ description,
263
+ config: config as Record<string, unknown>,
264
+ updatedAt: new Date(),
265
+ })
266
+ .where(eq(customReports.id, report.id));
267
+
268
+ return new Response(JSON.stringify({ uuid: reportUuid }), {
269
+ status: 200,
270
+ headers: { "Content-Type": "application/json" },
271
+ });
272
+ },
273
+ ]);
274
+
275
+ export const reportsApi = prefix("/", [
276
+ listOrCreateCustomReports,
277
+ getOrUpdateCustomReport,
278
+ ]);
@@ -0,0 +1,288 @@
1
+ // src/api/seed_api.ts
2
+ // Seed API endpoint for local development - handles site lookup, creation, and event seeding
3
+
4
+ import { route } from 'rwsdk/router';
5
+ import { env } from 'cloudflare:workers';
6
+ import { d1_client } from '@db/d1/client';
7
+ import { sites, team } from '@db/d1/schema';
8
+ import { eq, and } from 'drizzle-orm';
9
+ import { IS_DEV } from 'rwsdk/constants';
10
+ import { createId } from '@paralleldrive/cuid2';
11
+ import { insertSiteEvents } from '@db/adapter';
12
+ import type { SiteEventInput } from '@/session/siteSchema';
13
+ import type { DBAdapter } from '@db/types';
14
+
15
+ const SEED_DATA_SECRET = (env as { SEED_DATA_SECRET?: string }).SEED_DATA_SECRET;
16
+ const ENVIRONMENT = (env as { ENVIRONMENT?: string }).ENVIRONMENT;
17
+
18
+ function validateSeedSecret(request: Request): Response | null {
19
+ const seedSecretHeader = request.headers.get("x-seed-secret");
20
+
21
+ // Multiple layers of protection to ensure this never runs in production
22
+ // 1. Check IS_DEV from rwsdk (based on build mode)
23
+ if (!IS_DEV) {
24
+ return new Response("Seed endpoint disabled outside dev.", { status: 403 });
25
+ }
26
+
27
+ // 2. Explicitly check ENVIRONMENT env var
28
+ if (ENVIRONMENT === "production" || ENVIRONMENT === "staging") {
29
+ return new Response("Seed endpoint disabled in production/staging.", { status: 403 });
30
+ }
31
+
32
+ // 3. Require SEED_DATA_SECRET to be configured
33
+ if (!SEED_DATA_SECRET) {
34
+ return new Response("SEED_DATA_SECRET is not configured.", { status: 500 });
35
+ }
36
+
37
+ // 4. Validate the secret matches
38
+ if (seedSecretHeader !== SEED_DATA_SECRET) {
39
+ return new Response("Unauthorized", { status: 401 });
40
+ }
41
+
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * GET /api/seed/site/:siteId?teamId=X
47
+ * Look up a site by ID and team
48
+ */
49
+ async function getSite(siteId: number, teamId: number) {
50
+ const [siteDetails] = await d1_client
51
+ .select({
52
+ site_id: sites.site_id,
53
+ uuid: sites.uuid,
54
+ tag_id: sites.tag_id,
55
+ name: sites.name,
56
+ domain: sites.domain,
57
+ site_db_adapter: sites.site_db_adapter,
58
+ })
59
+ .from(sites)
60
+ .where(and(eq(sites.site_id, siteId), eq(sites.team_id, teamId)))
61
+ .limit(1);
62
+
63
+ return siteDetails;
64
+ }
65
+
66
+ /**
67
+ * POST /api/seed/site
68
+ * Create a new site
69
+ */
70
+ async function createSite(data: {
71
+ teamId: number;
72
+ name: string;
73
+ domain: string;
74
+ }) {
75
+ const tagId = createId();
76
+ const uuid = createId();
77
+ const ridSalt = createId();
78
+ const ridSaltExpire = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
79
+
80
+ await d1_client.insert(sites).values({
81
+ uuid,
82
+ tag_id: tagId,
83
+ track_web_events: true,
84
+ event_load_strategy: 'sdk',
85
+ team_id: data.teamId,
86
+ name: data.name,
87
+ domain: data.domain,
88
+ gdpr: false,
89
+ rid_salt: ridSalt,
90
+ rid_salt_expire: ridSaltExpire,
91
+ });
92
+
93
+ // Fetch the created site to get the auto-generated site_id
94
+ const [createdSite] = await d1_client
95
+ .select({
96
+ site_id: sites.site_id,
97
+ uuid: sites.uuid,
98
+ tag_id: sites.tag_id,
99
+ name: sites.name,
100
+ domain: sites.domain,
101
+ site_db_adapter: sites.site_db_adapter,
102
+ })
103
+ .from(sites)
104
+ .where(eq(sites.tag_id, tagId))
105
+ .limit(1);
106
+
107
+ return createdSite;
108
+ }
109
+
110
+ /**
111
+ * POST /api/seed/events/:siteId
112
+ * Insert events into a site's durable object
113
+ */
114
+ async function seedEvents(
115
+ siteId: number,
116
+ siteUuid: string,
117
+ siteAdapter: DBAdapter,
118
+ events: SiteEventInput[],
119
+ ) {
120
+ const normalizedEvents = events.map((event) => ({
121
+ ...event,
122
+ createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
123
+ updatedAt: event.updatedAt ? new Date(event.updatedAt) : undefined,
124
+ }));
125
+
126
+ return await insertSiteEvents(siteId, siteUuid, normalizedEvents, siteAdapter);
127
+ }
128
+
129
+ /**
130
+ * GET /api/seed/team/:teamId
131
+ * Check if a team exists
132
+ */
133
+ async function getTeam(teamId: number) {
134
+ const [teamDetails] = await d1_client
135
+ .select({
136
+ id: team.id,
137
+ name: team.name,
138
+ db_adapter: team.db_adapter,
139
+ })
140
+ .from(team)
141
+ .where(eq(team.id, teamId))
142
+ .limit(1);
143
+
144
+ return teamDetails;
145
+ }
146
+
147
+ /**
148
+ * Seed API endpoint
149
+ *
150
+ * Routes:
151
+ * - GET /api/seed/team/:teamId - Check if team exists
152
+ * - GET /api/seed/site/:siteId?teamId=X - Get site details
153
+ * - POST /api/seed/site - Create a new site
154
+ * - POST /api/seed/events/:siteId - Insert events into site
155
+ */
156
+ export const seedApi = route(
157
+ "/api/seed/*",
158
+ async ({ params, request }) => {
159
+ // Validate seed secret for all requests
160
+ const authError = validateSeedSecret(request);
161
+ if (authError) return authError;
162
+
163
+ const pathPart = params.$0 ?? "";
164
+ const segments = pathPart.split("/").filter(Boolean);
165
+ const url = new URL(request.url);
166
+
167
+ // GET /api/seed/team/:teamId
168
+ if (request.method === "GET" && segments[0] === "team" && segments[1]) {
169
+ const teamId = parseInt(segments[1], 10);
170
+ if (isNaN(teamId)) {
171
+ return new Response("Invalid team ID", { status: 400 });
172
+ }
173
+
174
+ const teamDetails = await getTeam(teamId);
175
+ if (!teamDetails) {
176
+ return new Response("Team not found", { status: 404 });
177
+ }
178
+
179
+ return Response.json(teamDetails);
180
+ }
181
+
182
+ // GET /api/seed/site/:siteId?teamId=X
183
+ if (request.method === "GET" && segments[0] === "site" && segments[1]) {
184
+ const siteId = parseInt(segments[1], 10);
185
+ const teamIdStr = url.searchParams.get("teamId");
186
+
187
+ if (isNaN(siteId)) {
188
+ return new Response("Invalid site ID", { status: 400 });
189
+ }
190
+ if (!teamIdStr) {
191
+ return new Response("teamId query param required", { status: 400 });
192
+ }
193
+
194
+ const teamId = parseInt(teamIdStr, 10);
195
+ if (isNaN(teamId)) {
196
+ return new Response("Invalid team ID", { status: 400 });
197
+ }
198
+
199
+ const siteDetails = await getSite(siteId, teamId);
200
+ if (!siteDetails) {
201
+ return new Response("Site not found", { status: 404 });
202
+ }
203
+
204
+ return Response.json(siteDetails);
205
+ }
206
+
207
+ // POST /api/seed/site
208
+ if (request.method === "POST" && segments[0] === "site" && !segments[1]) {
209
+ try {
210
+ const body = await request.json() as {
211
+ teamId: number;
212
+ name: string;
213
+ domain: string;
214
+ };
215
+
216
+ if (!body.teamId || !body.name || !body.domain) {
217
+ return new Response("teamId, name, and domain are required", { status: 400 });
218
+ }
219
+
220
+ // Verify team exists
221
+ const teamDetails = await getTeam(body.teamId);
222
+ if (!teamDetails) {
223
+ return new Response(`Team ${body.teamId} not found`, { status: 404 });
224
+ }
225
+
226
+ const site = await createSite(body);
227
+ return Response.json(site, { status: 201 });
228
+ } catch (error) {
229
+ console.error("Error creating site:", error);
230
+ return new Response(
231
+ error instanceof Error ? error.message : "Failed to create site",
232
+ { status: 500 }
233
+ );
234
+ }
235
+ }
236
+
237
+ // POST /api/seed/events/:siteId
238
+ if (request.method === "POST" && segments[0] === "events" && segments[1]) {
239
+ const siteId = parseInt(segments[1], 10);
240
+ const teamIdStr = url.searchParams.get("teamId");
241
+
242
+ if (isNaN(siteId)) {
243
+ return new Response("Invalid site ID", { status: 400 });
244
+ }
245
+ if (!teamIdStr) {
246
+ return new Response("teamId query param required", { status: 400 });
247
+ }
248
+
249
+ const teamId = parseInt(teamIdStr, 10);
250
+ if (isNaN(teamId)) {
251
+ return new Response("Invalid team ID", { status: 400 });
252
+ }
253
+
254
+ // Get site details
255
+ const siteDetails = await getSite(siteId, teamId);
256
+ if (!siteDetails) {
257
+ return new Response("Site not found", { status: 404 });
258
+ }
259
+
260
+ try {
261
+ const events = await request.json() as SiteEventInput[];
262
+ if (!Array.isArray(events)) {
263
+ return new Response("Events must be an array", { status: 400 });
264
+ }
265
+
266
+ const siteAdapter = (siteDetails.site_db_adapter ?? "sqlite") as DBAdapter;
267
+ const result = await seedEvents(siteId, siteDetails.uuid, siteAdapter, events);
268
+
269
+ if (result.success) {
270
+ return Response.json({
271
+ success: true,
272
+ inserted: result.inserted || 0,
273
+ });
274
+ }
275
+
276
+ return Response.json({ success: false, error: result.error }, { status: 500 });
277
+ } catch (error) {
278
+ console.error("Error seeding events:", error);
279
+ return new Response(
280
+ error instanceof Error ? error.message : "Failed to seed events",
281
+ { status: 500 }
282
+ );
283
+ }
284
+ }
285
+
286
+ return new Response("Not Found", { status: 404 });
287
+ }
288
+ );