From ad3b732f602d69125d36d3ecebf4f4593b33ec73 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:10:46 -0400 Subject: [PATCH] hosted: add product analytics (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * track core product analytics flows Track auth, search, export, audit, and credit-consumption events with canonical route IDs so PostHog funnels and usage dashboards stay low-noise and privacy-safe. * fix: keep auth actions usable after session loss * refactor: simplify analytics and auth helpers - Replace isRecord/getActiveOrganizationId type guards with simple cast - Refactor getAnalyticsRouteContext from if/return chain to route tables - Replace toVerificationIssueType switch with zod enum - Merge duplicate credits_consume events into single event per API call - Merge two PostHogBootstrap useEffects into one * refactor: add projectId to middleware context to reduce boilerplate The requireProjectContext middleware now includes projectId directly, eliminating repeated manual construction of BillingCustomerContext objects across all server function handlers. * remove unused BILLING_* env var fallbacks from cost profile script * remove before_send event enrichment to preserve native PostHog URL tracking The before_send hook was stripping $pathname, $current_url, $referrer and other URL properties, which breaks PostHog web analytics dashboards, paths analysis, session replay, and attribution. The route_id/route_group injection it provided is unnecessary since PostHog already captures $pathname natively. * remove route mapping layer, pass raw redirect paths to analytics events The route ID registry (STATIC_ROUTES, PROJECT_ROUTES, getAnalyticsRouteContext, getRedirectRouteId) duplicated what PostHog already captures via $pathname. Replace redirect_route_id with redirect_to containing the raw path, and remove ~80 lines of route mapping infrastructure. * clean up analytics events: drop redundant submit events and derived properties - Remove search_submit events for keywords, domain overview, and backlinks (the search_complete events capture the meaningful outcome data) - Remove target_type from backlinks events (derived 1:1 from search_scope) - Remove result_limit from keyword research (requested limit, not useful alongside actual result_count) - Remove export_format from data:export events (always "csv") * refactor: inline wrappers, colocate helpers, deduplicate getActiveOrganizationId - Inline toVerificationIssueType into verify-email.tsx (single-use wrapper) - Move mapDataforseoPathToCreditFeature into dataforseoClient.ts (only consumer) - Extract shared getActiveOrganizationId into lib/auth-session.ts (was duplicated in __root.tsx and middleware/ensure-user/hosted.ts) - Rename shared/analytics.ts → shared/internal-user.ts (only email helpers remain after removing route mapping, verification, and dataforseo helpers) * remove internal user tracking and email domain properties Drop is_internal_user super property, email_domain person property, and all supporting code (shared/internal-user.ts, getEmailDomain, isInternalUserEmail). Simplifies initPostHog and identifyAnalyticsUser signatures. * remove backlinks:search_complete effect-based tracking The reactive useEffect + useRef dedup pattern added ~30 lines of plumbing inside a data hook for a single analytics event. Not worth the complexity. * simplify: replace manual type guards with zod, deduplicate posthog and sign-out helpers - Replace hand-rolled typeof checks in getActiveOrganizationId and isAuthenticatedServerFunctionContext with zod safeParse - Extract withPostHogClient helper to deduplicate client posthog wrapper - Move apiKey guard into getServerPostHogClient factory - Extract signOutAndRedirect to avoid duplicated sign-out logic - Drop derivable has_results from analytics events - Remove unnecessary path normalization in mapDataforseoPathToCreditFeature * fix: strip email from pageview URLs, restore sign-out guard, harden server posthog, fix path mapper - Sanitize $current_url on pageviews to remove email query param (PII) - Restore onSuccess for sign-out redirect to avoid bounce-back on failure - Swallow shutdown() errors so PostHog outages can't fail billed work - Rewrite mapDataforseoPathToCreditFeature to match real API path structure (path[1] = module, path[3] = endpoint) instead of scanning all segments * simplify: remove redundant refs in verify-email, infer middleware context type - Remove unnecessary useRef guards in verify-email effects (deps already prevent re-firing) - Use z.ZodType annotation to infer return type instead of casting - Add comment explaining one-shot PostHog client on Workers * fix: reset PostHog identity on sign-out before redirect * fix: require POSTHOG_HOST env var instead of defaulting to us.i.posthog.com * fix: annotate url as unknown to satisfy no-unsafe-assignment * format --- scripts/backlinks-cost-profile.ts | 9 +- src/client/features/auth/AuthPage.tsx | 3 - .../backlinks/BacklinksSearchCard.tsx | 12 ++- .../domain/components/DomainResultsCard.tsx | 8 ++ src/client/features/domain/domainActions.ts | 5 + .../domainOverviewControllerInternals.ts | 7 ++ .../keywords/hooks/useKeywordResearchData.ts | 9 ++ .../state/keywordControllerActions.ts | 9 ++ .../state/useKeywordResearchController.ts | 2 + src/client/layout/AppShell.tsx | 14 +-- src/client/lib/posthog.ts | 76 +++++++++++---- src/lib/auth-client.ts | 15 +++ src/lib/auth-session.ts | 12 +++ src/middleware/ensure-user/hosted.ts | 13 +-- src/routes/__root.tsx | 24 ++++- src/routes/_auth.sign-in.tsx | 21 ++-- src/routes/_auth.sign-up.tsx | 21 ++-- src/routes/_authenticated.subscribe.tsx | 16 +-- src/routes/_project/p/$projectId/saved.tsx | 6 ++ src/routes/verify-email.tsx | 23 +++++ src/server/billing/subscription.test.ts | 3 + src/server/billing/subscription.ts | 6 +- .../services/BacklinksService.billing.test.ts | 2 + src/server/lib/dataforseoClient.test.ts | 97 ++++++++++++++++++- src/server/lib/dataforseoClient.ts | 54 +++++++++++ src/server/lib/posthog.ts | 58 ++++++++--- src/server/workflows/SiteAuditWorkflow.ts | 19 ++++ .../workflows/siteAuditWorkflowPhases.ts | 48 +++++++-- src/serverFunctions/audit.ts | 36 ++++--- src/serverFunctions/backlinks.ts | 18 ++-- src/serverFunctions/backlinksAccess.ts | 5 +- src/serverFunctions/domain.ts | 7 +- src/serverFunctions/keywords.ts | 20 ++-- src/serverFunctions/lighthouse.ts | 4 +- src/serverFunctions/middleware.ts | 36 +++---- 35 files changed, 539 insertions(+), 179 deletions(-) create mode 100644 src/lib/auth-session.ts diff --git a/scripts/backlinks-cost-profile.ts b/scripts/backlinks-cost-profile.ts index 2fcfb8c..7f926e8 100644 --- a/scripts/backlinks-cost-profile.ts +++ b/scripts/backlinks-cost-profile.ts @@ -105,12 +105,9 @@ function buildBillingCustomer( cliArgs: Record, ): BillingCustomerContext { return { - organizationId: - cliArgs.organizationId ?? process.env.BILLING_ORGANIZATION_ID ?? "local", - userEmail: - cliArgs.userEmail ?? - process.env.BILLING_USER_EMAIL ?? - "local@example.com", + organizationId: cliArgs.organizationId ?? "local", + userId: cliArgs.userId ?? "local-user", + userEmail: cliArgs.userEmail ?? "local@example.com", }; } diff --git a/src/client/features/auth/AuthPage.tsx b/src/client/features/auth/AuthPage.tsx index b3dc5dc..1eb80e9 100644 --- a/src/client/features/auth/AuthPage.tsx +++ b/src/client/features/auth/AuthPage.tsx @@ -1,6 +1,5 @@ import { z } from "zod"; import { normalizeAuthRedirect } from "@/lib/auth-redirect"; -import { useSession } from "@/lib/auth-client"; import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { getFieldError as getSharedFieldError, @@ -13,13 +12,11 @@ export const authRedirectSearchSchema = z.object({ export function useAuthPageState(redirect: string | undefined) { const redirectTo = normalizeAuthRedirect(redirect); - const { isPending: isSessionPending } = useSession(); const isHostedMode = isHostedClientAuthMode(); return { redirectTo, isHostedMode, - isSessionPending, }; } diff --git a/src/client/features/backlinks/BacklinksSearchCard.tsx b/src/client/features/backlinks/BacklinksSearchCard.tsx index 977e562..c14c80c 100644 --- a/src/client/features/backlinks/BacklinksSearchCard.tsx +++ b/src/client/features/backlinks/BacklinksSearchCard.tsx @@ -54,14 +54,16 @@ export function BacklinksSearchCard({ }, onSubmit: ({ value }) => { const target = value.target.trim(); + const scope = resolveBacklinksSearchScope({ + target, + selectedScope: value.scope, + userSelectedScope, + }); + onSubmit({ ...value, target, - scope: resolveBacklinksSearchScope({ - target, - selectedScope: value.scope, - userSelectedScope, - }), + scope, }); }, }); diff --git a/src/client/features/domain/components/DomainResultsCard.tsx b/src/client/features/domain/components/DomainResultsCard.tsx index ce89b36..4dc2520 100644 --- a/src/client/features/domain/components/DomainResultsCard.tsx +++ b/src/client/features/domain/components/DomainResultsCard.tsx @@ -18,6 +18,7 @@ import { keywordsToCsv, pagesToCsv, } from "@/client/features/domain/utils"; +import { captureClientEvent } from "@/client/lib/posthog"; import type { DomainActiveTab, DomainOverviewData, @@ -87,6 +88,13 @@ export function DomainResultsCard({ ? keywordsToCsv(filteredKeywords) : pagesToCsv(filteredPages); downloadCsv(rows, `${overview.domain}-${activeTab}.${extension}`); + + if (extension === "csv") { + captureClientEvent("data:export", { + source_feature: "domain_overview", + result_count: currentRows.length, + }); + } }; const isKeywordsTab = activeTab === "keywords"; diff --git a/src/client/features/domain/domainActions.ts b/src/client/features/domain/domainActions.ts index e035480..b411332 100644 --- a/src/client/features/domain/domainActions.ts +++ b/src/client/features/domain/domainActions.ts @@ -1,5 +1,6 @@ import { toast } from "sonner"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { captureClientEvent } from "@/client/lib/posthog"; import type { DomainOverviewData } from "@/client/features/domain/types"; type SaveMutation = (payload: { @@ -54,6 +55,10 @@ export function saveSelectedKeywords({ }, { onSuccess: () => { + captureClientEvent("keyword:save", { + source_feature: "domain_overview", + keyword_count: selectedKeywords.size, + }); toast.success(`Saved ${selectedKeywords.size} keywords`); }, onError: (error: unknown) => { diff --git a/src/client/features/domain/domainOverviewControllerInternals.ts b/src/client/features/domain/domainOverviewControllerInternals.ts index 88fd16a..3be91f4 100644 --- a/src/client/features/domain/domainOverviewControllerInternals.ts +++ b/src/client/features/domain/domainOverviewControllerInternals.ts @@ -5,6 +5,7 @@ import { sortBy } from "remeda"; import { toast } from "sonner"; import { getDomainOverview } from "@/serverFunctions/domain"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { captureClientEvent } from "@/client/lib/posthog"; import { filterAndSortKeywords } from "@/client/features/domain/domainFiltering"; import { getDefaultSortOrder, @@ -293,6 +294,12 @@ export function useSearchRunner({ languageCode: "en", }); + captureClientEvent("domain_overview:search_complete", { + sort_mode: activeSort, + include_subdomains: activeSubdomains, + result_count: response.keywords.length, + }); + setOverview(response); setSelectedKeywords(new Set()); addSearch({ diff --git a/src/client/features/keywords/hooks/useKeywordResearchData.ts b/src/client/features/keywords/hooks/useKeywordResearchData.ts index 9ed3849..de20dc5 100644 --- a/src/client/features/keywords/hooks/useKeywordResearchData.ts +++ b/src/client/features/keywords/hooks/useKeywordResearchData.ts @@ -1,6 +1,7 @@ import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { captureClientEvent } from "@/client/lib/posthog"; import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils"; import { researchKeywords } from "@/serverFunctions/keywords"; import type { @@ -75,11 +76,19 @@ export function useKeywordResearchData(addSearch: AddSearchFn) { }, { onSuccess: (result) => { + const resultCount = result.rows.length; + setResearchError(null); setRows(result.rows); setLastResultSource(result.source); setLastUsedFallback(result.usedFallback); + captureClientEvent("keyword_research:search_complete", { + location_code: input.locationCode, + search_mode: input.mode, + result_count: resultCount, + }); + if (seedKeyword) { addSearch( seedKeyword, diff --git a/src/client/features/keywords/state/keywordControllerActions.ts b/src/client/features/keywords/state/keywordControllerActions.ts index 5d33250..a2a45fd 100644 --- a/src/client/features/keywords/state/keywordControllerActions.ts +++ b/src/client/features/keywords/state/keywordControllerActions.ts @@ -1,6 +1,7 @@ import { toast } from "sonner"; import { buildCsv, downloadCsv } from "@/client/lib/csv"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { captureClientEvent } from "@/client/lib/posthog"; import { getLanguageCode } from "@/client/features/keywords/utils"; import type { KeywordResearchRow } from "@/types/keywords"; import type { SortDir, SortField } from "@/client/features/keywords/components"; @@ -74,6 +75,10 @@ export function useSaveAndExportActions(params: SaveExportActionParams) { }, { onSuccess: () => { + captureClientEvent("keyword:save", { + source_feature: "keyword_research", + keyword_count: selectedRows.size, + }); toast.success(`Saved ${selectedRows.size} keywords`); setShowSaveDialog(false); }, @@ -111,6 +116,10 @@ export function useSaveAndExportActions(params: SaveExportActionParams) { ]); const csv = buildCsv(headers, csvRows); downloadCsv("keyword-research.csv", csv); + captureClientEvent("data:export", { + source_feature: "keyword_research", + result_count: source.length, + }); }; return { handleSaveKeywords, confirmSave, exportCsv }; diff --git a/src/client/features/keywords/state/useKeywordResearchController.ts b/src/client/features/keywords/state/useKeywordResearchController.ts index 11178ed..4dbb69f 100644 --- a/src/client/features/keywords/state/useKeywordResearchController.ts +++ b/src/client/features/keywords/state/useKeywordResearchController.ts @@ -5,6 +5,7 @@ import { useLocalKeywordFilters } from "@/client/features/keywords/hooks/useLoca import { useKeywordResearchData } from "@/client/features/keywords/hooks/useKeywordResearchData"; import { useKeywordSelection } from "@/client/features/keywords/hooks/useKeywordSelection"; import { useKeywordSerpAnalysis } from "@/client/features/keywords/hooks/useKeywordSerpAnalysis"; +import { captureClientEvent } from "@/client/lib/posthog"; import { useSearchHistory } from "@/client/hooks/useSearchHistory"; import { type KeywordMode, @@ -88,6 +89,7 @@ export function useKeywordResearchController( }; const handleRowClick = (row: KeywordResearchRow) => { + captureClientEvent("keyword_research:serp_open"); state.setSelectedKeyword(row); state.setSerpKeyword(row.keyword); state.setSerpPage(0); diff --git a/src/client/layout/AppShell.tsx b/src/client/layout/AppShell.tsx index 8e77669..b4527aa 100644 --- a/src/client/layout/AppShell.tsx +++ b/src/client/layout/AppShell.tsx @@ -14,8 +14,7 @@ import { } from "@/client/layout/AppShellParts"; import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; import { getProjectNavItems } from "@/client/navigation/items"; -import { getSignInHrefForLocation } from "@/lib/auth-redirect"; -import { authClient, useSession } from "@/lib/auth-client"; +import { signOutAndRedirect, useSession } from "@/lib/auth-client"; import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { BILLING_ROUTE } from "@/shared/billing"; import { getSeoApiKeyStatus } from "@/serverFunctions/config"; @@ -244,16 +243,7 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) { const isHostedMode = isHostedClientAuthMode(); const email = session?.user?.email; - const handleSignOut = () => { - const signInHref = getSignInHrefForLocation(window.location); - void authClient.signOut({ - fetchOptions: { - onSuccess: () => { - window.location.assign(signInHref); - }, - }, - }); - }; + const handleSignOut = () => signOutAndRedirect(); const menu = (
diff --git a/src/client/lib/posthog.ts b/src/client/lib/posthog.ts index 62b8538..63e9d09 100644 --- a/src/client/lib/posthog.ts +++ b/src/client/lib/posthog.ts @@ -22,17 +22,33 @@ function getBrowserPostHogClient(): Promise { .then((module) => { const client = module.default; const apiKey = import.meta.env.POSTHOG_PUBLIC_KEY?.trim(); + const host = import.meta.env.POSTHOG_HOST?.trim(); - if (!apiKey) { + if (!apiKey || !host) { return null; } if (!browserPostHogInitialized) { client.init(apiKey, { - api_host: - import.meta.env.POSTHOG_HOST?.trim() || "https://us.i.posthog.com", + api_host: host, defaults: "2026-01-30", capture_exceptions: true, + capture_pageview: "history_change", + sanitize_properties(properties, event) { + if (event === "$pageview" || event === "$pageleave") { + const url: unknown = properties["$current_url"]; + if (typeof url === "string") { + try { + const parsed = new URL(url); + parsed.searchParams.delete("email"); + properties["$current_url"] = parsed.toString(); + } catch { + // leave as-is if URL parsing fails + } + } + } + return properties; + }, }); browserPostHogInitialized = true; } @@ -51,22 +67,48 @@ export function initPostHog() { void getBrowserPostHogClient(); } +function withPostHogClient(fn: (client: BrowserPostHogClient) => void) { + void getBrowserPostHogClient().then((client) => { + if (!client) return; + try { + fn(client); + } catch (e) { + console.error("posthog operation failed", e); + } + }); +} + +export function captureClientEvent( + event: string, + properties?: Record, +) { + withPostHogClient((client) => client.capture(event, properties)); +} + +export function identifyAnalyticsUser(args: { + userId: string; + organizationId: string | null; +}) { + withPostHogClient((client) => { + client.identify(args.userId); + if (args.organizationId) { + client.group("organization", args.organizationId); + } + }); +} + +export function resetAnalyticsUser() { + withPostHogClient((client) => client.reset()); +} + export function captureClientError( error: unknown, properties: Record = {}, ) { - void getBrowserPostHogClient().then((client) => { - if (!client) { - return; - } - - try { - client.captureException(error, { - source: "client", - ...properties, - }); - } catch (e) { - console.error("posthog capture failed", e); - } - }); + withPostHogClient((client) => + client.captureException(error, { + source: "client", + ...properties, + }), + ); } diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 4aa88a5..50b58e9 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,5 +1,7 @@ import { createAuthClient } from "better-auth/react"; import { organizationClient } from "better-auth/client/plugins"; +import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog"; +import { getSignInHrefForLocation } from "@/lib/auth-redirect"; export const authClient = createAuthClient({ baseURL: typeof window !== "undefined" ? window.location.origin : "", @@ -7,3 +9,16 @@ export const authClient = createAuthClient({ }); export const { useSession } = authClient; + +export function signOutAndRedirect() { + const signInHref = getSignInHrefForLocation(window.location); + captureClientEvent("auth:sign_out"); + resetAnalyticsUser(); + void authClient.signOut({ + fetchOptions: { + onSuccess: () => { + window.location.assign(signInHref); + }, + }, + }); +} diff --git a/src/lib/auth-session.ts b/src/lib/auth-session.ts new file mode 100644 index 0000000..9ec84c4 --- /dev/null +++ b/src/lib/auth-session.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +const sessionWithOrg = z.object({ + session: z.object({ + activeOrganizationId: z.string(), + }), +}); + +export function getActiveOrganizationId(session: unknown): string | null { + const result = sessionWithOrg.safeParse(session); + return result.success ? result.data.session.activeOrganizationId : null; +} diff --git a/src/middleware/ensure-user/hosted.ts b/src/middleware/ensure-user/hosted.ts index 13f057a..3d87534 100644 --- a/src/middleware/ensure-user/hosted.ts +++ b/src/middleware/ensure-user/hosted.ts @@ -1,20 +1,9 @@ import { getAuth, hasHostedAuthConfig } from "@/lib/auth"; +import { getActiveOrganizationId } from "@/lib/auth-session"; import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization"; import { AppError } from "@/server/lib/errors"; import type { EnsuredUserContext } from "./types"; -function getActiveOrganizationId(session: { session: unknown }) { - if (!session.session || typeof session.session !== "object") { - return null; - } - - const { activeOrganizationId } = session.session as { - activeOrganizationId?: unknown; - }; - - return typeof activeOrganizationId === "string" ? activeOrganizationId : null; -} - async function requireHostedSession(headers: Headers) { if (!hasHostedAuthConfig()) { throw new AppError( diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index a45ba7d..cec6a05 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -12,12 +12,18 @@ import { QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary"; import { themePreferenceInitScript } from "@/client/lib/theme"; -import { initPostHog } from "@/client/lib/posthog"; +import { + identifyAnalyticsUser, + initPostHog, + resetAnalyticsUser, +} from "@/client/lib/posthog"; import { NotFound } from "@/client/components/NotFound"; import appCss from "@/client/styles/app.css?url"; +import { useSession } from "@/lib/auth-client"; import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { Toaster } from "sonner"; import { queryClient } from "@/client/tanstack-db"; +import { getActiveOrganizationId } from "@/lib/auth-session"; export const Route = createRootRoute({ head: () => ({ @@ -77,14 +83,26 @@ function AppLayout() { function PostHogBootstrap() { const isHostedMode = isHostedClientAuthMode(); + const { data: session, isPending: isSessionPending } = useSession(); + const userId = session?.user?.id ?? null; + const organizationId = getActiveOrganizationId(session); + const previousUserIdRef = React.useRef(null); React.useEffect(() => { - if (!isHostedMode) { + if (!isHostedMode || isSessionPending) { return; } initPostHog(); - }, [isHostedMode]); + + if (userId) { + identifyAnalyticsUser({ userId, organizationId }); + previousUserIdRef.current = userId; + } else if (previousUserIdRef.current) { + previousUserIdRef.current = null; + resetAnalyticsUser(); + } + }, [isHostedMode, isSessionPending, organizationId, userId]); return null; } diff --git a/src/routes/_auth.sign-in.tsx b/src/routes/_auth.sign-in.tsx index 7a435ab..b46d68f 100644 --- a/src/routes/_auth.sign-in.tsx +++ b/src/routes/_auth.sign-in.tsx @@ -9,6 +9,7 @@ import { getFormError, useAuthPageState, } from "@/client/features/auth/AuthPage"; +import { captureClientEvent } from "@/client/lib/posthog"; import { authClient } from "@/lib/auth-client"; import { getSignInSearch } from "@/lib/auth-redirect"; import { z } from "zod"; @@ -25,9 +26,7 @@ export const Route = createFileRoute("/_auth/sign-in")({ function SignInPage() { const search = Route.useSearch(); - const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState( - search.redirect, - ); + const { redirectTo, isHostedMode } = useAuthPageState(search.redirect); const [verificationEmail, setVerificationEmail] = useState( null, ); @@ -44,6 +43,9 @@ function SignInPage() { onSubmit: async ({ formApi, value }) => { try { const email = value.email.trim(); + captureClientEvent("auth:sign_in_submit", { + redirect_to: redirectTo, + }); setVerificationEmail(null); const result = await authClient.signIn.email({ @@ -53,10 +55,16 @@ function SignInPage() { }); if (!result.error) { + captureClientEvent("auth:sign_in_success", { + redirect_to: redirectTo, + }); return; } if (result.error.status === 403) { + captureClientEvent("auth:sign_in_block_unverified", { + redirect_to: redirectTo, + }); setVerificationEmail(email); formApi.setErrorMap({ onSubmit: { @@ -105,6 +113,7 @@ function SignInPage() { return; } + captureClientEvent("auth:verification_resend"); toast.success("A new email is on the way."); } catch { toast.error( @@ -159,7 +168,7 @@ function SignInPage() { value={field.state.value} onChange={(event) => field.handleChange(event.target.value)} autoComplete="email" - disabled={!isHostedMode || isSessionPending} + disabled={!isHostedMode} required /> {error ? ( @@ -183,7 +192,7 @@ function SignInPage() { value={field.state.value} onChange={(event) => field.handleChange(event.target.value)} autoComplete="current-password" - disabled={!isHostedMode || isSessionPending} + disabled={!isHostedMode} required /> {error ? ( @@ -232,7 +241,7 @@ function SignInPage() { ) : null} diff --git a/src/routes/_auth.sign-up.tsx b/src/routes/_auth.sign-up.tsx index 6f956ec..c4587a0 100644 --- a/src/routes/_auth.sign-up.tsx +++ b/src/routes/_auth.sign-up.tsx @@ -7,6 +7,7 @@ import { getFormError, useAuthPageState, } from "@/client/features/auth/AuthPage"; +import { captureClientEvent } from "@/client/lib/posthog"; import { authClient } from "@/lib/auth-client"; import { getSignInSearch } from "@/lib/auth-redirect"; import { @@ -44,9 +45,7 @@ export const Route = createFileRoute("/_auth/sign-up")({ function SignUpPage() { const search = Route.useSearch(); const navigate = useNavigate(); - const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState( - search.redirect, - ); + const { redirectTo, isHostedMode } = useAuthPageState(search.redirect); const form = useForm({ defaultValues: { @@ -61,6 +60,9 @@ function SignUpPage() { onSubmit: async ({ formApi, value }) => { try { const email = value.email.trim(); + captureClientEvent("auth:sign_up_submit", { + redirect_to: redirectTo, + }); const resolvedName = value.name.trim() || email.split("@")[0] || "OpenSEO User"; const result = await authClient.signUp.email({ @@ -85,6 +87,9 @@ function SignUpPage() { return; } + captureClientEvent("auth:sign_up_success", { + redirect_to: redirectTo, + }); void navigate({ to: "/verify-email", search: { email, ...getSignInSearch(redirectTo) }, @@ -162,7 +167,7 @@ function SignUpPage() { value={field.state.value} onChange={(event) => field.handleChange(event.target.value)} autoComplete="name" - disabled={!isHostedMode || isSessionPending} + disabled={!isHostedMode} /> {error ? (

{error}

@@ -185,7 +190,7 @@ function SignUpPage() { value={field.state.value} onChange={(event) => field.handleChange(event.target.value)} autoComplete="email" - disabled={!isHostedMode || isSessionPending} + disabled={!isHostedMode} required /> {error ? ( @@ -209,7 +214,7 @@ function SignUpPage() { value={field.state.value} onChange={(event) => field.handleChange(event.target.value)} autoComplete="new-password" - disabled={!isHostedMode || isSessionPending} + disabled={!isHostedMode} required minLength={HOSTED_PASSWORD_MIN_LENGTH} maxLength={HOSTED_PASSWORD_MAX_LENGTH} @@ -235,7 +240,7 @@ function SignUpPage() { value={field.state.value} onChange={(event) => field.handleChange(event.target.value)} autoComplete="new-password" - disabled={!isHostedMode || isSessionPending} + disabled={!isHostedMode} required minLength={HOSTED_PASSWORD_MIN_LENGTH} maxLength={HOSTED_PASSWORD_MAX_LENGTH} @@ -263,7 +268,7 @@ function SignUpPage() { ) : null} diff --git a/src/routes/_authenticated.subscribe.tsx b/src/routes/_authenticated.subscribe.tsx index d665cb5..a0a4727 100644 --- a/src/routes/_authenticated.subscribe.tsx +++ b/src/routes/_authenticated.subscribe.tsx @@ -3,8 +3,8 @@ import { AutumnProvider, useCustomer } from "autumn-js/react"; import { useEffect, useState } from "react"; import { User } from "lucide-react"; import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; -import { authClient, useSession } from "@/lib/auth-client"; -import { getSignInHrefForLocation } from "@/lib/auth-redirect"; +import { captureClientEvent } from "@/client/lib/posthog"; +import { signOutAndRedirect, useSession } from "@/lib/auth-client"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getSubscribeRouteState } from "@/client/features/billing/route-state"; import { @@ -96,6 +96,7 @@ function SubscribePageContent() { setIsAttaching(true); try { + captureClientEvent("billing:checkout_start"); await customerQuery.attach({ planId: AUTUMN_PAID_PLAN_ID, redirectMode: "always", @@ -168,16 +169,7 @@ function SubscribePageContent() { function SubscribePageAccountMenu({ email }: { email: string | undefined }) { if (!email) return null; - const handleSignOut = () => { - const signInHref = getSignInHrefForLocation(window.location); - void authClient.signOut({ - fetchOptions: { - onSuccess: () => { - window.location.assign(signInHref); - }, - }, - }); - }; + const handleSignOut = () => signOutAndRedirect(); return (
diff --git a/src/routes/_project/p/$projectId/saved.tsx b/src/routes/_project/p/$projectId/saved.tsx index cef4791..f835386 100644 --- a/src/routes/_project/p/$projectId/saved.tsx +++ b/src/routes/_project/p/$projectId/saved.tsx @@ -9,6 +9,7 @@ import { import { Trash2, Download, Search, Loader2, AlertCircle } from "lucide-react"; import { buildCsv, downloadCsv } from "@/client/lib/csv"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { captureClientEvent } from "@/client/lib/posthog"; export const Route = createFileRoute("/_project/p/$projectId/saved")({ component: SavedKeywordsPage, @@ -33,6 +34,7 @@ function SavedKeywordsPage() { void queryClient.invalidateQueries({ queryKey: ["savedKeywords", projectId], }); + captureClientEvent("saved_keywords:remove"); toast.success("Keyword removed"); }, onError: (error) => { @@ -78,6 +80,10 @@ function SavedKeywordsPage() { ]); const csv = buildCsv(headers, csvRows); downloadCsv("saved-keywords.csv", csv); + captureClientEvent("data:export", { + source_feature: "saved_keywords", + result_count: savedKeywords.length, + }); }; return ( diff --git a/src/routes/verify-email.tsx b/src/routes/verify-email.tsx index 6d11e72..3ed62bb 100644 --- a/src/routes/verify-email.tsx +++ b/src/routes/verify-email.tsx @@ -6,11 +6,16 @@ import { AuthPageShell, authRedirectSearchSchema, } from "@/client/features/auth/AuthPage"; +import { captureClientEvent } from "@/client/lib/posthog"; import { authClient, useSession } from "@/lib/auth-client"; import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect"; import { z } from "zod"; +const verificationIssueSchema = z + .enum(["invalid_token", "token_expired", "user_not_found", "unknown"]) + .catch("unknown"); + const verifyEmailSearchSchema = authRedirectSearchSchema.extend({ error: z.string().optional(), email: z.string().optional(), @@ -98,6 +103,9 @@ function VerifyEmailPage() { const isHostedMode = isHostedClientAuthMode(); const { data: session, isPending } = useSession(); const errorMessage = getVerificationErrorMessage(search.error); + const verificationIssueType = search.error + ? verificationIssueSchema.parse(search.error) + : null; const email = search.email; const isWaiting = !errorMessage && !session?.user?.emailVerified && !!email; const [isResending, setIsResending] = useState(false); @@ -116,6 +124,10 @@ function VerifyEmailPage() { return; } + captureClientEvent("auth:verification_success", { + redirect_to: redirectTo, + }); + // Full page reload instead of client-side navigation: the auth→app // transition needs a clean server-side load so that all server function // handlers are freshly registered (client-side nav during Vite HMR can @@ -124,6 +136,16 @@ function VerifyEmailPage() { window.location.replace(redirectTo); }, [isVerified, redirectTo]); + useEffect(() => { + if (!verificationIssueType) { + return; + } + + captureClientEvent("auth:verification_issue", { + issue_type: verificationIssueType, + }); + }, [verificationIssueType]); + async function handleResend() { if (!email) return; setIsResending(true); @@ -139,6 +161,7 @@ function VerifyEmailPage() { toast.error(result.error.message || "We couldn't send another email."); return; } + captureClientEvent("auth:verification_resend"); toast.success("A new email is on the way."); } catch { toast.error( diff --git a/src/server/billing/subscription.test.ts b/src/server/billing/subscription.test.ts index 844e9a1..784c6ae 100644 --- a/src/server/billing/subscription.test.ts +++ b/src/server/billing/subscription.test.ts @@ -52,6 +52,7 @@ describe("subscription billing", () => { await expect( requireManagedServiceAccess({ organizationId: "org_123", + userId: "user_123", userEmail: "alice@example.com", }), ).resolves.toBeUndefined(); @@ -68,6 +69,7 @@ describe("subscription billing", () => { await expect( requireManagedServiceAccess({ organizationId: "org_123", + userId: "user_123", userEmail: "alice@example.com", }), ).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" }); @@ -78,6 +80,7 @@ describe("subscription billing", () => { await getOrCreateOrganizationCustomer({ organizationId: "org_123", + userId: "user_123", userEmail: "alice@example.com", }); diff --git a/src/server/billing/subscription.ts b/src/server/billing/subscription.ts index 4db95b8..13ce11f 100644 --- a/src/server/billing/subscription.ts +++ b/src/server/billing/subscription.ts @@ -6,8 +6,10 @@ import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; export type BillingCustomerContext = Pick< EnsuredUserContext, - "organizationId" | "userEmail" ->; + "organizationId" | "userEmail" | "userId" +> & { + projectId?: string; +}; export async function getOrCreateOrganizationCustomer( context: BillingCustomerContext, diff --git a/src/server/features/backlinks/services/BacklinksService.billing.test.ts b/src/server/features/backlinks/services/BacklinksService.billing.test.ts index 92efa08..d4a77e8 100644 --- a/src/server/features/backlinks/services/BacklinksService.billing.test.ts +++ b/src/server/features/backlinks/services/BacklinksService.billing.test.ts @@ -36,6 +36,7 @@ import { createBacklinksService } from "./BacklinksService"; const billingCustomer = { organizationId: "org_123", + userId: "user_123", userEmail: "team@example.com", }; @@ -227,6 +228,7 @@ it("keeps cache entries isolated per organization", async () => { await service.profileOverview(input, billingCustomer); await service.profileOverview(input, { organizationId: "org_456", + userId: "user_456", userEmail: "other@example.com", }); diff --git a/src/server/lib/dataforseoClient.test.ts b/src/server/lib/dataforseoClient.test.ts index 1819e95..3afb3a2 100644 --- a/src/server/lib/dataforseoClient.test.ts +++ b/src/server/lib/dataforseoClient.test.ts @@ -19,6 +19,10 @@ const { checkMock, trackMock, getOrCreateMock, isHostedServerAuthModeMock } = isHostedServerAuthModeMock: vi.fn(), })); +vi.mock("cloudflare:workers", () => ({ + waitUntil: vi.fn(), +})); + vi.mock("@/server/billing/autumn", () => ({ autumn: { check: checkMock, @@ -34,6 +38,10 @@ vi.mock("@/server/lib/runtime-env", () => ({ isHostedServerAuthMode: isHostedServerAuthModeMock, })); +vi.mock("@/server/lib/posthog", () => ({ + captureServerEvent: vi.fn(), +})); + vi.mock("@/server/lib/dataforseo", () => ({ fetchKeywordIdeasRaw: vi.fn(), fetchKeywordSuggestionsRaw: vi.fn(), @@ -55,11 +63,15 @@ vi.mock("@/server/lib/dataforseoBacklinks", () => ({ fetchReferringDomainsRaw: vi.fn(), })); -import { createDataforseoClient } from "./dataforseoClient"; +import { + createDataforseoClient, + mapDataforseoPathToCreditFeature, +} from "./dataforseoClient"; import { fetchBacklinksSummaryRaw } from "./dataforseoBacklinks"; const billingCustomer = { organizationId: "org_123", + userId: "user_123", userEmail: "alice@example.com", }; @@ -234,3 +246,86 @@ describe("meterDataforseoCall with split balances", () => { ); }); }); + +describe("mapDataforseoPathToCreditFeature", () => { + it("maps real keyword research paths", () => { + expect( + mapDataforseoPathToCreditFeature([ + "v3", + "dataforseo_labs", + "google", + "related_keywords", + "live", + ]), + ).toBe("keyword_research"); + expect( + mapDataforseoPathToCreditFeature([ + "v3", + "dataforseo_labs", + "google", + "keyword_suggestions", + "live", + ]), + ).toBe("keyword_research"); + }); + + it("maps real serp paths", () => { + expect( + mapDataforseoPathToCreditFeature([ + "v3", + "serp", + "google", + "organic", + "live", + "regular", + ]), + ).toBe("keyword_research"); + }); + + it("maps real domain paths", () => { + expect( + mapDataforseoPathToCreditFeature([ + "v3", + "dataforseo_labs", + "google", + "domain_rank_overview", + "live", + ]), + ).toBe("domain_overview"); + expect( + mapDataforseoPathToCreditFeature([ + "v3", + "dataforseo_labs", + "google", + "ranked_keywords", + "live", + ]), + ).toBe("domain_overview"); + }); + + it("maps real backlinks paths", () => { + expect( + mapDataforseoPathToCreditFeature(["v3", "backlinks", "summary", "live"]), + ).toBe("backlinks"); + expect( + mapDataforseoPathToCreditFeature([ + "v3", + "backlinks", + "referring_domains", + "live", + ]), + ).toBe("backlinks"); + }); + + it("maps real lighthouse/on_page paths to site_audit", () => { + expect( + mapDataforseoPathToCreditFeature([ + "v3", + "on_page", + "lighthouse", + "live", + "json", + ]), + ).toBe("site_audit"); + }); +}); diff --git a/src/server/lib/dataforseoClient.ts b/src/server/lib/dataforseoClient.ts index 846624e..044d438 100644 --- a/src/server/lib/dataforseoClient.ts +++ b/src/server/lib/dataforseoClient.ts @@ -36,8 +36,44 @@ import { type DataforseoApiCallCost, } from "@/server/lib/dataforseoCost"; import { AppError } from "@/server/lib/errors"; +import { captureServerEvent } from "@/server/lib/posthog"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; +type CreditFeature = + | "keyword_research" + | "domain_overview" + | "backlinks" + | "site_audit"; + +/** + * Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"]) + * to a product feature for analytics. path[1] is the API module; for dataforseo_labs, + * path[3] distinguishes keyword vs domain endpoints. + */ +export function mapDataforseoPathToCreditFeature( + path: string[], +): CreditFeature { + const module = path[1]; + + switch (module) { + case "on_page": + return "site_audit"; + case "backlinks": + return "backlinks"; + case "serp": + return "keyword_research"; + case "dataforseo_labs": { + const endpoint = path[3] ?? ""; + if (endpoint.startsWith("domain_") || endpoint === "ranked_keywords") { + return "domain_overview"; + } + return "keyword_research"; + } + default: + return "site_audit"; + } +} + export function createDataforseoClient(customer: BillingCustomerContext) { return { backlinks: { @@ -194,6 +230,7 @@ async function meterDataforseoCall( const result = await execute(); await trackDataforseoCost({ + customer, customerId: billingCustomer.id, billing: result.billing, monthlyRemaining, @@ -233,6 +270,7 @@ async function assertSeoDataBalanceAvailable(args: { } async function trackDataforseoCost(args: { + customer: BillingCustomerContext; customerId: string; billing: DataforseoApiCallCost; monthlyRemaining: number; @@ -277,6 +315,22 @@ async function trackDataforseoCost(args: { }, }); } + + if (totalCostCredits > 0) { + await captureServerEvent({ + distinctId: args.customer.userId, + event: "usage:credits_consume", + organizationId: args.customer.organizationId, + properties: { + project_id: args.customer.projectId, + credit_feature: mapDataforseoPathToCreditFeature(args.billing.path), + monthly_credits: monthlyDeduct, + topup_credits: topupDeduct, + total_credits: totalCostCredits, + cost_usd: totalCostUsd, + }, + }); + } } export type { LabsKeywordDataItem, SerpLiveItem }; diff --git a/src/server/lib/posthog.ts b/src/server/lib/posthog.ts index dec3568..995c914 100644 --- a/src/server/lib/posthog.ts +++ b/src/server/lib/posthog.ts @@ -2,6 +2,20 @@ import { env } from "cloudflare:workers"; import { PostHog } from "posthog-node"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; +/** Returns a one-shot PostHog client, or null if the key is missing. Caller must shut down after use. + * A new instance per call is fine — this runs on Cloudflare Workers where construction cost is negligible. */ +function getServerPostHogClient(): PostHog | null { + const apiKey = env.POSTHOG_PUBLIC_KEY?.trim(); + const host = env.POSTHOG_HOST?.trim(); + if (!apiKey || !host) return null; + + return new PostHog(apiKey, { + host, + flushAt: 1, + flushInterval: 0, + }); +} + export async function captureServerError( error: unknown, properties: Record = {}, @@ -10,17 +24,8 @@ export async function captureServerError( return; } - const apiKey = env.POSTHOG_PUBLIC_KEY?.trim(); - - if (!apiKey) { - return; - } - - const client = new PostHog(apiKey, { - host: env.POSTHOG_HOST?.trim() || "https://us.i.posthog.com", - flushAt: 1, - flushInterval: 0, - }); + const client = getServerPostHogClient(); + if (!client) return; try { await client.captureExceptionImmediate(error, undefined, { @@ -30,6 +35,35 @@ export async function captureServerError( } catch (posthogError) { console.error("posthog server capture failed", posthogError); } finally { - await client.shutdown(); + await client.shutdown().catch(() => {}); + } +} + +export async function captureServerEvent(args: { + distinctId: string; + event: string; + properties?: Record; + organizationId: string; +}) { + if (!(await isHostedServerAuthMode())) { + return; + } + + const client = getServerPostHogClient(); + if (!client) return; + + try { + client.capture({ + distinctId: args.distinctId, + event: args.event, + properties: args.properties, + groups: { + organization: args.organizationId, + }, + }); + } catch (posthogError) { + console.error("posthog server capture failed", posthogError); + } finally { + await client.shutdown().catch(() => {}); } } diff --git a/src/server/workflows/SiteAuditWorkflow.ts b/src/server/workflows/SiteAuditWorkflow.ts index 9f63eb1..2b1c9fc 100644 --- a/src/server/workflows/SiteAuditWorkflow.ts +++ b/src/server/workflows/SiteAuditWorkflow.ts @@ -12,6 +12,7 @@ import { import type { BillingCustomerContext } from "@/server/billing/subscription"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import type { AuditConfig } from "@/server/lib/audit/types"; +import { captureServerEvent } from "@/server/lib/posthog"; import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases"; interface AuditParams { @@ -53,6 +54,24 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint { console.error(`Audit ${auditId} failed:`, error); await step.do("mark-failed", async () => { await AuditRepository.failAudit(auditId, event.instanceId); + + const latestAudit = await AuditRepository.getAuditForWorkflow( + auditId, + event.instanceId, + ); + + await captureServerEvent({ + distinctId: billingCustomer.userId, + event: "site_audit:complete", + organizationId: billingCustomer.organizationId, + properties: { + project_id: projectId, + status: "failed", + pages_crawled: latestAudit?.pagesCrawled, + pages_total: latestAudit?.pagesTotal, + run_lighthouse: config.lighthouseStrategy !== "none", + }, + }); }); throw error; } diff --git a/src/server/workflows/siteAuditWorkflowPhases.ts b/src/server/workflows/siteAuditWorkflowPhases.ts index 0b39715..618f325 100644 --- a/src/server/workflows/siteAuditWorkflowPhases.ts +++ b/src/server/workflows/siteAuditWorkflowPhases.ts @@ -13,6 +13,7 @@ import type { LighthouseResult, StepPageResult, } from "@/server/lib/audit/types"; +import { captureServerEvent } from "@/server/lib/posthog"; import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl"; const LIGHTHOUSE_URL_BATCH_SIZE = 10; @@ -83,13 +84,16 @@ export async function runAuditPhases( config, allPages, }); - await finalizeAudit( + await finalizeAudit({ step, auditId, workflowInstanceId, + billingCustomer, + projectId, + config, allPages, lighthouseResults, - ); + }); } async function runDiscoveryPhase( @@ -249,13 +253,27 @@ async function runLighthouseBatch(params: { }); } -async function finalizeAudit( - step: WorkflowStep, - auditId: string, - workflowInstanceId: string, - allPages: StepPageResult[], - lighthouseResults: LighthouseResult[], -) { +async function finalizeAudit(args: { + step: WorkflowStep; + auditId: string; + workflowInstanceId: string; + billingCustomer: BillingCustomerContext; + projectId: string; + config: AuditConfig; + allPages: StepPageResult[]; + lighthouseResults: LighthouseResult[]; +}) { + const { + step, + auditId, + workflowInstanceId, + billingCustomer, + projectId, + config, + allPages, + lighthouseResults, + } = args; + await step.do("finalize", async () => { await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { currentPhase: "finalizing", @@ -269,6 +287,18 @@ async function finalizeAudit( pagesCrawled: allPages.length, pagesTotal: allPages.length, }); + await captureServerEvent({ + distinctId: billingCustomer.userId, + event: "site_audit:complete", + organizationId: billingCustomer.organizationId, + properties: { + project_id: projectId, + status: "completed", + pages_crawled: allPages.length, + pages_total: allPages.length, + run_lighthouse: config.lighthouseStrategy !== "none", + }, + }); await AuditProgressKV.clear(auditId); }); } diff --git a/src/serverFunctions/audit.ts b/src/serverFunctions/audit.ts index 92627a9..da133c2 100644 --- a/src/serverFunctions/audit.ts +++ b/src/serverFunctions/audit.ts @@ -1,5 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; +import { waitUntil } from "cloudflare:workers"; import { AuditService } from "@/server/features/audit/services/AuditService"; +import { captureServerEvent } from "@/server/lib/posthog"; import { requireProjectContext } from "@/serverFunctions/middleware"; import { deleteAuditSchema, @@ -14,51 +16,63 @@ export const startAudit = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => startAuditSchema.parse(data)) .handler(async ({ data, context }) => { - return AuditService.startAudit({ + const result = await AuditService.startAudit({ actorUserId: context.userId, - billingCustomer: { - organizationId: context.organizationId, - userEmail: context.userEmail, - }, - projectId: context.project.id, + billingCustomer: context, + projectId: context.projectId, startUrl: data.startUrl, maxPages: data.maxPages, lighthouseStrategy: data.lighthouseStrategy, }); + + waitUntil( + captureServerEvent({ + distinctId: context.userId, + event: "site_audit:start", + organizationId: context.organizationId, + properties: { + project_id: context.projectId, + max_pages: data.maxPages ?? 50, + run_lighthouse: data.lighthouseStrategy !== "none", + }, + }), + ); + + return result; }); export const getAuditStatus = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => getAuditStatusSchema.parse(data)) .handler(async ({ data, context }) => { - return AuditService.getStatus(data.auditId, context.project.id); + return AuditService.getStatus(data.auditId, context.projectId); }); export const getAuditResults = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => getAuditResultsSchema.parse(data)) .handler(async ({ data, context }) => { - return AuditService.getResults(data.auditId, context.project.id); + return AuditService.getResults(data.auditId, context.projectId); }); export const getAuditHistory = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => getAuditHistorySchema.parse(data)) .handler(async ({ context }) => { - return AuditService.getHistory(context.project.id); + return AuditService.getHistory(context.projectId); }); export const getCrawlProgress = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => getCrawlProgressSchema.parse(data)) .handler(async ({ data, context }) => { - return AuditService.getCrawlProgress(data.auditId, context.project.id); + return AuditService.getCrawlProgress(data.auditId, context.projectId); }); export const deleteAudit = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => deleteAuditSchema.parse(data)) .handler(async ({ data, context }) => { - await AuditService.remove(data.auditId, context.project.id); + await AuditService.remove(data.auditId, context.projectId); return { success: true }; }); diff --git a/src/serverFunctions/backlinks.ts b/src/serverFunctions/backlinks.ts index 47daef9..fa1fe11 100644 --- a/src/serverFunctions/backlinks.ts +++ b/src/serverFunctions/backlinks.ts @@ -19,10 +19,7 @@ export const getBacklinksOverview = createServerFn({ target: data.target, scope: data.scope, }; - const profile = await BacklinksService.profileOverview(input, { - organizationId: context.organizationId, - userEmail: context.userEmail, - }); + const profile = await BacklinksService.profileOverview(input, context); return profile.overview; } catch (error) { if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") { @@ -47,10 +44,10 @@ export const getBacklinksReferringDomains = createServerFn({ target: data.target, scope: data.scope, }; - const profile = await BacklinksService.profileReferringDomains(input, { - organizationId: context.organizationId, - userEmail: context.userEmail, - }); + const profile = await BacklinksService.profileReferringDomains( + input, + context, + ); return profile.rows; } catch (error) { await updateBacklinksAccessStatusOnError(error); @@ -69,10 +66,7 @@ export const getBacklinksTopPages = createServerFn({ target: data.target, scope: data.scope, }; - const profile = await BacklinksService.profileTopPages(input, { - organizationId: context.organizationId, - userEmail: context.userEmail, - }); + const profile = await BacklinksService.profileTopPages(input, context); return profile.rows; } catch (error) { await updateBacklinksAccessStatusOnError(error); diff --git a/src/serverFunctions/backlinksAccess.ts b/src/serverFunctions/backlinksAccess.ts index d869b9b..95b3c6d 100644 --- a/src/serverFunctions/backlinksAccess.ts +++ b/src/serverFunctions/backlinksAccess.ts @@ -38,10 +38,7 @@ export const testBacklinksAccess = createServerFn({ } const checkedAt = new Date().toISOString(); - const dataforseo = createDataforseoClient({ - organizationId: context.organizationId, - userEmail: context.userEmail, - }); + const dataforseo = createDataforseoClient(context); try { await dataforseo.backlinks.summary({ diff --git a/src/serverFunctions/domain.ts b/src/serverFunctions/domain.ts index 64de032..c54f4c9 100644 --- a/src/serverFunctions/domain.ts +++ b/src/serverFunctions/domain.ts @@ -10,11 +10,8 @@ export const getDomainOverview = createServerFn({ method: "POST" }) DomainService.getOverview( { ...data, - projectId: context.project.id, - }, - { - organizationId: context.organizationId, - userEmail: context.userEmail, + projectId: context.projectId, }, + context, ), ); diff --git a/src/serverFunctions/keywords.ts b/src/serverFunctions/keywords.ts index 5a365ca..a034afc 100644 --- a/src/serverFunctions/keywords.ts +++ b/src/serverFunctions/keywords.ts @@ -16,12 +16,9 @@ export const researchKeywords = createServerFn({ method: "POST" }) return KeywordResearchService.research( { ...data, - projectId: context.project.id, - }, - { - organizationId: context.organizationId, - userEmail: context.userEmail, + projectId: context.projectId, }, + context, ); }); @@ -31,7 +28,7 @@ export const saveKeywords = createServerFn({ method: "POST" }) .handler(async ({ data, context }) => { return KeywordResearchService.saveKeywords({ ...data, - projectId: context.project.id, + projectId: context.projectId, }); }); @@ -41,7 +38,7 @@ export const getSavedKeywords = createServerFn({ method: "POST" }) .handler(async ({ data, context }) => { return KeywordResearchService.getSavedKeywords({ ...data, - projectId: context.project.id, + projectId: context.projectId, }); }); @@ -51,7 +48,7 @@ export const removeSavedKeyword = createServerFn({ .middleware(requireProjectContext) .inputValidator((data: unknown) => removeSavedKeywordSchema.parse(data)) .handler(async ({ data, context }) => { - return KeywordResearchService.removeSavedKeyword(context.project.id, data); + return KeywordResearchService.removeSavedKeyword(context.projectId, data); }); export const getSerpAnalysis = createServerFn({ method: "POST" }) @@ -61,11 +58,8 @@ export const getSerpAnalysis = createServerFn({ method: "POST" }) KeywordResearchService.getSerpAnalysis( { ...data, - projectId: context.project.id, - }, - { - organizationId: context.organizationId, - userEmail: context.userEmail, + projectId: context.projectId, }, + context, ), ); diff --git a/src/serverFunctions/lighthouse.ts b/src/serverFunctions/lighthouse.ts index 107c99b..f73e3b9 100644 --- a/src/serverFunctions/lighthouse.ts +++ b/src/serverFunctions/lighthouse.ts @@ -48,7 +48,7 @@ export const getAuditLighthouseIssues = createServerFn({ method: "POST" }) .inputValidator((data: unknown) => lighthouseAuditIssueSchema.parse(data)) .handler(async ({ data, context }) => { const lighthouse = await getAuditLighthouseData({ - projectId: context.project.id, + projectId: context.projectId, resultId: data.resultId, }); @@ -71,7 +71,7 @@ export const exportAuditLighthouseIssues = createServerFn({ method: "POST" }) .inputValidator((data: unknown) => lighthouseAuditExportSchema.parse(data)) .handler(async ({ data, context }) => { const lighthouse = await getAuditLighthouseData({ - projectId: context.project.id, + projectId: context.projectId, resultId: data.resultId, }); diff --git a/src/serverFunctions/middleware.ts b/src/serverFunctions/middleware.ts index f96b543..5ee8434 100644 --- a/src/serverFunctions/middleware.ts +++ b/src/serverFunctions/middleware.ts @@ -1,40 +1,27 @@ import { createMiddleware } from "@tanstack/react-start"; +import { z } from "zod"; import { AppError } from "@/server/lib/errors"; import { errorHandlingMiddleware } from "@/middleware/errorHandling"; import type { EnsuredUserContext } from "@/middleware/ensure-user/types"; import { ensureUserMiddleware } from "@/middleware/ensureUser"; import { requireManagedServiceAccess } from "@/server/billing/subscription"; -type AuthenticatedServerFunctionContext = EnsuredUserContext; +const ensuredUserContextSchema: z.ZodType = z.object({ + userId: z.string(), + userEmail: z.string(), + organizationId: z.string(), + project: z.any().optional(), +}); -function getAuthenticatedContext( - context: unknown, -): AuthenticatedServerFunctionContext { - if (!isAuthenticatedServerFunctionContext(context)) { +function getAuthenticatedContext(context: unknown): EnsuredUserContext { + const result = ensuredUserContextSchema.safeParse(context); + if (!result.success) { throw new AppError( "INTERNAL_ERROR", "Authenticated server function context missing", ); } - - return context; -} - -function isAuthenticatedServerFunctionContext( - context: unknown, -): context is AuthenticatedServerFunctionContext { - if (!context || typeof context !== "object") { - return false; - } - - return ( - "userId" in context && - typeof context.userId === "string" && - "userEmail" in context && - typeof context.userEmail === "string" && - "organizationId" in context && - typeof context.organizationId === "string" - ); + return result.data; } export const globalServerFunctionMiddleware = [ @@ -70,6 +57,7 @@ export const requireProjectContext = [ context: { ...authenticatedContext, project: authenticatedContext.project, + projectId: authenticatedContext.project.id, }, }); }),