hosted: add product analytics (#83)
* 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<EnsuredUserContext> 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
This commit is contained in:
parent
f3ef909d3e
commit
ad3b732f60
@ -105,12 +105,9 @@ function buildBillingCustomer(
|
||||
cliArgs: Record<string, string>,
|
||||
): 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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 = (
|
||||
<div className={mobileOnly ? "ml-2 flex-none md:hidden" : "flex-none"}>
|
||||
|
||||
@ -22,17 +22,33 @@ function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
|
||||
.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<string, unknown>,
|
||||
) {
|
||||
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<string, string | null | undefined> = {},
|
||||
) {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
12
src/lib/auth-session.ts
Normal file
12
src/lib/auth-session.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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<string | null>(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;
|
||||
}
|
||||
|
||||
@ -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<string | null>(
|
||||
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}
|
||||
<button
|
||||
className="btn btn-soft w-full"
|
||||
disabled={!isHostedMode || isSessionPending || isSubmitting}
|
||||
disabled={!isHostedMode || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
|
||||
@ -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 ? (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
@ -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}
|
||||
<button
|
||||
className="btn btn-soft w-full"
|
||||
disabled={!isHostedMode || isSessionPending || isSubmitting}
|
||||
disabled={!isHostedMode || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Creating account..." : "Create account"}
|
||||
</button>
|
||||
|
||||
@ -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 (
|
||||
<div className="fixed top-4 right-4">
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<T>(
|
||||
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 };
|
||||
|
||||
@ -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<string, string | null | undefined> = {},
|
||||
@ -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<string, unknown>;
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Env, AuditParams> {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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<EnsuredUserContext> = 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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user