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>,
|
cliArgs: Record<string, string>,
|
||||||
): BillingCustomerContext {
|
): BillingCustomerContext {
|
||||||
return {
|
return {
|
||||||
organizationId:
|
organizationId: cliArgs.organizationId ?? "local",
|
||||||
cliArgs.organizationId ?? process.env.BILLING_ORGANIZATION_ID ?? "local",
|
userId: cliArgs.userId ?? "local-user",
|
||||||
userEmail:
|
userEmail: cliArgs.userEmail ?? "local@example.com",
|
||||||
cliArgs.userEmail ??
|
|
||||||
process.env.BILLING_USER_EMAIL ??
|
|
||||||
"local@example.com",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
||||||
import { useSession } from "@/lib/auth-client";
|
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import {
|
import {
|
||||||
getFieldError as getSharedFieldError,
|
getFieldError as getSharedFieldError,
|
||||||
@ -13,13 +12,11 @@ export const authRedirectSearchSchema = z.object({
|
|||||||
|
|
||||||
export function useAuthPageState(redirect: string | undefined) {
|
export function useAuthPageState(redirect: string | undefined) {
|
||||||
const redirectTo = normalizeAuthRedirect(redirect);
|
const redirectTo = normalizeAuthRedirect(redirect);
|
||||||
const { isPending: isSessionPending } = useSession();
|
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
const isHostedMode = isHostedClientAuthMode();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirectTo,
|
redirectTo,
|
||||||
isHostedMode,
|
isHostedMode,
|
||||||
isSessionPending,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,14 +54,16 @@ export function BacklinksSearchCard({
|
|||||||
},
|
},
|
||||||
onSubmit: ({ value }) => {
|
onSubmit: ({ value }) => {
|
||||||
const target = value.target.trim();
|
const target = value.target.trim();
|
||||||
onSubmit({
|
const scope = resolveBacklinksSearchScope({
|
||||||
...value,
|
|
||||||
target,
|
|
||||||
scope: resolveBacklinksSearchScope({
|
|
||||||
target,
|
target,
|
||||||
selectedScope: value.scope,
|
selectedScope: value.scope,
|
||||||
userSelectedScope,
|
userSelectedScope,
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
...value,
|
||||||
|
target,
|
||||||
|
scope,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
keywordsToCsv,
|
keywordsToCsv,
|
||||||
pagesToCsv,
|
pagesToCsv,
|
||||||
} from "@/client/features/domain/utils";
|
} from "@/client/features/domain/utils";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import type {
|
import type {
|
||||||
DomainActiveTab,
|
DomainActiveTab,
|
||||||
DomainOverviewData,
|
DomainOverviewData,
|
||||||
@ -87,6 +88,13 @@ export function DomainResultsCard({
|
|||||||
? keywordsToCsv(filteredKeywords)
|
? keywordsToCsv(filteredKeywords)
|
||||||
: pagesToCsv(filteredPages);
|
: pagesToCsv(filteredPages);
|
||||||
downloadCsv(rows, `${overview.domain}-${activeTab}.${extension}`);
|
downloadCsv(rows, `${overview.domain}-${activeTab}.${extension}`);
|
||||||
|
|
||||||
|
if (extension === "csv") {
|
||||||
|
captureClientEvent("data:export", {
|
||||||
|
source_feature: "domain_overview",
|
||||||
|
result_count: currentRows.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isKeywordsTab = activeTab === "keywords";
|
const isKeywordsTab = activeTab === "keywords";
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import type { DomainOverviewData } from "@/client/features/domain/types";
|
import type { DomainOverviewData } from "@/client/features/domain/types";
|
||||||
|
|
||||||
type SaveMutation = (payload: {
|
type SaveMutation = (payload: {
|
||||||
@ -54,6 +55,10 @@ export function saveSelectedKeywords({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
captureClientEvent("keyword:save", {
|
||||||
|
source_feature: "domain_overview",
|
||||||
|
keyword_count: selectedKeywords.size,
|
||||||
|
});
|
||||||
toast.success(`Saved ${selectedKeywords.size} keywords`);
|
toast.success(`Saved ${selectedKeywords.size} keywords`);
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { sortBy } from "remeda";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getDomainOverview } from "@/serverFunctions/domain";
|
import { getDomainOverview } from "@/serverFunctions/domain";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { filterAndSortKeywords } from "@/client/features/domain/domainFiltering";
|
import { filterAndSortKeywords } from "@/client/features/domain/domainFiltering";
|
||||||
import {
|
import {
|
||||||
getDefaultSortOrder,
|
getDefaultSortOrder,
|
||||||
@ -293,6 +294,12 @@ export function useSearchRunner({
|
|||||||
languageCode: "en",
|
languageCode: "en",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
captureClientEvent("domain_overview:search_complete", {
|
||||||
|
sort_mode: activeSort,
|
||||||
|
include_subdomains: activeSubdomains,
|
||||||
|
result_count: response.keywords.length,
|
||||||
|
});
|
||||||
|
|
||||||
setOverview(response);
|
setOverview(response);
|
||||||
setSelectedKeywords(new Set());
|
setSelectedKeywords(new Set());
|
||||||
addSearch({
|
addSearch({
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
|
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
|
||||||
import { researchKeywords } from "@/serverFunctions/keywords";
|
import { researchKeywords } from "@/serverFunctions/keywords";
|
||||||
import type {
|
import type {
|
||||||
@ -75,11 +76,19 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
|
const resultCount = result.rows.length;
|
||||||
|
|
||||||
setResearchError(null);
|
setResearchError(null);
|
||||||
setRows(result.rows);
|
setRows(result.rows);
|
||||||
setLastResultSource(result.source);
|
setLastResultSource(result.source);
|
||||||
setLastUsedFallback(result.usedFallback);
|
setLastUsedFallback(result.usedFallback);
|
||||||
|
|
||||||
|
captureClientEvent("keyword_research:search_complete", {
|
||||||
|
location_code: input.locationCode,
|
||||||
|
search_mode: input.mode,
|
||||||
|
result_count: resultCount,
|
||||||
|
});
|
||||||
|
|
||||||
if (seedKeyword) {
|
if (seedKeyword) {
|
||||||
addSearch(
|
addSearch(
|
||||||
seedKeyword,
|
seedKeyword,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { getLanguageCode } from "@/client/features/keywords/utils";
|
import { getLanguageCode } from "@/client/features/keywords/utils";
|
||||||
import type { KeywordResearchRow } from "@/types/keywords";
|
import type { KeywordResearchRow } from "@/types/keywords";
|
||||||
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
||||||
@ -74,6 +75,10 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
captureClientEvent("keyword:save", {
|
||||||
|
source_feature: "keyword_research",
|
||||||
|
keyword_count: selectedRows.size,
|
||||||
|
});
|
||||||
toast.success(`Saved ${selectedRows.size} keywords`);
|
toast.success(`Saved ${selectedRows.size} keywords`);
|
||||||
setShowSaveDialog(false);
|
setShowSaveDialog(false);
|
||||||
},
|
},
|
||||||
@ -111,6 +116,10 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
|
|||||||
]);
|
]);
|
||||||
const csv = buildCsv(headers, csvRows);
|
const csv = buildCsv(headers, csvRows);
|
||||||
downloadCsv("keyword-research.csv", csv);
|
downloadCsv("keyword-research.csv", csv);
|
||||||
|
captureClientEvent("data:export", {
|
||||||
|
source_feature: "keyword_research",
|
||||||
|
result_count: source.length,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return { handleSaveKeywords, confirmSave, exportCsv };
|
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 { useKeywordResearchData } from "@/client/features/keywords/hooks/useKeywordResearchData";
|
||||||
import { useKeywordSelection } from "@/client/features/keywords/hooks/useKeywordSelection";
|
import { useKeywordSelection } from "@/client/features/keywords/hooks/useKeywordSelection";
|
||||||
import { useKeywordSerpAnalysis } from "@/client/features/keywords/hooks/useKeywordSerpAnalysis";
|
import { useKeywordSerpAnalysis } from "@/client/features/keywords/hooks/useKeywordSerpAnalysis";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { useSearchHistory } from "@/client/hooks/useSearchHistory";
|
import { useSearchHistory } from "@/client/hooks/useSearchHistory";
|
||||||
import {
|
import {
|
||||||
type KeywordMode,
|
type KeywordMode,
|
||||||
@ -88,6 +89,7 @@ export function useKeywordResearchController(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRowClick = (row: KeywordResearchRow) => {
|
const handleRowClick = (row: KeywordResearchRow) => {
|
||||||
|
captureClientEvent("keyword_research:serp_open");
|
||||||
state.setSelectedKeyword(row);
|
state.setSelectedKeyword(row);
|
||||||
state.setSerpKeyword(row.keyword);
|
state.setSerpKeyword(row.keyword);
|
||||||
state.setSerpPage(0);
|
state.setSerpPage(0);
|
||||||
|
|||||||
@ -14,8 +14,7 @@ import {
|
|||||||
} from "@/client/layout/AppShellParts";
|
} from "@/client/layout/AppShellParts";
|
||||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||||
import { getProjectNavItems } from "@/client/navigation/items";
|
import { getProjectNavItems } from "@/client/navigation/items";
|
||||||
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||||
import { authClient, useSession } from "@/lib/auth-client";
|
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { BILLING_ROUTE } from "@/shared/billing";
|
import { BILLING_ROUTE } from "@/shared/billing";
|
||||||
import { getSeoApiKeyStatus } from "@/serverFunctions/config";
|
import { getSeoApiKeyStatus } from "@/serverFunctions/config";
|
||||||
@ -244,16 +243,7 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
|
|||||||
const isHostedMode = isHostedClientAuthMode();
|
const isHostedMode = isHostedClientAuthMode();
|
||||||
const email = session?.user?.email;
|
const email = session?.user?.email;
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => signOutAndRedirect();
|
||||||
const signInHref = getSignInHrefForLocation(window.location);
|
|
||||||
void authClient.signOut({
|
|
||||||
fetchOptions: {
|
|
||||||
onSuccess: () => {
|
|
||||||
window.location.assign(signInHref);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const menu = (
|
const menu = (
|
||||||
<div className={mobileOnly ? "ml-2 flex-none md:hidden" : "flex-none"}>
|
<div className={mobileOnly ? "ml-2 flex-none md:hidden" : "flex-none"}>
|
||||||
|
|||||||
@ -22,17 +22,33 @@ function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
|
|||||||
.then((module) => {
|
.then((module) => {
|
||||||
const client = module.default;
|
const client = module.default;
|
||||||
const apiKey = import.meta.env.POSTHOG_PUBLIC_KEY?.trim();
|
const apiKey = import.meta.env.POSTHOG_PUBLIC_KEY?.trim();
|
||||||
|
const host = import.meta.env.POSTHOG_HOST?.trim();
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey || !host) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!browserPostHogInitialized) {
|
if (!browserPostHogInitialized) {
|
||||||
client.init(apiKey, {
|
client.init(apiKey, {
|
||||||
api_host:
|
api_host: host,
|
||||||
import.meta.env.POSTHOG_HOST?.trim() || "https://us.i.posthog.com",
|
|
||||||
defaults: "2026-01-30",
|
defaults: "2026-01-30",
|
||||||
capture_exceptions: true,
|
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;
|
browserPostHogInitialized = true;
|
||||||
}
|
}
|
||||||
@ -51,22 +67,48 @@ export function initPostHog() {
|
|||||||
void getBrowserPostHogClient();
|
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(
|
export function captureClientError(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
properties: Record<string, string | null | undefined> = {},
|
properties: Record<string, string | null | undefined> = {},
|
||||||
) {
|
) {
|
||||||
void getBrowserPostHogClient().then((client) => {
|
withPostHogClient((client) =>
|
||||||
if (!client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
client.captureException(error, {
|
client.captureException(error, {
|
||||||
source: "client",
|
source: "client",
|
||||||
...properties,
|
...properties,
|
||||||
});
|
}),
|
||||||
} catch (e) {
|
);
|
||||||
console.error("posthog capture failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
import { organizationClient } from "better-auth/client/plugins";
|
import { organizationClient } from "better-auth/client/plugins";
|
||||||
|
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
|
||||||
|
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
||||||
@ -7,3 +9,16 @@ export const authClient = createAuthClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const { useSession } = authClient;
|
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 { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
||||||
|
import { getActiveOrganizationId } from "@/lib/auth-session";
|
||||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
import type { EnsuredUserContext } from "./types";
|
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) {
|
async function requireHostedSession(headers: Headers) {
|
||||||
if (!hasHostedAuthConfig()) {
|
if (!hasHostedAuthConfig()) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
|
|||||||
@ -12,12 +12,18 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
|
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
|
||||||
import { themePreferenceInitScript } from "@/client/lib/theme";
|
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 { NotFound } from "@/client/components/NotFound";
|
||||||
import appCss from "@/client/styles/app.css?url";
|
import appCss from "@/client/styles/app.css?url";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { queryClient } from "@/client/tanstack-db";
|
import { queryClient } from "@/client/tanstack-db";
|
||||||
|
import { getActiveOrganizationId } from "@/lib/auth-session";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
@ -77,14 +83,26 @@ function AppLayout() {
|
|||||||
|
|
||||||
function PostHogBootstrap() {
|
function PostHogBootstrap() {
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!isHostedMode) {
|
if (!isHostedMode || isSessionPending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
initPostHog();
|
initPostHog();
|
||||||
}, [isHostedMode]);
|
|
||||||
|
if (userId) {
|
||||||
|
identifyAnalyticsUser({ userId, organizationId });
|
||||||
|
previousUserIdRef.current = userId;
|
||||||
|
} else if (previousUserIdRef.current) {
|
||||||
|
previousUserIdRef.current = null;
|
||||||
|
resetAnalyticsUser();
|
||||||
|
}
|
||||||
|
}, [isHostedMode, isSessionPending, organizationId, userId]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
getFormError,
|
getFormError,
|
||||||
useAuthPageState,
|
useAuthPageState,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { getSignInSearch } from "@/lib/auth-redirect";
|
import { getSignInSearch } from "@/lib/auth-redirect";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -25,9 +26,7 @@ export const Route = createFileRoute("/_auth/sign-in")({
|
|||||||
|
|
||||||
function SignInPage() {
|
function SignInPage() {
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
|
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
|
||||||
search.redirect,
|
|
||||||
);
|
|
||||||
const [verificationEmail, setVerificationEmail] = useState<string | null>(
|
const [verificationEmail, setVerificationEmail] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@ -44,6 +43,9 @@ function SignInPage() {
|
|||||||
onSubmit: async ({ formApi, value }) => {
|
onSubmit: async ({ formApi, value }) => {
|
||||||
try {
|
try {
|
||||||
const email = value.email.trim();
|
const email = value.email.trim();
|
||||||
|
captureClientEvent("auth:sign_in_submit", {
|
||||||
|
redirect_to: redirectTo,
|
||||||
|
});
|
||||||
setVerificationEmail(null);
|
setVerificationEmail(null);
|
||||||
|
|
||||||
const result = await authClient.signIn.email({
|
const result = await authClient.signIn.email({
|
||||||
@ -53,10 +55,16 @@ function SignInPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.error) {
|
if (!result.error) {
|
||||||
|
captureClientEvent("auth:sign_in_success", {
|
||||||
|
redirect_to: redirectTo,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.error.status === 403) {
|
if (result.error.status === 403) {
|
||||||
|
captureClientEvent("auth:sign_in_block_unverified", {
|
||||||
|
redirect_to: redirectTo,
|
||||||
|
});
|
||||||
setVerificationEmail(email);
|
setVerificationEmail(email);
|
||||||
formApi.setErrorMap({
|
formApi.setErrorMap({
|
||||||
onSubmit: {
|
onSubmit: {
|
||||||
@ -105,6 +113,7 @@ function SignInPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
captureClientEvent("auth:verification_resend");
|
||||||
toast.success("A new email is on the way.");
|
toast.success("A new email is on the way.");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(
|
toast.error(
|
||||||
@ -159,7 +168,7 @@ function SignInPage() {
|
|||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={!isHostedMode || isSessionPending}
|
disabled={!isHostedMode}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{error ? (
|
{error ? (
|
||||||
@ -183,7 +192,7 @@ function SignInPage() {
|
|||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={!isHostedMode || isSessionPending}
|
disabled={!isHostedMode}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{error ? (
|
{error ? (
|
||||||
@ -232,7 +241,7 @@ function SignInPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="btn btn-soft w-full"
|
className="btn btn-soft w-full"
|
||||||
disabled={!isHostedMode || isSessionPending || isSubmitting}
|
disabled={!isHostedMode || isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
getFormError,
|
getFormError,
|
||||||
useAuthPageState,
|
useAuthPageState,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { getSignInSearch } from "@/lib/auth-redirect";
|
import { getSignInSearch } from "@/lib/auth-redirect";
|
||||||
import {
|
import {
|
||||||
@ -44,9 +45,7 @@ export const Route = createFileRoute("/_auth/sign-up")({
|
|||||||
function SignUpPage() {
|
function SignUpPage() {
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { redirectTo, isHostedMode, isSessionPending } = useAuthPageState(
|
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
|
||||||
search.redirect,
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -61,6 +60,9 @@ function SignUpPage() {
|
|||||||
onSubmit: async ({ formApi, value }) => {
|
onSubmit: async ({ formApi, value }) => {
|
||||||
try {
|
try {
|
||||||
const email = value.email.trim();
|
const email = value.email.trim();
|
||||||
|
captureClientEvent("auth:sign_up_submit", {
|
||||||
|
redirect_to: redirectTo,
|
||||||
|
});
|
||||||
const resolvedName =
|
const resolvedName =
|
||||||
value.name.trim() || email.split("@")[0] || "OpenSEO User";
|
value.name.trim() || email.split("@")[0] || "OpenSEO User";
|
||||||
const result = await authClient.signUp.email({
|
const result = await authClient.signUp.email({
|
||||||
@ -85,6 +87,9 @@ function SignUpPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
captureClientEvent("auth:sign_up_success", {
|
||||||
|
redirect_to: redirectTo,
|
||||||
|
});
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/verify-email",
|
to: "/verify-email",
|
||||||
search: { email, ...getSignInSearch(redirectTo) },
|
search: { email, ...getSignInSearch(redirectTo) },
|
||||||
@ -162,7 +167,7 @@ function SignUpPage() {
|
|||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
disabled={!isHostedMode || isSessionPending}
|
disabled={!isHostedMode}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="mt-1 text-sm text-error">{error}</p>
|
<p className="mt-1 text-sm text-error">{error}</p>
|
||||||
@ -185,7 +190,7 @@ function SignUpPage() {
|
|||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={!isHostedMode || isSessionPending}
|
disabled={!isHostedMode}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{error ? (
|
{error ? (
|
||||||
@ -209,7 +214,7 @@ function SignUpPage() {
|
|||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={!isHostedMode || isSessionPending}
|
disabled={!isHostedMode}
|
||||||
required
|
required
|
||||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||||
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
||||||
@ -235,7 +240,7 @@ function SignUpPage() {
|
|||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={!isHostedMode || isSessionPending}
|
disabled={!isHostedMode}
|
||||||
required
|
required
|
||||||
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
minLength={HOSTED_PASSWORD_MIN_LENGTH}
|
||||||
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
|
||||||
@ -263,7 +268,7 @@ function SignUpPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="btn btn-soft w-full"
|
className="btn btn-soft w-full"
|
||||||
disabled={!isHostedMode || isSessionPending || isSubmitting}
|
disabled={!isHostedMode || isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Creating account..." : "Create account"}
|
{isSubmitting ? "Creating account..." : "Create account"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { AutumnProvider, useCustomer } from "autumn-js/react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { User } from "lucide-react";
|
import { User } from "lucide-react";
|
||||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||||
import { authClient, useSession } from "@/lib/auth-client";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
||||||
import {
|
import {
|
||||||
@ -96,6 +96,7 @@ function SubscribePageContent() {
|
|||||||
setIsAttaching(true);
|
setIsAttaching(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
captureClientEvent("billing:checkout_start");
|
||||||
await customerQuery.attach({
|
await customerQuery.attach({
|
||||||
planId: AUTUMN_PAID_PLAN_ID,
|
planId: AUTUMN_PAID_PLAN_ID,
|
||||||
redirectMode: "always",
|
redirectMode: "always",
|
||||||
@ -168,16 +169,7 @@ function SubscribePageContent() {
|
|||||||
function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
|
function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
|
||||||
if (!email) return null;
|
if (!email) return null;
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => signOutAndRedirect();
|
||||||
const signInHref = getSignInHrefForLocation(window.location);
|
|
||||||
void authClient.signOut({
|
|
||||||
fetchOptions: {
|
|
||||||
onSuccess: () => {
|
|
||||||
window.location.assign(signInHref);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-4 right-4">
|
<div className="fixed top-4 right-4">
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
import { Trash2, Download, Search, Loader2, AlertCircle } from "lucide-react";
|
import { Trash2, Download, Search, Loader2, AlertCircle } from "lucide-react";
|
||||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_project/p/$projectId/saved")({
|
export const Route = createFileRoute("/_project/p/$projectId/saved")({
|
||||||
component: SavedKeywordsPage,
|
component: SavedKeywordsPage,
|
||||||
@ -33,6 +34,7 @@ function SavedKeywordsPage() {
|
|||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["savedKeywords", projectId],
|
queryKey: ["savedKeywords", projectId],
|
||||||
});
|
});
|
||||||
|
captureClientEvent("saved_keywords:remove");
|
||||||
toast.success("Keyword removed");
|
toast.success("Keyword removed");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@ -78,6 +80,10 @@ function SavedKeywordsPage() {
|
|||||||
]);
|
]);
|
||||||
const csv = buildCsv(headers, csvRows);
|
const csv = buildCsv(headers, csvRows);
|
||||||
downloadCsv("saved-keywords.csv", csv);
|
downloadCsv("saved-keywords.csv", csv);
|
||||||
|
captureClientEvent("data:export", {
|
||||||
|
source_feature: "saved_keywords",
|
||||||
|
result_count: savedKeywords.length,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -6,11 +6,16 @@ import {
|
|||||||
AuthPageShell,
|
AuthPageShell,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { authClient, useSession } from "@/lib/auth-client";
|
import { authClient, useSession } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect";
|
import { getSignInSearch, normalizeAuthRedirect } from "@/lib/auth-redirect";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const verificationIssueSchema = z
|
||||||
|
.enum(["invalid_token", "token_expired", "user_not_found", "unknown"])
|
||||||
|
.catch("unknown");
|
||||||
|
|
||||||
const verifyEmailSearchSchema = authRedirectSearchSchema.extend({
|
const verifyEmailSearchSchema = authRedirectSearchSchema.extend({
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
@ -98,6 +103,9 @@ function VerifyEmailPage() {
|
|||||||
const isHostedMode = isHostedClientAuthMode();
|
const isHostedMode = isHostedClientAuthMode();
|
||||||
const { data: session, isPending } = useSession();
|
const { data: session, isPending } = useSession();
|
||||||
const errorMessage = getVerificationErrorMessage(search.error);
|
const errorMessage = getVerificationErrorMessage(search.error);
|
||||||
|
const verificationIssueType = search.error
|
||||||
|
? verificationIssueSchema.parse(search.error)
|
||||||
|
: null;
|
||||||
const email = search.email;
|
const email = search.email;
|
||||||
const isWaiting = !errorMessage && !session?.user?.emailVerified && !!email;
|
const isWaiting = !errorMessage && !session?.user?.emailVerified && !!email;
|
||||||
const [isResending, setIsResending] = useState(false);
|
const [isResending, setIsResending] = useState(false);
|
||||||
@ -116,6 +124,10 @@ function VerifyEmailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
captureClientEvent("auth:verification_success", {
|
||||||
|
redirect_to: redirectTo,
|
||||||
|
});
|
||||||
|
|
||||||
// Full page reload instead of client-side navigation: the auth→app
|
// Full page reload instead of client-side navigation: the auth→app
|
||||||
// transition needs a clean server-side load so that all server function
|
// transition needs a clean server-side load so that all server function
|
||||||
// handlers are freshly registered (client-side nav during Vite HMR can
|
// handlers are freshly registered (client-side nav during Vite HMR can
|
||||||
@ -124,6 +136,16 @@ function VerifyEmailPage() {
|
|||||||
window.location.replace(redirectTo);
|
window.location.replace(redirectTo);
|
||||||
}, [isVerified, redirectTo]);
|
}, [isVerified, redirectTo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!verificationIssueType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
captureClientEvent("auth:verification_issue", {
|
||||||
|
issue_type: verificationIssueType,
|
||||||
|
});
|
||||||
|
}, [verificationIssueType]);
|
||||||
|
|
||||||
async function handleResend() {
|
async function handleResend() {
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
setIsResending(true);
|
setIsResending(true);
|
||||||
@ -139,6 +161,7 @@ function VerifyEmailPage() {
|
|||||||
toast.error(result.error.message || "We couldn't send another email.");
|
toast.error(result.error.message || "We couldn't send another email.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
captureClientEvent("auth:verification_resend");
|
||||||
toast.success("A new email is on the way.");
|
toast.success("A new email is on the way.");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(
|
toast.error(
|
||||||
|
|||||||
@ -52,6 +52,7 @@ describe("subscription billing", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
requireManagedServiceAccess({
|
requireManagedServiceAccess({
|
||||||
organizationId: "org_123",
|
organizationId: "org_123",
|
||||||
|
userId: "user_123",
|
||||||
userEmail: "alice@example.com",
|
userEmail: "alice@example.com",
|
||||||
}),
|
}),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
@ -68,6 +69,7 @@ describe("subscription billing", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
requireManagedServiceAccess({
|
requireManagedServiceAccess({
|
||||||
organizationId: "org_123",
|
organizationId: "org_123",
|
||||||
|
userId: "user_123",
|
||||||
userEmail: "alice@example.com",
|
userEmail: "alice@example.com",
|
||||||
}),
|
}),
|
||||||
).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" });
|
).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" });
|
||||||
@ -78,6 +80,7 @@ describe("subscription billing", () => {
|
|||||||
|
|
||||||
await getOrCreateOrganizationCustomer({
|
await getOrCreateOrganizationCustomer({
|
||||||
organizationId: "org_123",
|
organizationId: "org_123",
|
||||||
|
userId: "user_123",
|
||||||
userEmail: "alice@example.com",
|
userEmail: "alice@example.com",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,10 @@ import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
|||||||
|
|
||||||
export type BillingCustomerContext = Pick<
|
export type BillingCustomerContext = Pick<
|
||||||
EnsuredUserContext,
|
EnsuredUserContext,
|
||||||
"organizationId" | "userEmail"
|
"organizationId" | "userEmail" | "userId"
|
||||||
>;
|
> & {
|
||||||
|
projectId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function getOrCreateOrganizationCustomer(
|
export async function getOrCreateOrganizationCustomer(
|
||||||
context: BillingCustomerContext,
|
context: BillingCustomerContext,
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import { createBacklinksService } from "./BacklinksService";
|
|||||||
|
|
||||||
const billingCustomer = {
|
const billingCustomer = {
|
||||||
organizationId: "org_123",
|
organizationId: "org_123",
|
||||||
|
userId: "user_123",
|
||||||
userEmail: "team@example.com",
|
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, billingCustomer);
|
||||||
await service.profileOverview(input, {
|
await service.profileOverview(input, {
|
||||||
organizationId: "org_456",
|
organizationId: "org_456",
|
||||||
|
userId: "user_456",
|
||||||
userEmail: "other@example.com",
|
userEmail: "other@example.com",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,10 @@ const { checkMock, trackMock, getOrCreateMock, isHostedServerAuthModeMock } =
|
|||||||
isHostedServerAuthModeMock: vi.fn(),
|
isHostedServerAuthModeMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("cloudflare:workers", () => ({
|
||||||
|
waitUntil: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/server/billing/autumn", () => ({
|
vi.mock("@/server/billing/autumn", () => ({
|
||||||
autumn: {
|
autumn: {
|
||||||
check: checkMock,
|
check: checkMock,
|
||||||
@ -34,6 +38,10 @@ vi.mock("@/server/lib/runtime-env", () => ({
|
|||||||
isHostedServerAuthMode: isHostedServerAuthModeMock,
|
isHostedServerAuthMode: isHostedServerAuthModeMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/lib/posthog", () => ({
|
||||||
|
captureServerEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/server/lib/dataforseo", () => ({
|
vi.mock("@/server/lib/dataforseo", () => ({
|
||||||
fetchKeywordIdeasRaw: vi.fn(),
|
fetchKeywordIdeasRaw: vi.fn(),
|
||||||
fetchKeywordSuggestionsRaw: vi.fn(),
|
fetchKeywordSuggestionsRaw: vi.fn(),
|
||||||
@ -55,11 +63,15 @@ vi.mock("@/server/lib/dataforseoBacklinks", () => ({
|
|||||||
fetchReferringDomainsRaw: vi.fn(),
|
fetchReferringDomainsRaw: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { createDataforseoClient } from "./dataforseoClient";
|
import {
|
||||||
|
createDataforseoClient,
|
||||||
|
mapDataforseoPathToCreditFeature,
|
||||||
|
} from "./dataforseoClient";
|
||||||
import { fetchBacklinksSummaryRaw } from "./dataforseoBacklinks";
|
import { fetchBacklinksSummaryRaw } from "./dataforseoBacklinks";
|
||||||
|
|
||||||
const billingCustomer = {
|
const billingCustomer = {
|
||||||
organizationId: "org_123",
|
organizationId: "org_123",
|
||||||
|
userId: "user_123",
|
||||||
userEmail: "alice@example.com",
|
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,
|
type DataforseoApiCallCost,
|
||||||
} from "@/server/lib/dataforseoCost";
|
} from "@/server/lib/dataforseoCost";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
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) {
|
export function createDataforseoClient(customer: BillingCustomerContext) {
|
||||||
return {
|
return {
|
||||||
backlinks: {
|
backlinks: {
|
||||||
@ -194,6 +230,7 @@ async function meterDataforseoCall<T>(
|
|||||||
const result = await execute();
|
const result = await execute();
|
||||||
|
|
||||||
await trackDataforseoCost({
|
await trackDataforseoCost({
|
||||||
|
customer,
|
||||||
customerId: billingCustomer.id,
|
customerId: billingCustomer.id,
|
||||||
billing: result.billing,
|
billing: result.billing,
|
||||||
monthlyRemaining,
|
monthlyRemaining,
|
||||||
@ -233,6 +270,7 @@ async function assertSeoDataBalanceAvailable(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function trackDataforseoCost(args: {
|
async function trackDataforseoCost(args: {
|
||||||
|
customer: BillingCustomerContext;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
billing: DataforseoApiCallCost;
|
billing: DataforseoApiCallCost;
|
||||||
monthlyRemaining: number;
|
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 };
|
export type { LabsKeywordDataItem, SerpLiveItem };
|
||||||
|
|||||||
@ -2,6 +2,20 @@ import { env } from "cloudflare:workers";
|
|||||||
import { PostHog } from "posthog-node";
|
import { PostHog } from "posthog-node";
|
||||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
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(
|
export async function captureServerError(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
properties: Record<string, string | null | undefined> = {},
|
properties: Record<string, string | null | undefined> = {},
|
||||||
@ -10,17 +24,8 @@ export async function captureServerError(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = env.POSTHOG_PUBLIC_KEY?.trim();
|
const client = getServerPostHogClient();
|
||||||
|
if (!client) return;
|
||||||
if (!apiKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new PostHog(apiKey, {
|
|
||||||
host: env.POSTHOG_HOST?.trim() || "https://us.i.posthog.com",
|
|
||||||
flushAt: 1,
|
|
||||||
flushInterval: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.captureExceptionImmediate(error, undefined, {
|
await client.captureExceptionImmediate(error, undefined, {
|
||||||
@ -30,6 +35,35 @@ export async function captureServerError(
|
|||||||
} catch (posthogError) {
|
} catch (posthogError) {
|
||||||
console.error("posthog server capture failed", posthogError);
|
console.error("posthog server capture failed", posthogError);
|
||||||
} finally {
|
} 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 type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||||
import type { AuditConfig } from "@/server/lib/audit/types";
|
import type { AuditConfig } from "@/server/lib/audit/types";
|
||||||
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases";
|
import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases";
|
||||||
|
|
||||||
interface AuditParams {
|
interface AuditParams {
|
||||||
@ -53,6 +54,24 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
|
|||||||
console.error(`Audit ${auditId} failed:`, error);
|
console.error(`Audit ${auditId} failed:`, error);
|
||||||
await step.do("mark-failed", async () => {
|
await step.do("mark-failed", async () => {
|
||||||
await AuditRepository.failAudit(auditId, event.instanceId);
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import type {
|
|||||||
LighthouseResult,
|
LighthouseResult,
|
||||||
StepPageResult,
|
StepPageResult,
|
||||||
} from "@/server/lib/audit/types";
|
} from "@/server/lib/audit/types";
|
||||||
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl";
|
import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl";
|
||||||
|
|
||||||
const LIGHTHOUSE_URL_BATCH_SIZE = 10;
|
const LIGHTHOUSE_URL_BATCH_SIZE = 10;
|
||||||
@ -83,13 +84,16 @@ export async function runAuditPhases(
|
|||||||
config,
|
config,
|
||||||
allPages,
|
allPages,
|
||||||
});
|
});
|
||||||
await finalizeAudit(
|
await finalizeAudit({
|
||||||
step,
|
step,
|
||||||
auditId,
|
auditId,
|
||||||
workflowInstanceId,
|
workflowInstanceId,
|
||||||
|
billingCustomer,
|
||||||
|
projectId,
|
||||||
|
config,
|
||||||
allPages,
|
allPages,
|
||||||
lighthouseResults,
|
lighthouseResults,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runDiscoveryPhase(
|
async function runDiscoveryPhase(
|
||||||
@ -249,13 +253,27 @@ async function runLighthouseBatch(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finalizeAudit(
|
async function finalizeAudit(args: {
|
||||||
step: WorkflowStep,
|
step: WorkflowStep;
|
||||||
auditId: string,
|
auditId: string;
|
||||||
workflowInstanceId: string,
|
workflowInstanceId: string;
|
||||||
allPages: StepPageResult[],
|
billingCustomer: BillingCustomerContext;
|
||||||
lighthouseResults: LighthouseResult[],
|
projectId: string;
|
||||||
) {
|
config: AuditConfig;
|
||||||
|
allPages: StepPageResult[];
|
||||||
|
lighthouseResults: LighthouseResult[];
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
step,
|
||||||
|
auditId,
|
||||||
|
workflowInstanceId,
|
||||||
|
billingCustomer,
|
||||||
|
projectId,
|
||||||
|
config,
|
||||||
|
allPages,
|
||||||
|
lighthouseResults,
|
||||||
|
} = args;
|
||||||
|
|
||||||
await step.do("finalize", async () => {
|
await step.do("finalize", async () => {
|
||||||
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
|
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
|
||||||
currentPhase: "finalizing",
|
currentPhase: "finalizing",
|
||||||
@ -269,6 +287,18 @@ async function finalizeAudit(
|
|||||||
pagesCrawled: allPages.length,
|
pagesCrawled: allPages.length,
|
||||||
pagesTotal: 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);
|
await AuditProgressKV.clear(auditId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { waitUntil } from "cloudflare:workers";
|
||||||
import { AuditService } from "@/server/features/audit/services/AuditService";
|
import { AuditService } from "@/server/features/audit/services/AuditService";
|
||||||
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||||
import {
|
import {
|
||||||
deleteAuditSchema,
|
deleteAuditSchema,
|
||||||
@ -14,51 +16,63 @@ export const startAudit = createServerFn({ method: "POST" })
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => startAuditSchema.parse(data))
|
.inputValidator((data: unknown) => startAuditSchema.parse(data))
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
return AuditService.startAudit({
|
const result = await AuditService.startAudit({
|
||||||
actorUserId: context.userId,
|
actorUserId: context.userId,
|
||||||
billingCustomer: {
|
billingCustomer: context,
|
||||||
organizationId: context.organizationId,
|
projectId: context.projectId,
|
||||||
userEmail: context.userEmail,
|
|
||||||
},
|
|
||||||
projectId: context.project.id,
|
|
||||||
startUrl: data.startUrl,
|
startUrl: data.startUrl,
|
||||||
maxPages: data.maxPages,
|
maxPages: data.maxPages,
|
||||||
lighthouseStrategy: data.lighthouseStrategy,
|
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" })
|
export const getAuditStatus = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => getAuditStatusSchema.parse(data))
|
.inputValidator((data: unknown) => getAuditStatusSchema.parse(data))
|
||||||
.handler(async ({ data, context }) => {
|
.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" })
|
export const getAuditResults = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => getAuditResultsSchema.parse(data))
|
.inputValidator((data: unknown) => getAuditResultsSchema.parse(data))
|
||||||
.handler(async ({ data, context }) => {
|
.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" })
|
export const getAuditHistory = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => getAuditHistorySchema.parse(data))
|
.inputValidator((data: unknown) => getAuditHistorySchema.parse(data))
|
||||||
.handler(async ({ context }) => {
|
.handler(async ({ context }) => {
|
||||||
return AuditService.getHistory(context.project.id);
|
return AuditService.getHistory(context.projectId);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getCrawlProgress = createServerFn({ method: "POST" })
|
export const getCrawlProgress = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => getCrawlProgressSchema.parse(data))
|
.inputValidator((data: unknown) => getCrawlProgressSchema.parse(data))
|
||||||
.handler(async ({ data, context }) => {
|
.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" })
|
export const deleteAudit = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => deleteAuditSchema.parse(data))
|
.inputValidator((data: unknown) => deleteAuditSchema.parse(data))
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
await AuditService.remove(data.auditId, context.project.id);
|
await AuditService.remove(data.auditId, context.projectId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -19,10 +19,7 @@ export const getBacklinksOverview = createServerFn({
|
|||||||
target: data.target,
|
target: data.target,
|
||||||
scope: data.scope,
|
scope: data.scope,
|
||||||
};
|
};
|
||||||
const profile = await BacklinksService.profileOverview(input, {
|
const profile = await BacklinksService.profileOverview(input, context);
|
||||||
organizationId: context.organizationId,
|
|
||||||
userEmail: context.userEmail,
|
|
||||||
});
|
|
||||||
return profile.overview;
|
return profile.overview;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
|
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
|
||||||
@ -47,10 +44,10 @@ export const getBacklinksReferringDomains = createServerFn({
|
|||||||
target: data.target,
|
target: data.target,
|
||||||
scope: data.scope,
|
scope: data.scope,
|
||||||
};
|
};
|
||||||
const profile = await BacklinksService.profileReferringDomains(input, {
|
const profile = await BacklinksService.profileReferringDomains(
|
||||||
organizationId: context.organizationId,
|
input,
|
||||||
userEmail: context.userEmail,
|
context,
|
||||||
});
|
);
|
||||||
return profile.rows;
|
return profile.rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await updateBacklinksAccessStatusOnError(error);
|
await updateBacklinksAccessStatusOnError(error);
|
||||||
@ -69,10 +66,7 @@ export const getBacklinksTopPages = createServerFn({
|
|||||||
target: data.target,
|
target: data.target,
|
||||||
scope: data.scope,
|
scope: data.scope,
|
||||||
};
|
};
|
||||||
const profile = await BacklinksService.profileTopPages(input, {
|
const profile = await BacklinksService.profileTopPages(input, context);
|
||||||
organizationId: context.organizationId,
|
|
||||||
userEmail: context.userEmail,
|
|
||||||
});
|
|
||||||
return profile.rows;
|
return profile.rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await updateBacklinksAccessStatusOnError(error);
|
await updateBacklinksAccessStatusOnError(error);
|
||||||
|
|||||||
@ -38,10 +38,7 @@ export const testBacklinksAccess = createServerFn({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checkedAt = new Date().toISOString();
|
const checkedAt = new Date().toISOString();
|
||||||
const dataforseo = createDataforseoClient({
|
const dataforseo = createDataforseoClient(context);
|
||||||
organizationId: context.organizationId,
|
|
||||||
userEmail: context.userEmail,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await dataforseo.backlinks.summary({
|
await dataforseo.backlinks.summary({
|
||||||
|
|||||||
@ -10,11 +10,8 @@ export const getDomainOverview = createServerFn({ method: "POST" })
|
|||||||
DomainService.getOverview(
|
DomainService.getOverview(
|
||||||
{
|
{
|
||||||
...data,
|
...data,
|
||||||
projectId: context.project.id,
|
projectId: context.projectId,
|
||||||
},
|
|
||||||
{
|
|
||||||
organizationId: context.organizationId,
|
|
||||||
userEmail: context.userEmail,
|
|
||||||
},
|
},
|
||||||
|
context,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,12 +16,9 @@ export const researchKeywords = createServerFn({ method: "POST" })
|
|||||||
return KeywordResearchService.research(
|
return KeywordResearchService.research(
|
||||||
{
|
{
|
||||||
...data,
|
...data,
|
||||||
projectId: context.project.id,
|
projectId: context.projectId,
|
||||||
},
|
|
||||||
{
|
|
||||||
organizationId: context.organizationId,
|
|
||||||
userEmail: context.userEmail,
|
|
||||||
},
|
},
|
||||||
|
context,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -31,7 +28,7 @@ export const saveKeywords = createServerFn({ method: "POST" })
|
|||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
return KeywordResearchService.saveKeywords({
|
return KeywordResearchService.saveKeywords({
|
||||||
...data,
|
...data,
|
||||||
projectId: context.project.id,
|
projectId: context.projectId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -41,7 +38,7 @@ export const getSavedKeywords = createServerFn({ method: "POST" })
|
|||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
return KeywordResearchService.getSavedKeywords({
|
return KeywordResearchService.getSavedKeywords({
|
||||||
...data,
|
...data,
|
||||||
projectId: context.project.id,
|
projectId: context.projectId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -51,7 +48,7 @@ export const removeSavedKeyword = createServerFn({
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => removeSavedKeywordSchema.parse(data))
|
.inputValidator((data: unknown) => removeSavedKeywordSchema.parse(data))
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
return KeywordResearchService.removeSavedKeyword(context.project.id, data);
|
return KeywordResearchService.removeSavedKeyword(context.projectId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getSerpAnalysis = createServerFn({ method: "POST" })
|
export const getSerpAnalysis = createServerFn({ method: "POST" })
|
||||||
@ -61,11 +58,8 @@ export const getSerpAnalysis = createServerFn({ method: "POST" })
|
|||||||
KeywordResearchService.getSerpAnalysis(
|
KeywordResearchService.getSerpAnalysis(
|
||||||
{
|
{
|
||||||
...data,
|
...data,
|
||||||
projectId: context.project.id,
|
projectId: context.projectId,
|
||||||
},
|
|
||||||
{
|
|
||||||
organizationId: context.organizationId,
|
|
||||||
userEmail: context.userEmail,
|
|
||||||
},
|
},
|
||||||
|
context,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export const getAuditLighthouseIssues = createServerFn({ method: "POST" })
|
|||||||
.inputValidator((data: unknown) => lighthouseAuditIssueSchema.parse(data))
|
.inputValidator((data: unknown) => lighthouseAuditIssueSchema.parse(data))
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
const lighthouse = await getAuditLighthouseData({
|
const lighthouse = await getAuditLighthouseData({
|
||||||
projectId: context.project.id,
|
projectId: context.projectId,
|
||||||
resultId: data.resultId,
|
resultId: data.resultId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -71,7 +71,7 @@ export const exportAuditLighthouseIssues = createServerFn({ method: "POST" })
|
|||||||
.inputValidator((data: unknown) => lighthouseAuditExportSchema.parse(data))
|
.inputValidator((data: unknown) => lighthouseAuditExportSchema.parse(data))
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
const lighthouse = await getAuditLighthouseData({
|
const lighthouse = await getAuditLighthouseData({
|
||||||
projectId: context.project.id,
|
projectId: context.projectId,
|
||||||
resultId: data.resultId,
|
resultId: data.resultId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,40 +1,27 @@
|
|||||||
import { createMiddleware } from "@tanstack/react-start";
|
import { createMiddleware } from "@tanstack/react-start";
|
||||||
|
import { z } from "zod";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
import { errorHandlingMiddleware } from "@/middleware/errorHandling";
|
import { errorHandlingMiddleware } from "@/middleware/errorHandling";
|
||||||
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
|
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
|
||||||
import { ensureUserMiddleware } from "@/middleware/ensureUser";
|
import { ensureUserMiddleware } from "@/middleware/ensureUser";
|
||||||
import { requireManagedServiceAccess } from "@/server/billing/subscription";
|
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(
|
function getAuthenticatedContext(context: unknown): EnsuredUserContext {
|
||||||
context: unknown,
|
const result = ensuredUserContextSchema.safeParse(context);
|
||||||
): AuthenticatedServerFunctionContext {
|
if (!result.success) {
|
||||||
if (!isAuthenticatedServerFunctionContext(context)) {
|
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
"INTERNAL_ERROR",
|
"INTERNAL_ERROR",
|
||||||
"Authenticated server function context missing",
|
"Authenticated server function context missing",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return result.data;
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const globalServerFunctionMiddleware = [
|
export const globalServerFunctionMiddleware = [
|
||||||
@ -70,6 +57,7 @@ export const requireProjectContext = [
|
|||||||
context: {
|
context: {
|
||||||
...authenticatedContext,
|
...authenticatedContext,
|
||||||
project: authenticatedContext.project,
|
project: authenticatedContext.project,
|
||||||
|
projectId: authenticatedContext.project.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user