* 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
245 lines
6.3 KiB
TypeScript
245 lines
6.3 KiB
TypeScript
import { beforeEach, expect, it, vi } from "vitest";
|
|
|
|
const backlinksSummaryMock = vi.fn();
|
|
const backlinksRowsMock = vi.fn();
|
|
const referringDomainsMock = vi.fn();
|
|
const domainPagesMock = vi.fn();
|
|
const backlinksHistoryMock = vi.fn();
|
|
|
|
vi.mock("@/server/lib/r2-cache", () => ({
|
|
buildCacheKey: vi.fn(
|
|
async (prefix: string, params: Record<string, unknown>) =>
|
|
`${prefix}:${JSON.stringify(params)}`,
|
|
),
|
|
getCached: vi.fn(async () => null),
|
|
setCached: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
vi.mock("@/server/lib/dataforseoBacklinks", () => ({
|
|
normalizeBacklinksTarget: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/server/lib/dataforseoClient", () => ({
|
|
createDataforseoClient: vi.fn(() => ({
|
|
backlinks: {
|
|
summary: backlinksSummaryMock,
|
|
rows: backlinksRowsMock,
|
|
referringDomains: referringDomainsMock,
|
|
domainPages: domainPagesMock,
|
|
history: backlinksHistoryMock,
|
|
},
|
|
})),
|
|
}));
|
|
|
|
import { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinks";
|
|
import { createBacklinksService } from "./BacklinksService";
|
|
|
|
const billingCustomer = {
|
|
organizationId: "org_123",
|
|
userId: "user_123",
|
|
userEmail: "team@example.com",
|
|
};
|
|
|
|
const cache = new Map<string, string>();
|
|
const service = createBacklinksService({
|
|
async get(key) {
|
|
const raw = cache.get(key);
|
|
return raw ? parseCachedValue(raw) : null;
|
|
},
|
|
async set(key, data) {
|
|
cache.set(key, JSON.stringify(data));
|
|
},
|
|
});
|
|
|
|
beforeEach(() => {
|
|
cache.clear();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("profiles only the initial overview calls and reuses cache on repeat", async () => {
|
|
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
|
|
apiTarget: "example.com",
|
|
displayTarget: "example.com",
|
|
scope: "domain",
|
|
});
|
|
backlinksSummaryMock.mockResolvedValue({
|
|
rank: 42,
|
|
backlinks: 1200,
|
|
referring_pages: 900,
|
|
referring_domains: 320,
|
|
broken_backlinks: 12,
|
|
broken_pages: 3,
|
|
backlinks_spam_score: 5,
|
|
info: { target_spam_score: 4 },
|
|
new_backlinks: 25,
|
|
lost_backlinks: 10,
|
|
new_referring_domains: 8,
|
|
lost_referring_domains: 2,
|
|
});
|
|
backlinksRowsMock.mockResolvedValue([
|
|
{
|
|
domain_from: "source.example",
|
|
url_from: "https://source.example/post",
|
|
url_to: "https://example.com/",
|
|
anchor: "Example",
|
|
item_type: "content",
|
|
dofollow: true,
|
|
rank: 77,
|
|
domain_from_rank: 65,
|
|
page_from_rank: 54,
|
|
backlink_spam_score: 3,
|
|
first_seen: "2026-01-01",
|
|
last_visited: "2026-03-01",
|
|
lost_date: null,
|
|
is_lost: false,
|
|
is_broken: false,
|
|
links_count: 1,
|
|
rel_attributes: ["noopener"],
|
|
},
|
|
]);
|
|
backlinksHistoryMock.mockResolvedValue([
|
|
{
|
|
date: "2026-02-01",
|
|
backlinks: 1100,
|
|
referring_domains: 300,
|
|
rank: 40,
|
|
new_backlinks: 20,
|
|
lost_backlinks: 5,
|
|
new_referring_domains: 3,
|
|
lost_referring_domains: 1,
|
|
},
|
|
]);
|
|
|
|
const first = await service.profileOverview(
|
|
{ target: "example.com" },
|
|
billingCustomer,
|
|
);
|
|
const second = await service.profileOverview(
|
|
{ target: "example.com" },
|
|
billingCustomer,
|
|
);
|
|
|
|
expect(first.overview.referringDomains).toEqual([]);
|
|
expect(first.overview.topPages).toEqual([]);
|
|
expect(referringDomainsMock).not.toHaveBeenCalled();
|
|
expect(domainPagesMock).not.toHaveBeenCalled();
|
|
expect(backlinksSummaryMock).toHaveBeenCalledOnce();
|
|
expect(backlinksHistoryMock).toHaveBeenCalledOnce();
|
|
expect(second).toEqual(first);
|
|
});
|
|
|
|
it("profiles referring domains and top pages separately", async () => {
|
|
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
|
|
apiTarget: "https://example.com/foo",
|
|
displayTarget: "https://example.com/foo",
|
|
scope: "page",
|
|
});
|
|
referringDomainsMock.mockResolvedValue([
|
|
{
|
|
domain: "source.example",
|
|
backlinks: 4,
|
|
referring_pages: 2,
|
|
rank: 65,
|
|
first_seen: "2026-01-01",
|
|
broken_backlinks: 0,
|
|
broken_pages: 0,
|
|
backlinks_spam_score: 2,
|
|
target_spam_score: 4,
|
|
},
|
|
]);
|
|
domainPagesMock.mockResolvedValue([
|
|
{
|
|
page: "https://example.com/foo",
|
|
backlinks: 100,
|
|
referring_domains: 20,
|
|
rank: 50,
|
|
broken_backlinks: 0,
|
|
},
|
|
]);
|
|
|
|
const domains = await service.profileReferringDomains(
|
|
{ target: "https://example.com/foo" },
|
|
billingCustomer,
|
|
);
|
|
const pages = await service.profileTopPages(
|
|
{ target: "https://example.com/foo" },
|
|
billingCustomer,
|
|
);
|
|
|
|
expect(domains.rows).toHaveLength(1);
|
|
expect(domains.rows[0]?.spamScore).toBe(2);
|
|
expect(pages.rows).toHaveLength(1);
|
|
});
|
|
|
|
it("does not fall back to target spam score for referring domains", async () => {
|
|
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
|
|
apiTarget: "example.com",
|
|
displayTarget: "example.com",
|
|
scope: "domain",
|
|
});
|
|
referringDomainsMock.mockResolvedValue([
|
|
{
|
|
domain: "source.example",
|
|
backlinks: 4,
|
|
referring_pages: 2,
|
|
rank: 65,
|
|
first_seen: "2026-01-01",
|
|
broken_backlinks: 0,
|
|
broken_pages: 0,
|
|
backlinks_spam_score: null,
|
|
target_spam_score: 4,
|
|
},
|
|
]);
|
|
|
|
const domains = await service.profileReferringDomains(
|
|
{ target: "example.com" },
|
|
billingCustomer,
|
|
);
|
|
|
|
expect(domains.rows).toHaveLength(1);
|
|
expect(domains.rows[0]?.spamScore).toBeNull();
|
|
});
|
|
|
|
it("keeps cache entries isolated per organization", async () => {
|
|
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
|
|
apiTarget: "example.com",
|
|
displayTarget: "example.com",
|
|
scope: "domain",
|
|
});
|
|
backlinksSummaryMock.mockResolvedValue({
|
|
rank: 42,
|
|
backlinks: 1200,
|
|
referring_pages: 900,
|
|
referring_domains: 320,
|
|
broken_backlinks: 12,
|
|
broken_pages: 3,
|
|
backlinks_spam_score: 5,
|
|
info: { target_spam_score: 4 },
|
|
new_backlinks: 25,
|
|
lost_backlinks: 10,
|
|
new_referring_domains: 8,
|
|
lost_referring_domains: 2,
|
|
});
|
|
backlinksRowsMock.mockResolvedValue([]);
|
|
backlinksHistoryMock.mockResolvedValue([]);
|
|
|
|
const input = { target: "example.com" };
|
|
|
|
await service.profileOverview(input, billingCustomer);
|
|
await service.profileOverview(input, {
|
|
organizationId: "org_456",
|
|
userId: "user_456",
|
|
userEmail: "other@example.com",
|
|
});
|
|
|
|
expect(backlinksSummaryMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
function parseCachedValue(raw: string): unknown {
|
|
try {
|
|
return JSON.parse(raw) as unknown;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|