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,45 @@
1
+ // import { initSupabase } from "@/db/server";
2
+ import type { AppContext } from "@/types/app-context";
3
+ // import { env } from "cloudflare:workers";
4
+ // import { route } from "rwsdk/router";
5
+ import type { RequestInfo } from "rwsdk/worker";
6
+ import { asAuthUserSession, getAuth } from "@lib/auth";
7
+ import type { UserRole } from "@db/types";
8
+
9
+ const resolveUserRole = (role: unknown): UserRole => {
10
+ if (role === "admin" || role === "editor" || role === "viewer") {
11
+ return role;
12
+ }
13
+ return "viewer";
14
+ };
15
+
16
+ export function authMiddleware({ request }: RequestInfo<unknown, AppContext>) {
17
+ const auth = getAuth();
18
+ if (["POST", "GET"].includes(request.method)) {
19
+ return auth.handler(request);
20
+ } else return new Response("Method Not Allowed", { status: 405 });
21
+ }
22
+
23
+ export async function sessionMiddleware({ request, ctx }: RequestInfo<unknown, AppContext>) {
24
+ const auth = getAuth();
25
+ const session = asAuthUserSession(await auth.api.getSession({ headers: request.headers }));
26
+ if (!session) {
27
+ return new Response("Not logged in", { status: 303, headers: { location: '/login' } });
28
+ }
29
+
30
+ ctx.initial_site_setup = session.initial_site_setup;
31
+ ctx.db_adapter = session.db_adapter;
32
+ ctx.sites = session.userSites || null;
33
+ ctx.session = session;
34
+ ctx.team = session.team;
35
+ ctx.user_role = resolveUserRole(session.role);
36
+
37
+ }
38
+
39
+ export function getSiteFromContext(ctx: AppContext, site_id: number) {
40
+ if (!ctx.session) return null;
41
+ if (!ctx.sites) return null;
42
+ return ctx.sites.find(s => s.site_id == site_id);
43
+ }
44
+
45
+ export const sessionName = 'lytx_session';
@@ -0,0 +1,465 @@
1
+ import { route, prefix } from "rwsdk/router";
2
+ import { env } from "cloudflare:workers";
3
+ import { getAuth } from "@lib/auth";
4
+ import { d1_client } from "@db/d1/client";
5
+ import { team_member, user as userTable } from "@db/d1/schema";
6
+ import { getSitesForUser } from "@db/d1/sites";
7
+ import { and, eq } from "drizzle-orm";
8
+ import { sessionMiddleware } from "./authMiddleware";
9
+ import { onlyAllowPost } from "@utilities/route_interuptors";
10
+
11
+ function getClientIp(request: Request) {
12
+ const cfIp = request.headers.get("CF-Connecting-IP");
13
+ if (cfIp) return cfIp;
14
+
15
+ const forwardedFor = request.headers.get("X-Forwarded-For");
16
+ if (!forwardedFor) return "unknown";
17
+
18
+ return forwardedFor.split(",")[0]?.trim() || "unknown";
19
+ }
20
+
21
+ function validateCallbackURL(callbackURL: unknown) {
22
+ if (callbackURL === undefined) return undefined;
23
+ if (typeof callbackURL !== "string") return null;
24
+
25
+ // Prevent open redirects by only allowing relative URLs.
26
+ if (!callbackURL.startsWith("/")) return null;
27
+ if (callbackURL.includes("://")) return null;
28
+
29
+ return callbackURL;
30
+ }
31
+
32
+ /**
33
+ * POST /api/resend-verification-email
34
+ *
35
+ * Proxies Better Auth's `/api/auth/send-verification-email` with app-level
36
+ * rate limiting to prevent abuse.
37
+ */
38
+ export const resendVerificationEmailRoute = route(
39
+ "/api/resend-verification-email",
40
+ async ({ request }) => {
41
+ const requestId = crypto.randomUUID();
42
+
43
+ if (request.method !== "POST") {
44
+ return new Response(JSON.stringify({ error: "Method not allowed", requestId }), {
45
+ status: 405,
46
+ headers: { "Content-Type": "application/json" },
47
+ });
48
+ }
49
+
50
+ let body: unknown;
51
+ try {
52
+ body = await request.json();
53
+ } catch {
54
+ return new Response(JSON.stringify({ error: "Invalid JSON", requestId }), {
55
+ status: 400,
56
+ headers: { "Content-Type": "application/json" },
57
+ });
58
+ }
59
+
60
+ if (!body || typeof body !== "object") {
61
+ return new Response(JSON.stringify({ error: "Invalid request body", requestId }), {
62
+ status: 400,
63
+ headers: { "Content-Type": "application/json" },
64
+ });
65
+ }
66
+
67
+ const { email, callbackURL } = body as { email?: unknown; callbackURL?: unknown };
68
+
69
+ if (typeof email !== "string" || !email.trim()) {
70
+ return new Response(JSON.stringify({ error: "email is required", requestId }), {
71
+ status: 400,
72
+ headers: { "Content-Type": "application/json" },
73
+ });
74
+ }
75
+
76
+ const normalizedEmail = email.trim().toLowerCase();
77
+ const safeCallbackURL = validateCallbackURL(callbackURL);
78
+ if (safeCallbackURL === null) {
79
+ return new Response(
80
+ JSON.stringify({ error: "callbackURL must be a relative path", requestId }),
81
+ {
82
+ status: 400,
83
+ headers: { "Content-Type": "application/json" },
84
+ },
85
+ );
86
+ }
87
+
88
+ const ip = getClientIp(request);
89
+ const rateLimitKey = `rate_limit:resend_verification:${ip}:${normalizedEmail}`;
90
+
91
+ try {
92
+ const existing = await env.lytx_sessions.get(rateLimitKey);
93
+ if (existing) {
94
+ // Avoid account enumeration: do not reveal rate limiting either.
95
+ return new Response(JSON.stringify({ status: true }), {
96
+ status: 200,
97
+ headers: { "Content-Type": "application/json" },
98
+ });
99
+ }
100
+
101
+ await env.lytx_sessions.put(rateLimitKey, "1", { expirationTtl: 60 });
102
+
103
+ const proxyUrl = new URL("/api/auth/send-verification-email", request.url);
104
+ const proxyHeaders = new Headers(request.headers);
105
+ proxyHeaders.set("Content-Type", "application/json");
106
+
107
+ const proxyRequest = new Request(proxyUrl, {
108
+ method: "POST",
109
+ headers: proxyHeaders,
110
+ body: JSON.stringify({
111
+ email: normalizedEmail,
112
+ callbackURL: safeCallbackURL ?? "/dashboard",
113
+ }),
114
+ });
115
+
116
+ const auth = getAuth();
117
+ const proxyResponse = await auth.handler(proxyRequest);
118
+
119
+ // Avoid account enumeration: always return 200 for common failures.
120
+ if (proxyResponse.status === 400 || proxyResponse.status === 403) {
121
+ return new Response(JSON.stringify({ status: true }), {
122
+ status: 200,
123
+ headers: { "Content-Type": "application/json" },
124
+ });
125
+ }
126
+
127
+ return proxyResponse;
128
+ } catch (error) {
129
+ console.error("Resend verification email API error:", { requestId, error });
130
+ return new Response(JSON.stringify({ error: "Internal server error", requestId }), {
131
+ status: 500,
132
+ headers: { "Content-Type": "application/json" },
133
+ });
134
+ }
135
+ },
136
+ );
137
+
138
+ /**
139
+ * POST /api/user/update-timezone
140
+ *
141
+ * Updates the user's timezone preference.
142
+ */
143
+ const updateTimezoneRoute = route(
144
+ "/update-timezone",
145
+ [
146
+ sessionMiddleware,
147
+ onlyAllowPost,
148
+ async ({ ctx, request }) => {
149
+ const requestId = crypto.randomUUID();
150
+
151
+ let body: unknown;
152
+ try {
153
+ body = await request.json();
154
+ } catch {
155
+ return new Response(JSON.stringify({ error: "Invalid JSON", requestId }), {
156
+ status: 400,
157
+ headers: { "Content-Type": "application/json" },
158
+ });
159
+ }
160
+
161
+ if (!body || typeof body !== "object") {
162
+ return new Response(JSON.stringify({ error: "Invalid request body", requestId }), {
163
+ status: 400,
164
+ headers: { "Content-Type": "application/json" },
165
+ });
166
+ }
167
+
168
+ const { timezone } = body as { timezone?: unknown };
169
+
170
+ if (typeof timezone !== "string" || !timezone.trim()) {
171
+ return new Response(JSON.stringify({ error: "timezone is required", requestId }), {
172
+ status: 400,
173
+ headers: { "Content-Type": "application/json" },
174
+ });
175
+ }
176
+
177
+ // Validate timezone is a valid IANA timezone
178
+ try {
179
+ Intl.DateTimeFormat(undefined, { timeZone: timezone });
180
+ } catch {
181
+ return new Response(JSON.stringify({ error: "Invalid timezone", requestId }), {
182
+ status: 400,
183
+ headers: { "Content-Type": "application/json" },
184
+ });
185
+ }
186
+
187
+ const userId = ctx.session.user.id;
188
+
189
+ try {
190
+ await d1_client
191
+ .update(userTable)
192
+ .set({ timezone: timezone.trim() })
193
+ .where(eq(userTable.id, userId));
194
+
195
+ return new Response(JSON.stringify({ success: true, timezone: timezone.trim() }), {
196
+ status: 200,
197
+ headers: { "Content-Type": "application/json" },
198
+ });
199
+ } catch (error) {
200
+ console.error("Update timezone error:", { requestId, error });
201
+ return new Response(JSON.stringify({ error: "Internal server error", requestId }), {
202
+ status: 500,
203
+ headers: { "Content-Type": "application/json" },
204
+ });
205
+ }
206
+ },
207
+ ],
208
+ );
209
+
210
+ /**
211
+ * POST /api/user/update-last-site
212
+ *
213
+ * Updates the user's last selected site.
214
+ */
215
+ const updateLastSiteRoute = route(
216
+ "/update-last-site",
217
+ [
218
+ sessionMiddleware,
219
+ onlyAllowPost,
220
+ async ({ ctx, request }) => {
221
+ const requestId = crypto.randomUUID();
222
+
223
+ let body: unknown;
224
+ try {
225
+ body = await request.json();
226
+ } catch {
227
+ return new Response(JSON.stringify({ error: "Invalid JSON", requestId }), {
228
+ status: 400,
229
+ headers: { "Content-Type": "application/json" },
230
+ });
231
+ }
232
+
233
+ if (!body || typeof body !== "object") {
234
+ return new Response(JSON.stringify({ error: "Invalid request body", requestId }), {
235
+ status: 400,
236
+ headers: { "Content-Type": "application/json" },
237
+ });
238
+ }
239
+
240
+ const { site_id } = body as { site_id?: unknown };
241
+
242
+ if (typeof site_id !== "number" || !Number.isInteger(site_id)) {
243
+ return new Response(JSON.stringify({ error: "site_id must be an integer", requestId }), {
244
+ status: 400,
245
+ headers: { "Content-Type": "application/json" },
246
+ });
247
+ }
248
+
249
+ // Verify the site_id belongs to the user's accessible sites
250
+ const userSites = ctx.session.userSites || [];
251
+ const isValidSite = userSites.some((site: { site_id: number }) => site.site_id === site_id);
252
+
253
+ if (!isValidSite) {
254
+ return new Response(JSON.stringify({ error: "Invalid site_id", requestId }), {
255
+ status: 403,
256
+ headers: { "Content-Type": "application/json" },
257
+ });
258
+ }
259
+
260
+ const userId = ctx.session.user.id;
261
+
262
+ try {
263
+ await d1_client
264
+ .update(userTable)
265
+ .set({ last_site_id: site_id })
266
+ .where(eq(userTable.id, userId));
267
+
268
+ return new Response(JSON.stringify({ success: true, site_id }), {
269
+ status: 200,
270
+ headers: { "Content-Type": "application/json" },
271
+ });
272
+ } catch (error) {
273
+ console.error("Update last site error:", { requestId, error });
274
+ return new Response(JSON.stringify({ error: "Internal server error", requestId }), {
275
+ status: 500,
276
+ headers: { "Content-Type": "application/json" },
277
+ });
278
+ }
279
+ },
280
+ ],
281
+ );
282
+
283
+ /**
284
+ * POST /api/user/update-last-team
285
+ *
286
+ * Updates the user's last selected team and refreshes session cache.
287
+ */
288
+ const updateLastTeamRoute = route(
289
+ "/update-last-team",
290
+ [
291
+ sessionMiddleware,
292
+ onlyAllowPost,
293
+ async ({ ctx, request }) => {
294
+ const requestId = crypto.randomUUID();
295
+
296
+ let body: unknown;
297
+ try {
298
+ body = await request.json();
299
+ } catch {
300
+ return new Response(JSON.stringify({ error: "Invalid JSON", requestId }), {
301
+ status: 400,
302
+ headers: { "Content-Type": "application/json" },
303
+ });
304
+ }
305
+
306
+ if (!body || typeof body !== "object") {
307
+ return new Response(JSON.stringify({ error: "Invalid request body", requestId }), {
308
+ status: 400,
309
+ headers: { "Content-Type": "application/json" },
310
+ });
311
+ }
312
+
313
+ const { team_id } = body as { team_id?: unknown };
314
+
315
+ if (typeof team_id !== "number" || !Number.isInteger(team_id)) {
316
+ return new Response(JSON.stringify({ error: "team_id must be an integer", requestId }), {
317
+ status: 400,
318
+ headers: { "Content-Type": "application/json" },
319
+ });
320
+ }
321
+
322
+ const userId = ctx.session.user.id;
323
+
324
+ const membership = await d1_client
325
+ .select({ id: team_member.id })
326
+ .from(team_member)
327
+ .where(and(eq(team_member.user_id, userId), eq(team_member.team_id, team_id)))
328
+ .limit(1);
329
+
330
+ if (membership.length === 0) {
331
+ return new Response(JSON.stringify({ error: "Invalid team_id", requestId }), {
332
+ status: 403,
333
+ headers: { "Content-Type": "application/json" },
334
+ });
335
+ }
336
+
337
+ try {
338
+ const userSites = await getSitesForUser(userId, team_id);
339
+ if (!userSites) {
340
+ return new Response(JSON.stringify({ error: "Team not found", requestId }), {
341
+ status: 404,
342
+ headers: { "Content-Type": "application/json" },
343
+ });
344
+ }
345
+
346
+ const nextSiteId = userSites.sitesList[0]?.site_id ?? null;
347
+
348
+ await d1_client
349
+ .update(userTable)
350
+ .set({ last_team_id: team_id, last_site_id: nextSiteId })
351
+ .where(eq(userTable.id, userId));
352
+
353
+ const teamContext = userSites.team;
354
+ const updatedTeams = [
355
+ { id: teamContext.id, name: teamContext.name, external_id: teamContext.external_id },
356
+ ];
357
+ if (userSites.all_teams?.length) {
358
+ updatedTeams.push(
359
+ ...userSites.all_teams.map((team) => ({
360
+ id: team.team_id,
361
+ name: team.name!,
362
+ external_id: team.external_id!,
363
+ })),
364
+ );
365
+ }
366
+
367
+ const updatedSession = {
368
+ ...ctx.session,
369
+ initial_site_setup: userSites.teamHasSites,
370
+ team: {
371
+ id: teamContext.id,
372
+ name: teamContext.name,
373
+ external_id: teamContext.external_id,
374
+ },
375
+ all_teams: updatedTeams,
376
+ role: teamContext.role ?? ctx.session.role,
377
+ db_adapter: teamContext.db_adapter ?? ctx.session.db_adapter,
378
+ userSites: userSites.sitesList,
379
+ last_team_id: team_id,
380
+ last_site_id: nextSiteId,
381
+ };
382
+ const updatedUser = {
383
+ ...ctx.session.user,
384
+ last_team_id: team_id,
385
+ last_site_id: nextSiteId,
386
+ };
387
+ const updatedSessionCache = {
388
+ ...updatedSession,
389
+ user: updatedUser,
390
+ };
391
+
392
+ const sessionToken = ctx.session.session?.token;
393
+ const sessionId = ctx.session.session?.id;
394
+ const expiresAt = ctx.session.session?.expiresAt;
395
+ let expirationTtl: number | undefined;
396
+ if (expiresAt) {
397
+ const expiresAtMs =
398
+ expiresAt instanceof Date ? expiresAt.getTime() : new Date(expiresAt).getTime();
399
+ if (!Number.isNaN(expiresAtMs)) {
400
+ const ttlSeconds = Math.floor((expiresAtMs - Date.now()) / 1000);
401
+ if (ttlSeconds > 0) expirationTtl = ttlSeconds;
402
+ }
403
+ }
404
+
405
+ const candidateKeys = [
406
+ sessionToken ? `session:${sessionToken}` : null,
407
+ sessionToken || null,
408
+ sessionId ? `session:${sessionId}` : null,
409
+ sessionId || null,
410
+ ].filter(Boolean) as string[];
411
+
412
+ let updatedCache = false;
413
+ for (const key of candidateKeys) {
414
+ const existing = await env.lytx_sessions.get(key);
415
+ if (!existing) continue;
416
+ if (expirationTtl) {
417
+ await env.lytx_sessions.put(key, JSON.stringify(updatedSessionCache), {
418
+ expirationTtl,
419
+ });
420
+ } else {
421
+ await env.lytx_sessions.put(key, JSON.stringify(updatedSessionCache));
422
+ }
423
+ updatedCache = true;
424
+ break;
425
+ }
426
+
427
+ if (!updatedCache && sessionToken) {
428
+ if (expirationTtl) {
429
+ await env.lytx_sessions.put(sessionToken, JSON.stringify(updatedSessionCache), {
430
+ expirationTtl,
431
+ });
432
+ } else {
433
+ await env.lytx_sessions.put(sessionToken, JSON.stringify(updatedSessionCache));
434
+ }
435
+ }
436
+
437
+ return new Response(
438
+ JSON.stringify({
439
+ success: true,
440
+ team_id,
441
+ team: updatedSession.team,
442
+ userSites: userSites.sitesList,
443
+ last_site_id: nextSiteId,
444
+ }),
445
+ {
446
+ status: 200,
447
+ headers: { "Content-Type": "application/json" },
448
+ },
449
+ );
450
+ } catch (error) {
451
+ console.error("Update last team error:", { requestId, error });
452
+ return new Response(JSON.stringify({ error: "Internal server error", requestId }), {
453
+ status: 500,
454
+ headers: { "Content-Type": "application/json" },
455
+ });
456
+ }
457
+ },
458
+ ],
459
+ );
460
+
461
+ export const userApiRoutes = prefix("/api/user", [
462
+ updateTimezoneRoute,
463
+ updateLastSiteRoute,
464
+ updateLastTeamRoute,
465
+ ]);
@@ -0,0 +1,193 @@
1
+ import { route, prefix } from "rwsdk/router";
2
+ import { onlyAllowPost } from "@utilities/route_interuptors";
3
+ import { d1_client } from "@db/d1/client";
4
+ import { eventLabels } from "@db/d1/schema";
5
+ import { eq, and } from "drizzle-orm";
6
+ import { getSiteFromContext } from "@/api/authMiddleware";
7
+ import { IS_DEV } from "rwsdk/constants";
8
+
9
+ // GET /api/event-labels?site_id=123
10
+ const getEventLabels = route("/event-labels", [
11
+ async ({ request, ctx }) => {
12
+ if (request.method !== "GET") {
13
+ return new Response("Method not allowed", { status: 405 });
14
+ }
15
+
16
+ const url = new URL(request.url);
17
+ const siteIdParam = url.searchParams.get("site_id");
18
+ const siteId = siteIdParam ? parseInt(siteIdParam, 10) : null;
19
+
20
+ if (!siteId || isNaN(siteId)) {
21
+ return new Response(JSON.stringify({ error: "site_id required" }), {
22
+ status: 400,
23
+ headers: { "Content-Type": "application/json" },
24
+ });
25
+ }
26
+
27
+ // Verify site belongs to team
28
+ const siteDetails = getSiteFromContext(ctx, siteId);
29
+ if (!siteDetails) {
30
+ return new Response(JSON.stringify({ error: "Site not found" }), {
31
+ status: 404,
32
+ headers: { "Content-Type": "application/json" },
33
+ });
34
+ }
35
+
36
+ const labels = await d1_client
37
+ .select()
38
+ .from(eventLabels)
39
+ .where(eq(eventLabels.site_id, siteId));
40
+
41
+ return new Response(JSON.stringify(labels), {
42
+ status: 200,
43
+ headers: { "Content-Type": "application/json" },
44
+ });
45
+ },
46
+ ]);
47
+
48
+ // POST /api/event-labels/save
49
+ const saveEventLabel = route("/event-labels/save", [
50
+ onlyAllowPost,
51
+ async ({ request, ctx }) => {
52
+ const body = (await request.json()) as {
53
+ site_id: number;
54
+ event_name: string;
55
+ label: string;
56
+ description?: string;
57
+ };
58
+
59
+ if (!body.site_id || !body.event_name || !body.label) {
60
+ return new Response(
61
+ JSON.stringify({ error: "site_id, event_name, and label required" }),
62
+ {
63
+ status: 400,
64
+ headers: { "Content-Type": "application/json" },
65
+ }
66
+ );
67
+ }
68
+
69
+ // Verify site belongs to team
70
+ const siteDetails = getSiteFromContext(ctx, body.site_id);
71
+ if (!siteDetails) {
72
+ return new Response(JSON.stringify({ error: "Site not found" }), {
73
+ status: 404,
74
+ headers: { "Content-Type": "application/json" },
75
+ });
76
+ }
77
+
78
+ // Check if user has at least editor role
79
+ if (ctx.user_role === "viewer") {
80
+ return new Response(
81
+ JSON.stringify({ error: "You need editor or admin permissions to edit labels" }),
82
+ {
83
+ status: 403,
84
+ headers: { "Content-Type": "application/json" },
85
+ }
86
+ );
87
+ }
88
+
89
+ // Check if label exists, upsert
90
+ const existing = await d1_client
91
+ .select()
92
+ .from(eventLabels)
93
+ .where(
94
+ and(
95
+ eq(eventLabels.site_id, body.site_id),
96
+ eq(eventLabels.event_name, body.event_name)
97
+ )
98
+ )
99
+ .limit(1);
100
+
101
+ let result;
102
+ if (existing.length > 0) {
103
+ result = await d1_client
104
+ .update(eventLabels)
105
+ .set({
106
+ label: body.label,
107
+ description: body.description ?? null,
108
+ })
109
+ .where(eq(eventLabels.id, existing[0].id))
110
+ .returning();
111
+ } else {
112
+ result = await d1_client
113
+ .insert(eventLabels)
114
+ .values({
115
+ site_id: body.site_id,
116
+ event_name: body.event_name,
117
+ label: body.label,
118
+ description: body.description ?? null,
119
+ })
120
+ .returning();
121
+ }
122
+
123
+ if (IS_DEV) console.log("Event label saved:", result[0]);
124
+
125
+ return new Response(JSON.stringify(result[0]), {
126
+ status: 200,
127
+ headers: { "Content-Type": "application/json" },
128
+ });
129
+ },
130
+ ]);
131
+
132
+ // POST /api/event-labels/delete
133
+ const deleteEventLabel = route("/event-labels/delete", [
134
+ onlyAllowPost,
135
+ async ({ request, ctx }) => {
136
+ const body = (await request.json()) as {
137
+ site_id: number;
138
+ event_name: string;
139
+ };
140
+
141
+ if (!body.site_id || !body.event_name) {
142
+ return new Response(
143
+ JSON.stringify({ error: "site_id and event_name required" }),
144
+ {
145
+ status: 400,
146
+ headers: { "Content-Type": "application/json" },
147
+ }
148
+ );
149
+ }
150
+
151
+ const siteDetails = getSiteFromContext(ctx, body.site_id);
152
+ if (!siteDetails) {
153
+ return new Response(JSON.stringify({ error: "Site not found" }), {
154
+ status: 404,
155
+ headers: { "Content-Type": "application/json" },
156
+ });
157
+ }
158
+
159
+ // Check if user has at least editor role
160
+ if (ctx.user_role === "viewer") {
161
+ return new Response(
162
+ JSON.stringify({ error: "You need editor or admin permissions to delete labels" }),
163
+ {
164
+ status: 403,
165
+ headers: { "Content-Type": "application/json" },
166
+ }
167
+ );
168
+ }
169
+
170
+ await d1_client
171
+ .delete(eventLabels)
172
+ .where(
173
+ and(
174
+ eq(eventLabels.site_id, body.site_id),
175
+ eq(eventLabels.event_name, body.event_name)
176
+ )
177
+ );
178
+
179
+ if (IS_DEV) console.log("Event label deleted:", body.event_name);
180
+
181
+ return new Response(JSON.stringify({ success: true }), {
182
+ status: 200,
183
+ headers: { "Content-Type": "application/json" },
184
+ });
185
+ },
186
+ ]);
187
+
188
+ // Group all event label routes under /api prefix
189
+ export const eventLabelsApi = prefix("/", [
190
+ getEventLabels,
191
+ saveEventLabel,
192
+ deleteEventLabel,
193
+ ]);