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.
- package/.env.example +37 -0
- package/README.md +486 -0
- package/alchemy.run.ts +155 -0
- package/cli/bootstrap-admin.ts +284 -0
- package/cli/deploy-staging.ts +692 -0
- package/cli/import-events.ts +628 -0
- package/cli/import-sites.ts +518 -0
- package/cli/index.ts +609 -0
- package/cli/init-db.ts +269 -0
- package/cli/migrate-to-durable-objects.ts +564 -0
- package/cli/migration-worker.ts +300 -0
- package/cli/performance-test.ts +588 -0
- package/cli/pg/client.ts +4 -0
- package/cli/pg/new-site.ts +153 -0
- package/cli/rollback-durable-objects.ts +622 -0
- package/cli/seed-data.ts +459 -0
- package/cli/setup.js +18 -0
- package/cli/setup.ts +463 -0
- package/cli/validate-migration.ts +200 -0
- package/cli/wrangler-migration.jsonc +28 -0
- package/db/adapter.ts +166 -0
- package/db/analytics_engine/client.ts +0 -0
- package/db/analytics_engine/sites.ts +0 -0
- package/db/client.ts +16 -0
- package/db/d1/client.ts +8 -0
- package/db/d1/drizzle.config.ts +35 -0
- package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
- package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
- package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
- package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
- package/db/d1/migrations/0004_mute_stardust.sql +1 -0
- package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
- package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
- package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
- package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
- package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
- package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
- package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
- package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
- package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
- package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
- package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
- package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
- package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
- package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
- package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
- package/db/d1/migrations/meta/_journal.json +76 -0
- package/db/d1/schema.ts +407 -0
- package/db/d1/sites.ts +374 -0
- package/db/d1/teamAiUsage.ts +101 -0
- package/db/d1/teams.ts +127 -0
- package/db/durable/drizzle.config.ts +8 -0
- package/db/durable/durableObjectClient.ts +480 -0
- package/db/durable/events.ts +100 -0
- package/db/durable/migrations/0000_fair_bucky.sql +38 -0
- package/db/durable/migrations/meta/0000_snapshot.json +278 -0
- package/db/durable/migrations/meta/_journal.json +13 -0
- package/db/durable/migrations/migrations.js +10 -0
- package/db/durable/schema.ts +5 -0
- package/db/durable/siteDurableObject.ts +1352 -0
- package/db/durable/types.ts +53 -0
- package/db/postgres/client.ts +13 -0
- package/db/postgres/drizzle.config.ts +12 -0
- package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
- package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
- package/db/postgres/migrations/meta/_journal.json +13 -0
- package/db/postgres/schema.ts +145 -0
- package/db/postgres/sites.ts +118 -0
- package/db/tranformReports.ts +595 -0
- package/db/types.ts +55 -0
- package/endpoints/api_worker.tsx +1854 -0
- package/endpoints/site_do_worker.ts +11 -0
- package/index.d.ts +63 -0
- package/index.ts +83 -0
- package/lib/auth.ts +279 -0
- package/lib/geojson/world_countries.json +45307 -0
- package/lib/random_name.ts +41 -0
- package/lib/sendMail.ts +252 -0
- package/package.json +142 -0
- package/public/favicon.ico +0 -0
- package/public/images/android-chrome-192x192.png +0 -0
- package/public/images/android-chrome-512x512.png +0 -0
- package/public/images/apple-touch-icon.png +0 -0
- package/public/images/favicon-16x16.png +0 -0
- package/public/images/favicon-32x32.png +0 -0
- package/public/images/lytx_dark_dashboard.png +0 -0
- package/public/images/lytx_light_dashboard.png +0 -0
- package/public/images/safari-pinned-tab.svg +4 -0
- package/public/logo.png +0 -0
- package/public/site.webmanifest +26 -0
- package/public/sw.js +107 -0
- package/src/Document.tsx +86 -0
- package/src/api/ai_api.ts +1156 -0
- package/src/api/authMiddleware.ts +45 -0
- package/src/api/auth_api.ts +465 -0
- package/src/api/event_labels_api.ts +193 -0
- package/src/api/events_api.ts +210 -0
- package/src/api/queueWorker.ts +303 -0
- package/src/api/reports_api.ts +278 -0
- package/src/api/seed_api.ts +288 -0
- package/src/api/sites_api.ts +904 -0
- package/src/api/tag_api.ts +458 -0
- package/src/api/tag_api_v2.ts +289 -0
- package/src/api/team_api.ts +456 -0
- package/src/app/Dashboard.tsx +1339 -0
- package/src/app/Events.tsx +974 -0
- package/src/app/Explore.tsx +312 -0
- package/src/app/Layout.tsx +58 -0
- package/src/app/Settings.tsx +1302 -0
- package/src/app/components/DashboardCard.tsx +118 -0
- package/src/app/components/EditableCell.tsx +123 -0
- package/src/app/components/EventForm.tsx +93 -0
- package/src/app/components/MarketingFooter.tsx +49 -0
- package/src/app/components/MarketingNav.tsx +150 -0
- package/src/app/components/Nav.tsx +755 -0
- package/src/app/components/NewSiteSetup.tsx +298 -0
- package/src/app/components/SQLEditor.tsx +740 -0
- package/src/app/components/SiteSelector.tsx +126 -0
- package/src/app/components/SiteTag.tsx +42 -0
- package/src/app/components/SiteTagInstallCard.tsx +241 -0
- package/src/app/components/WorldMapCard.tsx +337 -0
- package/src/app/components/charts/ChartComponents.tsx +1481 -0
- package/src/app/components/charts/EventFunnel.tsx +45 -0
- package/src/app/components/charts/EventSummary.tsx +194 -0
- package/src/app/components/charts/SankeyFlows.tsx +72 -0
- package/src/app/components/marketing/CheckIcon.tsx +16 -0
- package/src/app/components/marketing/MarketingLayout.tsx +23 -0
- package/src/app/components/marketing/SectionHeading.tsx +35 -0
- package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
- package/src/app/components/reports/CreateReportStarter.tsx +74 -0
- package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
- package/src/app/components/reports/DashboardToolbar.tsx +154 -0
- package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
- package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
- package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
- package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
- package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
- package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
- package/src/app/components/reports/custom/chartPalettes.ts +18 -0
- package/src/app/components/reports/custom/types.ts +50 -0
- package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
- package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
- package/src/app/components/ui/AlertBanner.tsx +101 -0
- package/src/app/components/ui/Button.tsx +55 -0
- package/src/app/components/ui/Card.tsx +80 -0
- package/src/app/components/ui/Input.tsx +72 -0
- package/src/app/components/ui/Link.tsx +23 -0
- package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
- package/src/app/components/ui/ThemeToggle.tsx +54 -0
- package/src/app/constants.ts +6 -0
- package/src/app/headers.ts +33 -0
- package/src/app/providers/AuthProvider.tsx +189 -0
- package/src/app/providers/ClientProviders.tsx +18 -0
- package/src/app/providers/QueryProvider.tsx +23 -0
- package/src/app/providers/ThemeProvider.tsx +88 -0
- package/src/app/utils/chartThemes.ts +146 -0
- package/src/app/utils/keybinds.ts +96 -0
- package/src/app/utils/media.tsx +24 -0
- package/src/client.tsx +114 -0
- package/src/config/createLytxAppConfig.ts +252 -0
- package/src/config/resourceNames.ts +88 -0
- package/src/db/index.ts +67 -0
- package/src/index.css +285 -0
- package/src/lib/featureFlags.ts +69 -0
- package/src/pages/GetStarted.tsx +290 -0
- package/src/pages/Home.tsx +268 -0
- package/src/pages/Login.tsx +283 -0
- package/src/pages/PrivacyPolicy.tsx +120 -0
- package/src/pages/Signup.tsx +267 -0
- package/src/pages/TermsOfService.tsx +126 -0
- package/src/pages/VerifyEmail.tsx +56 -0
- package/src/session/durableObject.ts +7 -0
- package/src/session/siteSchema.ts +86 -0
- package/src/session/types.ts +36 -0
- package/src/templates/README.md +80 -0
- package/src/templates/cleanFunctions.js +44 -0
- package/src/templates/embedFunctions.js +52 -0
- package/src/templates/lytx-shared.ts +662 -0
- package/src/templates/lytxpixel-core.ts +144 -0
- package/src/templates/lytxpixel.ts +267 -0
- package/src/templates/lytxpixelBrowser.js +634 -0
- package/src/templates/lytxpixelBrowser.mjs +634 -0
- package/src/templates/parseData.js +12 -0
- package/src/templates/script.ts +31 -0
- package/src/templates/template.tsx +50 -0
- package/src/templates/test.js +3 -0
- package/src/templates/trackWebEvents.ts +177 -0
- package/src/templates/vendors/clickcease.ts +8 -0
- package/src/templates/vendors/google.ts +174 -0
- package/src/templates/vendors/linkedin.ts +23 -0
- package/src/templates/vendors/meta.ts +56 -0
- package/src/templates/vendors/quantcast.ts +22 -0
- package/src/templates/vendors/simplfi.ts +7 -0
- package/src/types/app-context.ts +16 -0
- package/src/utilities/dashboardParams.ts +188 -0
- package/src/utilities/dashboardQueries.ts +537 -0
- package/src/utilities/dashboardTransforms.ts +167 -0
- package/src/utilities/dataValidation.ts +414 -0
- package/src/utilities/detector.ts +73 -0
- package/src/utilities/encrypt.ts +103 -0
- package/src/utilities/index.ts +13 -0
- package/src/utilities/parser.ts +117 -0
- package/src/utilities/performanceMonitoring.ts +570 -0
- package/src/utilities/route_interuptors.ts +24 -0
- package/src/worker.tsx +675 -0
- package/tsconfig.json +78 -0
- package/types/env.d.ts +16 -0
- package/types/rw.d.ts +7 -0
- package/types/shims.d.ts +53 -0
- package/types/vite.d.ts +19 -0
- package/vite/vite-plugin-pixel-bundle.ts +126 -0
- package/vite.config.ts +53 -0
- 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
|
+
]);
|