From 9b89eb0329275d65cd1f8b476cba51c07cd75fed Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 18 Feb 2026 14:26:34 +0530 Subject: [PATCH 1/5] LIN-55: Implement comment-first mode UI and API for RED-55 --- src/app/(app)/health/page.tsx | 115 +++++++++- src/app/api/analytics/funnel/route.ts | 54 +++++ src/app/api/analytics/validation/route.ts | 28 +++ .../accounts/comment-first-status/route.ts | 78 +++++++ src/lib/analytics/funnel.ts | 213 ++++++++++++++++++ src/lib/analytics/validation.ts | 139 ++++++++++++ tests/unit/lib/analyticsFunnel.test.ts | 91 ++++++++ 7 files changed, 706 insertions(+), 12 deletions(-) create mode 100644 src/app/api/analytics/funnel/route.ts create mode 100644 src/app/api/analytics/validation/route.ts create mode 100644 src/app/api/reddit/accounts/comment-first-status/route.ts create mode 100644 src/lib/analytics/funnel.ts create mode 100644 src/lib/analytics/validation.ts create mode 100644 tests/unit/lib/analyticsFunnel.test.ts diff --git a/src/app/(app)/health/page.tsx b/src/app/(app)/health/page.tsx index 1d2e1a0..6db40bd 100644 --- a/src/app/(app)/health/page.tsx +++ b/src/app/(app)/health/page.tsx @@ -4,7 +4,17 @@ import { getHealthGuardrailThresholds } from "@/lib/health/guardrails"; import { prisma } from "@/lib/prisma"; import { requireWorkspaceSession } from "@/lib/server/auth-guards"; -function cadenceForTier(tier: "NEW" | "ESTABLISHED" | "TRUSTED" | "RESTRICTED") { +const DEFAULT_COMMENT_FIRST_MIN_COMMENTS = 3; + +function parsePositiveEnvInt(name: string, fallback: number) { + const raw = Number(process.env[name]); + if (!Number.isFinite(raw) || raw <= 0) return fallback; + return Math.floor(raw); +} + +function cadenceForTier( + tier: "NEW" | "ESTABLISHED" | "TRUSTED" | "RESTRICTED", +) { if (tier === "TRUSTED") return "3 to 5 posts per day"; if (tier === "ESTABLISHED") return "2 to 3 posts per day"; if (tier === "NEW") return "Prefer comments; 0 to 1 posts per day"; @@ -18,13 +28,18 @@ function scorePillTone( ) { if (score == null) return "border-slate-300 bg-slate-50 text-slate-700"; if (score < blockThreshold) return "border-red-300 bg-red-50 text-red-700"; - if (score < cautionThreshold) return "border-amber-300 bg-amber-50 text-amber-700"; + if (score < cautionThreshold) + return "border-amber-300 bg-amber-50 text-amber-700"; return "border-emerald-300 bg-emerald-50 text-emerald-700"; } export default async function HealthPage() { const session = await requireWorkspaceSession(); const healthThresholds = getHealthGuardrailThresholds(); + const commentFirstMinComments = parsePositiveEnvInt( + "COMMENT_FIRST_MIN_COMMENTS", + DEFAULT_COMMENT_FIRST_MIN_COMMENTS, + ); const accounts = await prisma.redditAccount.findMany({ where: { workspaceId: session.workspaceId, isActive: true }, @@ -52,6 +67,21 @@ export default async function HealthPage() { }, }); + const accountIds = accounts.map((a) => a.id); + const publishedCommentsByAccount = await prisma.publishedItem.groupBy({ + by: ["redditAccountId"], + where: { + workspaceId: session.workspaceId, + redditAccountId: { in: accountIds }, + type: "COMMENT", + }, + _count: true, + }); + + const commentCountsMap = new Map( + publishedCommentsByAccount.map((r) => [r.redditAccountId, r._count]), + ); + return (
@@ -70,7 +100,7 @@ export default async function HealthPage() { href="/opportunities" className="rounded-full bg-primary px-5 py-2 text-sm font-semibold text-primary-foreground" > - View opportunities + Find commenting opportunities
@@ -87,6 +117,15 @@ export default async function HealthPage() { latestSnapshot != null ? Math.round(latestSnapshot.healthScore) : null; + + const publishedComments = commentCountsMap.get(account.id) ?? 0; + const isNewAccount = account.safetyTier === "NEW"; + const canSchedulePosts = + !isNewAccount || publishedComments >= commentFirstMinComments; + const remainingComments = isNewAccount + ? Math.max(0, commentFirstMinComments - publishedComments) + : 0; + const warnings: string[] = []; if (account.safetyTier === "RESTRICTED") { @@ -94,7 +133,15 @@ export default async function HealthPage() { "Publishing should stay blocked until account restrictions recover.", ); } - if (healthScore != null && healthScore < healthThresholds.blockPublishing) { + if (isNewAccount && !canSchedulePosts) { + warnings.push( + `Comment-first mode: publish ${remainingComments} more comment(s) before scheduling posts.`, + ); + } + if ( + healthScore != null && + healthScore < healthThresholds.blockPublishing + ) { warnings.push( "High risk detected. Do not schedule posts until health improves.", ); @@ -140,7 +187,9 @@ export default async function HealthPage() { healthThresholds.caution, )}`} > - {healthScore == null ? "Score unavailable" : `Score ${healthScore}`} + {healthScore == null + ? "Score unavailable" + : `Score ${healthScore}`}
@@ -154,6 +203,47 @@ export default async function HealthPage() { )} + {isNewAccount && ( +
+
+
+

+ Comment-first mode +

+

+ {publishedComments} / {commentFirstMinComments}{" "} + comments published +

+
+ + {canSchedulePosts + ? "Ready for posts" + : `${remainingComments} more needed`} + +
+ {!canSchedulePosts && ( +
+

+ New accounts must publish comments before scheduling + posts to build history and avoid bans. +

+ + Find opportunities + +
+ )} +
+ )} +

Signals @@ -167,7 +257,8 @@ export default async function HealthPage() { )) )}

  • - Last visibility check: {latestVisibility?.result ?? "UNKNOWN"} + Last visibility check:{" "} + {latestVisibility?.result ?? "UNKNOWN"}
  • Last health snapshot:{" "} @@ -209,13 +300,11 @@ export default async function HealthPage() { }, { title: "Health block", - detail: - `Scheduling posts is blocked when latest health score is below ${healthThresholds.blockPublishing}.`, + detail: `Scheduling posts is blocked when latest health score is below ${healthThresholds.blockPublishing}.`, }, { - title: "Comments-first fallback", - detail: - "When health is low, prioritize comments before attempting post scheduling.", + title: "Comment-first for new accounts", + detail: `NEW tier accounts must publish ${commentFirstMinComments}+ comments before scheduling posts.`, }, ].map((item) => (

    {item.title}

    -

    {item.detail}

    +

    + {item.detail} +

    ))}
  • diff --git a/src/app/api/analytics/funnel/route.ts b/src/app/api/analytics/funnel/route.ts new file mode 100644 index 0000000..0e928c4 --- /dev/null +++ b/src/app/api/analytics/funnel/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { requireWorkspaceSession } from "@/lib/server/auth-guards"; +import { + getFunnelData, + getEventCountsLast24h, + getFullFunnelPaths, +} from "@/lib/analytics/funnel"; + +function authError(err: unknown) { + const code = err instanceof Error ? err.message : "UNAUTHORIZED"; + const status = code === "WORKSPACE_REQUIRED" ? 400 : 401; + return NextResponse.json({ error: "Unauthorized", code }, { status }); +} + +export async function GET(req: Request) { + let session; + try { + session = await requireWorkspaceSession(); + } catch (err) { + return authError(err); + } + + const { searchParams } = new URL(req.url); + const period = searchParams.get("period") || "7d"; + const startDateStr = searchParams.get("start"); + const endDateStr = searchParams.get("end"); + + let startDate: Date; + let endDate = new Date(); + + if (startDateStr && endDateStr) { + startDate = new Date(startDateStr); + endDate = new Date(endDateStr); + } else { + const days = period === "24h" ? 1 : period === "30d" ? 30 : 7; + startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + } + + const [funnelData, eventCounts, fullPaths] = await Promise.all([ + getFunnelData(startDate, endDate), + getEventCountsLast24h(), + getFullFunnelPaths(5), + ]); + + return NextResponse.json({ + funnel: funnelData, + eventCountsLast24h: eventCounts, + recentCompleteFunnels: fullPaths, + requestedBy: { + workspaceId: session.workspaceId, + userId: session.user.id, + }, + }); +} diff --git a/src/app/api/analytics/validation/route.ts b/src/app/api/analytics/validation/route.ts new file mode 100644 index 0000000..5d473ef --- /dev/null +++ b/src/app/api/analytics/validation/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { requireWorkspaceAdminSession } from "@/lib/server/admin-guards"; +import { validateAnalyticsPipeline } from "@/lib/analytics/validation"; + +function authError(err: unknown) { + const code = err instanceof Error ? err.message : "UNAUTHORIZED"; + const status = code === "WORKSPACE_REQUIRED" ? 400 : 401; + return NextResponse.json({ error: "Unauthorized", code }, { status }); +} + +export async function GET() { + let session; + try { + session = await requireWorkspaceAdminSession(); + } catch (err) { + return authError(err); + } + + const result = await validateAnalyticsPipeline(); + + return NextResponse.json({ + ...result, + requestedBy: { + workspaceId: session.workspaceId, + userId: session.user.id, + }, + }); +} diff --git a/src/app/api/reddit/accounts/comment-first-status/route.ts b/src/app/api/reddit/accounts/comment-first-status/route.ts new file mode 100644 index 0000000..09b451a --- /dev/null +++ b/src/app/api/reddit/accounts/comment-first-status/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireWorkspaceSession } from "@/lib/server/auth-guards"; + +const DEFAULT_COMMENT_FIRST_MIN_COMMENTS = 3; + +function parsePositiveEnvInt(name: string, fallback: number) { + const raw = Number(process.env[name]); + if (!Number.isFinite(raw) || raw <= 0) return fallback; + return Math.floor(raw); +} + +export async function GET() { + let session; + try { + session = await requireWorkspaceSession(); + } catch (err) { + const code = err instanceof Error ? err.message : "UNAUTHORIZED"; + const status = code === "WORKSPACE_REQUIRED" ? 400 : 401; + return NextResponse.json({ error: "Unauthorized", code }, { status }); + } + + const commentFirstMinComments = parsePositiveEnvInt( + "COMMENT_FIRST_MIN_COMMENTS", + DEFAULT_COMMENT_FIRST_MIN_COMMENTS, + ); + + const accounts = await prisma.redditAccount.findMany({ + where: { workspaceId: session.workspaceId, isActive: true }, + select: { + id: true, + redditUsername: true, + safetyTier: true, + }, + }); + + const accountIds = accounts.map((a) => a.id); + + const publishedCommentsByAccount = await prisma.publishedItem.groupBy({ + by: ["redditAccountId"], + where: { + workspaceId: session.workspaceId, + redditAccountId: { in: accountIds }, + type: "COMMENT", + }, + _count: true, + }); + + const commentCountsMap = new Map( + publishedCommentsByAccount.map((r) => [r.redditAccountId, r._count]), + ); + + const statuses = accounts.map((account) => { + const publishedComments = commentCountsMap.get(account.id) ?? 0; + const isNewAccount = account.safetyTier === "NEW"; + const canSchedulePosts = + !isNewAccount || publishedComments >= commentFirstMinComments; + const remainingComments = isNewAccount + ? Math.max(0, commentFirstMinComments - publishedComments) + : 0; + + return { + accountId: account.id, + username: account.redditUsername, + safetyTier: account.safetyTier, + isNewAccount, + publishedComments, + requiredComments: commentFirstMinComments, + canSchedulePosts, + remainingComments, + }; + }); + + return NextResponse.json({ + requiredComments: commentFirstMinComments, + accounts: statuses, + }); +} diff --git a/src/lib/analytics/funnel.ts b/src/lib/analytics/funnel.ts new file mode 100644 index 0000000..df20b4d --- /dev/null +++ b/src/lib/analytics/funnel.ts @@ -0,0 +1,213 @@ +import { prisma } from "@/lib/prisma"; +import { Prisma } from "@prisma/client"; + +export type FunnelStage = { + stage: string; + eventName: string; + uniqueUsers: number; + uniqueSessions: number; + totalEvents: number; +}; + +export type FunnelDropoff = { + fromStage: string; + toStage: string; + fromCount: number; + toCount: number; + dropoffRate: number; + conversionRate: number; +}; + +export type FunnelResult = { + stages: FunnelStage[]; + dropoffs: FunnelDropoff[]; + period: { + start: Date; + end: Date; + }; +}; + +const FUNNEL_STAGES = [ + { stage: "homepage", eventName: "homepage_view" }, + { stage: "signup_started", eventName: "signup_started" }, + { stage: "signup_completed", eventName: "signup_completed" }, + { stage: "onboarding_completed", eventName: "onboarding_completed" }, + { stage: "plan_activated", eventName: "plan_activated" }, +] as const; + +export async function getFunnelStages( + startDate: Date, + endDate: Date, +): Promise { + const stages: FunnelStage[] = []; + + for (const { stage, eventName } of FUNNEL_STAGES) { + const result = await prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(DISTINCT COALESCE(user_id, anonymous_session_id)) as count + FROM analytics_events + WHERE event_name = ${eventName} + AND event_ts >= ${startDate} + AND event_ts <= ${endDate} + `; + + const totalResult = await prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*) as count + FROM analytics_events + WHERE event_name = ${eventName} + AND event_ts >= ${startDate} + AND event_ts <= ${endDate} + `; + + const userResult = await prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(DISTINCT user_id) as count + FROM analytics_events + WHERE event_name = ${eventName} + AND event_ts >= ${startDate} + AND event_ts <= ${endDate} + AND user_id IS NOT NULL + `; + + stages.push({ + stage, + eventName, + uniqueUsers: Number(userResult[0]?.count ?? 0), + uniqueSessions: Number(result[0]?.count ?? 0), + totalEvents: Number(totalResult[0]?.count ?? 0), + }); + } + + return stages; +} + +export function calculateDropoffs(stages: FunnelStage[]): FunnelDropoff[] { + const dropoffs: FunnelDropoff[] = []; + + for (let i = 0; i < stages.length - 1; i++) { + const from = stages[i]; + const to = stages[i + 1]; + + const fromCount = from.uniqueSessions; + const toCount = to.uniqueSessions; + + const dropoffRate = + fromCount > 0 ? ((fromCount - toCount) / fromCount) * 100 : 0; + const conversionRate = fromCount > 0 ? (toCount / fromCount) * 100 : 0; + + dropoffs.push({ + fromStage: from.stage, + toStage: to.stage, + fromCount, + toCount, + dropoffRate: Math.round(dropoffRate * 100) / 100, + conversionRate: Math.round(conversionRate * 100) / 100, + }); + } + + return dropoffs; +} + +export async function getFunnelData( + startDate: Date, + endDate: Date, +): Promise { + const stages = await getFunnelStages(startDate, endDate); + const dropoffs = calculateDropoffs(stages); + + return { + stages, + dropoffs, + period: { start: startDate, end: endDate }, + }; +} + +export type EventCountByType = { + eventName: string; + count: number; + uniqueUsers: number; + uniqueSessions: number; +}; + +export async function getEventCountsLast24h(): Promise { + const startDate = new Date(Date.now() - 24 * 60 * 60 * 1000); + const endDate = new Date(); + + const results = await prisma.$queryRaw< + { + event_name: string; + count: bigint; + unique_users: bigint; + unique_sessions: bigint; + }[] + >` + SELECT + event_name, + COUNT(*) as count, + COUNT(DISTINCT user_id) as unique_users, + COUNT(DISTINCT COALESCE(user_id, anonymous_session_id)) as unique_sessions + FROM analytics_events + WHERE event_ts >= ${startDate} + AND event_ts <= ${endDate} + GROUP BY event_name + ORDER BY count DESC + `; + + return results.map((r) => ({ + eventName: r.event_name, + count: Number(r.count), + uniqueUsers: Number(r.unique_users), + uniqueSessions: Number(r.unique_sessions), + })); +} + +export type FullFunnelPath = { + anonymousSessionId: string | null; + userId: string | null; + workspaceId: string | null; + completedStages: string[]; + completedAt: Date; +}; + +export async function getFullFunnelPaths( + limit = 10, +): Promise { + const results = await prisma.$queryRaw` + WITH ranked_events AS ( + SELECT + COALESCE(user_id, anonymous_session_id) as session_key, + user_id, + anonymous_session_id, + workspace_id, + event_name, + event_ts, + ROW_NUMBER() OVER ( + PARTITION BY COALESCE(user_id, anonymous_session_id) + ORDER BY event_ts ASC + ) as rn + FROM analytics_events + WHERE event_name IN ('homepage_view', 'signup_started', 'signup_completed', 'onboarding_completed', 'plan_activated') + ), + session_stages AS ( + SELECT + session_key, + user_id, + anonymous_session_id, + workspace_id, + ARRAY_AGG(event_name ORDER BY rn) as stages, + MAX(event_ts) as completed_at + FROM ranked_events + GROUP BY session_key, user_id, anonymous_session_id, workspace_id + ) + SELECT + anonymous_session_id, + user_id, + workspace_id, + stages as "completedStages", + completed_at as "completedAt" + FROM session_stages + WHERE stages @> ARRAY['homepage_view', 'signup_completed', 'onboarding_completed', 'plan_activated']::text[] + ORDER BY completed_at DESC + LIMIT ${limit} + `; + + return results; +} diff --git a/src/lib/analytics/validation.ts b/src/lib/analytics/validation.ts new file mode 100644 index 0000000..7866d58 --- /dev/null +++ b/src/lib/analytics/validation.ts @@ -0,0 +1,139 @@ +import { prisma } from "@/lib/prisma"; + +export type ValidationCheck = { + id: string; + description: string; + passed: boolean; + value: number | string | boolean | null; + threshold?: number; + details?: string; +}; + +export type ValidationResult = { + passed: boolean; + checks: ValidationCheck[]; + checkedAt: Date; + summary: string; +}; + +export async function validateAnalyticsPipeline(): Promise { + const checks: ValidationCheck[] = []; + + const homepageCount = await prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*) as count + FROM analytics_events + WHERE event_name = 'homepage_view' + `; + const homepageViews = Number(homepageCount[0]?.count ?? 0); + checks.push({ + id: "homepage_events_min", + description: "At least 10 unique homepage_view events in table", + passed: homepageViews >= 10, + value: homepageViews, + threshold: 10, + }); + + const fullPaths = await prisma.$queryRaw<{ count: bigint }[]>` + WITH session_stages AS ( + SELECT + COALESCE(user_id, anonymous_session_id) as session_key, + ARRAY_AGG(DISTINCT event_name) as stages + FROM analytics_events + WHERE event_name IN ('homepage_view', 'signup_completed', 'onboarding_completed', 'plan_activated') + GROUP BY COALESCE(user_id, anonymous_session_id) + ) + SELECT COUNT(*) as count + FROM session_stages + WHERE stages @> ARRAY['homepage_view', 'signup_completed', 'onboarding_completed', 'plan_activated']::text[] + `; + const completeFunnels = Number(fullPaths[0]?.count ?? 0); + checks.push({ + id: "full_funnel_path", + description: + "At least 1 full funnel path (homepage → signup → onboarding → plan)", + passed: completeFunnels >= 1, + value: completeFunnels, + threshold: 1, + }); + + const malformedEvents = await prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*) as count + FROM analytics_events + WHERE event_ts IS NULL + OR (user_id IS NULL AND anonymous_session_id IS NULL) + `; + const malformed = Number(malformedEvents[0]?.count ?? 0); + checks.push({ + id: "no_malformed_events", + description: + "No malformed events (null timestamps, missing userId/sessionId)", + passed: malformed === 0, + value: malformed, + threshold: 0, + details: malformed > 0 ? `Found ${malformed} malformed events` : undefined, + }); + + const queryStart = Date.now(); + await prisma.$queryRaw` + SELECT event_name, COUNT(*) as count + FROM analytics_events + GROUP BY event_name + ORDER BY count DESC + LIMIT 10 + `; + const queryTimeMs = Date.now() - queryStart; + checks.push({ + id: "dashboard_query_performance", + description: "Dashboard queries respond in <2s (performance ok)", + passed: queryTimeMs < 2000, + value: `${queryTimeMs}ms`, + threshold: 2000, + details: `Query completed in ${queryTimeMs}ms`, + }); + + const eventTypes = await prisma.$queryRaw< + { event_name: string; count: bigint }[] + >` + SELECT event_name, COUNT(*) as count + FROM analytics_events + GROUP BY event_name + ORDER BY event_name + `; + checks.push({ + id: "event_type_distribution", + description: "Event type distribution sanity check", + passed: eventTypes.length > 0, + value: eventTypes + .map((e) => `${e.event_name}: ${Number(e.count)}`) + .join(", "), + details: `${eventTypes.length} distinct event types`, + }); + + const recentEvents = await prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*) as count + FROM analytics_events + WHERE event_ts > NOW() - INTERVAL '24 hours' + `; + const last24h = Number(recentEvents[0]?.count ?? 0); + checks.push({ + id: "recent_event_ingestion", + description: "Events are being ingested (events in last 24h)", + passed: last24h > 0, + value: last24h, + threshold: 1, + }); + + const allPassed = checks.every((c) => c.passed); + const passedCount = checks.filter((c) => c.passed).length; + + return { + passed: allPassed, + checks, + checkedAt: new Date(), + summary: `${passedCount}/${checks.length} checks passed. ${ + allPassed + ? "Pipeline validation successful - ready for RED-74/75 closure." + : "Some checks failed - review before closing RED-74/75." + }`, + }; +} diff --git a/tests/unit/lib/analyticsFunnel.test.ts b/tests/unit/lib/analyticsFunnel.test.ts new file mode 100644 index 0000000..24e1a9d --- /dev/null +++ b/tests/unit/lib/analyticsFunnel.test.ts @@ -0,0 +1,91 @@ +import { calculateDropoffs, type FunnelStage } from "@/lib/analytics/funnel"; + +describe("calculateDropoffs", () => { + it("calculates dropoffs between consecutive stages", () => { + const stages: FunnelStage[] = [ + { + stage: "homepage", + eventName: "homepage_view", + uniqueUsers: 100, + uniqueSessions: 100, + totalEvents: 150, + }, + { + stage: "signup_started", + eventName: "signup_started", + uniqueUsers: 50, + uniqueSessions: 50, + totalEvents: 50, + }, + { + stage: "signup_completed", + eventName: "signup_completed", + uniqueUsers: 30, + uniqueSessions: 30, + totalEvents: 30, + }, + ]; + + const dropoffs = calculateDropoffs(stages); + + expect(dropoffs).toHaveLength(2); + + expect(dropoffs[0]).toEqual({ + fromStage: "homepage", + toStage: "signup_started", + fromCount: 100, + toCount: 50, + dropoffRate: 50, + conversionRate: 50, + }); + + expect(dropoffs[1]).toEqual({ + fromStage: "signup_started", + toStage: "signup_completed", + fromCount: 50, + toCount: 30, + dropoffRate: 40, + conversionRate: 60, + }); + }); + + it("handles zero counts gracefully", () => { + const stages: FunnelStage[] = [ + { + stage: "homepage", + eventName: "homepage_view", + uniqueUsers: 0, + uniqueSessions: 0, + totalEvents: 0, + }, + { + stage: "signup_started", + eventName: "signup_started", + uniqueUsers: 0, + uniqueSessions: 0, + totalEvents: 0, + }, + ]; + + const dropoffs = calculateDropoffs(stages); + + expect(dropoffs).toHaveLength(1); + expect(dropoffs[0].dropoffRate).toBe(0); + expect(dropoffs[0].conversionRate).toBe(0); + }); + + it("returns empty array for single stage", () => { + const stages: FunnelStage[] = [ + { + stage: "homepage", + eventName: "homepage_view", + uniqueUsers: 100, + uniqueSessions: 100, + totalEvents: 100, + }, + ]; + + const dropoffs = calculateDropoffs(stages); + expect(dropoffs).toHaveLength(0); + }); +}); From c3f4ba381e33365c12caaaa1b0699b31e813a511 Mon Sep 17 00:00:00 2001 From: Tanay Date: Wed, 18 Feb 2026 18:54:03 +0000 Subject: [PATCH 2/5] fix(analytics): scope funnel queries by workspace and validate date range --- src/app/api/analytics/funnel/route.ts | 77 ++++++++++--- src/app/api/analytics/validation/route.ts | 2 +- src/lib/analytics/funnel.ts | 60 +++++----- src/lib/analytics/validation.ts | 19 +++- tests/unit/api/analyticsFunnelRoute.test.ts | 117 ++++++++++++++++++++ 5 files changed, 227 insertions(+), 48 deletions(-) create mode 100644 tests/unit/api/analyticsFunnelRoute.test.ts diff --git a/src/app/api/analytics/funnel/route.ts b/src/app/api/analytics/funnel/route.ts index 0e928c4..973ef26 100644 --- a/src/app/api/analytics/funnel/route.ts +++ b/src/app/api/analytics/funnel/route.ts @@ -12,6 +12,53 @@ function authError(err: unknown) { return NextResponse.json({ error: "Unauthorized", code }, { status }); } +type DateRangeResult = + | { ok: true; startDate: Date; endDate: Date } + | { ok: false; error: string }; + +const PERIOD_DAYS: Record = { + "24h": 1, + "7d": 7, + "30d": 30, +}; + +export function resolveDateRange( + searchParams: URLSearchParams, + now = new Date(), +): DateRangeResult { + const period = searchParams.get("period") ?? "7d"; + const startDateStr = searchParams.get("start"); + const endDateStr = searchParams.get("end"); + + if ((startDateStr && !endDateStr) || (!startDateStr && endDateStr)) { + return { ok: false, error: "Both start and end must be provided together" }; + } + + if (startDateStr && endDateStr) { + const startDate = new Date(startDateStr); + const endDate = new Date(endDateStr); + if ( + Number.isNaN(startDate.getTime()) || + Number.isNaN(endDate.getTime()) || + startDate > endDate + ) { + return { ok: false, error: "Invalid date range" }; + } + return { ok: true, startDate, endDate }; + } + + const days = PERIOD_DAYS[period]; + if (!days) { + return { ok: false, error: "period must be one of: 24h, 7d, 30d" }; + } + + return { + ok: true, + startDate: new Date(now.getTime() - days * 24 * 60 * 60 * 1000), + endDate: now, + }; +} + export async function GET(req: Request) { let session; try { @@ -21,25 +68,23 @@ export async function GET(req: Request) { } const { searchParams } = new URL(req.url); - const period = searchParams.get("period") || "7d"; - const startDateStr = searchParams.get("start"); - const endDateStr = searchParams.get("end"); - - let startDate: Date; - let endDate = new Date(); - - if (startDateStr && endDateStr) { - startDate = new Date(startDateStr); - endDate = new Date(endDateStr); - } else { - const days = period === "24h" ? 1 : period === "30d" ? 30 : 7; - startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const dateRange = resolveDateRange(searchParams); + if (!dateRange.ok) { + return NextResponse.json( + { error: "Invalid query params", code: "INVALID_DATE_RANGE" }, + { status: 400 }, + ); } const [funnelData, eventCounts, fullPaths] = await Promise.all([ - getFunnelData(startDate, endDate), - getEventCountsLast24h(), - getFullFunnelPaths(5), + getFunnelData(session.workspaceId, dateRange.startDate, dateRange.endDate), + getEventCountsLast24h(session.workspaceId), + getFullFunnelPaths( + session.workspaceId, + dateRange.startDate, + dateRange.endDate, + 5, + ), ]); return NextResponse.json({ diff --git a/src/app/api/analytics/validation/route.ts b/src/app/api/analytics/validation/route.ts index 5d473ef..e272297 100644 --- a/src/app/api/analytics/validation/route.ts +++ b/src/app/api/analytics/validation/route.ts @@ -16,7 +16,7 @@ export async function GET() { return authError(err); } - const result = await validateAnalyticsPipeline(); + const result = await validateAnalyticsPipeline(session.workspaceId); return NextResponse.json({ ...result, diff --git a/src/lib/analytics/funnel.ts b/src/lib/analytics/funnel.ts index df20b4d..6095ba7 100644 --- a/src/lib/analytics/funnel.ts +++ b/src/lib/analytics/funnel.ts @@ -36,6 +36,7 @@ const FUNNEL_STAGES = [ ] as const; export async function getFunnelStages( + workspaceId: string, startDate: Date, endDate: Date, ): Promise { @@ -45,7 +46,8 @@ export async function getFunnelStages( const result = await prisma.$queryRaw<{ count: bigint }[]>` SELECT COUNT(DISTINCT COALESCE(user_id, anonymous_session_id)) as count FROM analytics_events - WHERE event_name = ${eventName} + WHERE workspace_id = ${workspaceId} + AND event_name = ${eventName} AND event_ts >= ${startDate} AND event_ts <= ${endDate} `; @@ -53,7 +55,8 @@ export async function getFunnelStages( const totalResult = await prisma.$queryRaw<{ count: bigint }[]>` SELECT COUNT(*) as count FROM analytics_events - WHERE event_name = ${eventName} + WHERE workspace_id = ${workspaceId} + AND event_name = ${eventName} AND event_ts >= ${startDate} AND event_ts <= ${endDate} `; @@ -61,7 +64,8 @@ export async function getFunnelStages( const userResult = await prisma.$queryRaw<{ count: bigint }[]>` SELECT COUNT(DISTINCT user_id) as count FROM analytics_events - WHERE event_name = ${eventName} + WHERE workspace_id = ${workspaceId} + AND event_name = ${eventName} AND event_ts >= ${startDate} AND event_ts <= ${endDate} AND user_id IS NOT NULL @@ -107,10 +111,11 @@ export function calculateDropoffs(stages: FunnelStage[]): FunnelDropoff[] { } export async function getFunnelData( + workspaceId: string, startDate: Date, endDate: Date, ): Promise { - const stages = await getFunnelStages(startDate, endDate); + const stages = await getFunnelStages(workspaceId, startDate, endDate); const dropoffs = calculateDropoffs(stages); return { @@ -127,7 +132,9 @@ export type EventCountByType = { uniqueSessions: number; }; -export async function getEventCountsLast24h(): Promise { +export async function getEventCountsLast24h( + workspaceId: string, +): Promise { const startDate = new Date(Date.now() - 24 * 60 * 60 * 1000); const endDate = new Date(); @@ -145,7 +152,8 @@ export async function getEventCountsLast24h(): Promise { COUNT(DISTINCT user_id) as unique_users, COUNT(DISTINCT COALESCE(user_id, anonymous_session_id)) as unique_sessions FROM analytics_events - WHERE event_ts >= ${startDate} + WHERE workspace_id = ${workspaceId} + AND event_ts >= ${startDate} AND event_ts <= ${endDate} GROUP BY event_name ORDER BY count DESC @@ -168,33 +176,29 @@ export type FullFunnelPath = { }; export async function getFullFunnelPaths( + workspaceId: string, + startDate: Date, + endDate: Date, limit = 10, ): Promise { const results = await prisma.$queryRaw` - WITH ranked_events AS ( + WITH session_stages AS ( SELECT COALESCE(user_id, anonymous_session_id) as session_key, user_id, anonymous_session_id, workspace_id, - event_name, - event_ts, - ROW_NUMBER() OVER ( - PARTITION BY COALESCE(user_id, anonymous_session_id) - ORDER BY event_ts ASC - ) as rn - FROM analytics_events - WHERE event_name IN ('homepage_view', 'signup_started', 'signup_completed', 'onboarding_completed', 'plan_activated') - ), - session_stages AS ( - SELECT - session_key, - user_id, - anonymous_session_id, - workspace_id, - ARRAY_AGG(event_name ORDER BY rn) as stages, + ARRAY_AGG(event_name ORDER BY event_ts ASC) as stages, + MIN(CASE WHEN event_name = 'homepage_view' THEN event_ts END) as homepage_ts, + MIN(CASE WHEN event_name = 'signup_completed' THEN event_ts END) as signup_completed_ts, + MIN(CASE WHEN event_name = 'onboarding_completed' THEN event_ts END) as onboarding_completed_ts, + MIN(CASE WHEN event_name = 'plan_activated' THEN event_ts END) as plan_activated_ts, MAX(event_ts) as completed_at - FROM ranked_events + FROM analytics_events + WHERE workspace_id = ${workspaceId} + AND event_name IN ('homepage_view', 'signup_started', 'signup_completed', 'onboarding_completed', 'plan_activated') + AND event_ts >= ${startDate} + AND event_ts <= ${endDate} GROUP BY session_key, user_id, anonymous_session_id, workspace_id ) SELECT @@ -204,7 +208,13 @@ export async function getFullFunnelPaths( stages as "completedStages", completed_at as "completedAt" FROM session_stages - WHERE stages @> ARRAY['homepage_view', 'signup_completed', 'onboarding_completed', 'plan_activated']::text[] + WHERE homepage_ts IS NOT NULL + AND signup_completed_ts IS NOT NULL + AND onboarding_completed_ts IS NOT NULL + AND plan_activated_ts IS NOT NULL + AND homepage_ts <= signup_completed_ts + AND signup_completed_ts <= onboarding_completed_ts + AND onboarding_completed_ts <= plan_activated_ts ORDER BY completed_at DESC LIMIT ${limit} `; diff --git a/src/lib/analytics/validation.ts b/src/lib/analytics/validation.ts index 7866d58..1132330 100644 --- a/src/lib/analytics/validation.ts +++ b/src/lib/analytics/validation.ts @@ -16,13 +16,16 @@ export type ValidationResult = { summary: string; }; -export async function validateAnalyticsPipeline(): Promise { +export async function validateAnalyticsPipeline( + workspaceId: string, +): Promise { const checks: ValidationCheck[] = []; const homepageCount = await prisma.$queryRaw<{ count: bigint }[]>` SELECT COUNT(*) as count FROM analytics_events - WHERE event_name = 'homepage_view' + WHERE workspace_id = ${workspaceId} + AND event_name = 'homepage_view' `; const homepageViews = Number(homepageCount[0]?.count ?? 0); checks.push({ @@ -39,7 +42,8 @@ export async function validateAnalyticsPipeline(): Promise { COALESCE(user_id, anonymous_session_id) as session_key, ARRAY_AGG(DISTINCT event_name) as stages FROM analytics_events - WHERE event_name IN ('homepage_view', 'signup_completed', 'onboarding_completed', 'plan_activated') + WHERE workspace_id = ${workspaceId} + AND event_name IN ('homepage_view', 'signup_completed', 'onboarding_completed', 'plan_activated') GROUP BY COALESCE(user_id, anonymous_session_id) ) SELECT COUNT(*) as count @@ -59,8 +63,8 @@ export async function validateAnalyticsPipeline(): Promise { const malformedEvents = await prisma.$queryRaw<{ count: bigint }[]>` SELECT COUNT(*) as count FROM analytics_events - WHERE event_ts IS NULL - OR (user_id IS NULL AND anonymous_session_id IS NULL) + WHERE workspace_id = ${workspaceId} + AND (event_ts IS NULL OR (user_id IS NULL AND anonymous_session_id IS NULL)) `; const malformed = Number(malformedEvents[0]?.count ?? 0); checks.push({ @@ -77,6 +81,7 @@ export async function validateAnalyticsPipeline(): Promise { await prisma.$queryRaw` SELECT event_name, COUNT(*) as count FROM analytics_events + WHERE workspace_id = ${workspaceId} GROUP BY event_name ORDER BY count DESC LIMIT 10 @@ -96,6 +101,7 @@ export async function validateAnalyticsPipeline(): Promise { >` SELECT event_name, COUNT(*) as count FROM analytics_events + WHERE workspace_id = ${workspaceId} GROUP BY event_name ORDER BY event_name `; @@ -112,7 +118,8 @@ export async function validateAnalyticsPipeline(): Promise { const recentEvents = await prisma.$queryRaw<{ count: bigint }[]>` SELECT COUNT(*) as count FROM analytics_events - WHERE event_ts > NOW() - INTERVAL '24 hours' + WHERE workspace_id = ${workspaceId} + AND event_ts > NOW() - INTERVAL '24 hours' `; const last24h = Number(recentEvents[0]?.count ?? 0); checks.push({ diff --git a/tests/unit/api/analyticsFunnelRoute.test.ts b/tests/unit/api/analyticsFunnelRoute.test.ts new file mode 100644 index 0000000..838ef69 --- /dev/null +++ b/tests/unit/api/analyticsFunnelRoute.test.ts @@ -0,0 +1,117 @@ +jest.mock("@/lib/server/auth-guards", () => ({ + requireWorkspaceSession: jest.fn(), +})); + +jest.mock("@/lib/analytics/funnel", () => ({ + getFunnelData: jest.fn(), + getEventCountsLast24h: jest.fn(), + getFullFunnelPaths: jest.fn(), +})); + +import { + GET as getAnalyticsFunnel, + resolveDateRange, +} from "@/app/api/analytics/funnel/route"; + +const mockedGuards = jest.requireMock("@/lib/server/auth-guards") as { + requireWorkspaceSession: jest.Mock; +}; +const mockedFunnel = jest.requireMock("@/lib/analytics/funnel") as { + getFunnelData: jest.Mock; + getEventCountsLast24h: jest.Mock; + getFullFunnelPaths: jest.Mock; +}; + +async function readJson(res: Response) { + const text = await res.text(); + return text ? JSON.parse(text) : null; +} + +describe("analytics funnel route", () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedGuards.requireWorkspaceSession.mockResolvedValue({ + workspaceId: "ws_1", + user: { id: "u_1" }, + }); + mockedFunnel.getFunnelData.mockResolvedValue({ + stages: [], + dropoffs: [], + period: { + start: new Date("2026-02-10T00:00:00.000Z"), + end: new Date("2026-02-11T00:00:00.000Z"), + }, + }); + mockedFunnel.getEventCountsLast24h.mockResolvedValue([]); + mockedFunnel.getFullFunnelPaths.mockResolvedValue([]); + }); + + test("returns 400 for invalid explicit dates", async () => { + const res = await getAnalyticsFunnel( + new Request( + "http://test.local/api/analytics/funnel?start=nope&end=2026-02-11", + ), + ); + + expect(res.status).toBe(400); + expect(mockedFunnel.getFunnelData).not.toHaveBeenCalled(); + }); + + test("returns 400 when only one boundary date is provided", async () => { + const res = await getAnalyticsFunnel( + new Request("http://test.local/api/analytics/funnel?start=2026-02-10"), + ); + + expect(res.status).toBe(400); + expect(mockedFunnel.getEventCountsLast24h).not.toHaveBeenCalled(); + }); + + test("passes workspaceId to analytics query functions", async () => { + const res = await getAnalyticsFunnel( + new Request( + "http://test.local/api/analytics/funnel?start=2026-02-10T00:00:00.000Z&end=2026-02-11T00:00:00.000Z", + ), + ); + + expect(res.status).toBe(200); + expect(mockedFunnel.getFunnelData).toHaveBeenCalledWith( + "ws_1", + expect.any(Date), + expect.any(Date), + ); + expect(mockedFunnel.getEventCountsLast24h).toHaveBeenCalledWith("ws_1"); + expect(mockedFunnel.getFullFunnelPaths).toHaveBeenCalledWith( + "ws_1", + expect.any(Date), + expect.any(Date), + 5, + ); + }); + + test("resolveDateRange rejects invalid period values", () => { + const out = resolveDateRange(new URLSearchParams("period=1y")); + expect(out.ok).toBe(false); + }); + + test("resolveDateRange builds period window for defaults", () => { + const now = new Date("2026-02-18T12:00:00.000Z"); + const out = resolveDateRange(new URLSearchParams(), now); + expect(out.ok).toBe(true); + if (!out.ok) return; + expect(out.endDate.toISOString()).toBe("2026-02-18T12:00:00.000Z"); + expect(out.startDate.toISOString()).toBe("2026-02-11T12:00:00.000Z"); + }); + + test("returns auth errors when workspace session fails", async () => { + mockedGuards.requireWorkspaceSession.mockRejectedValue( + new Error("UNAUTHORIZED"), + ); + + const res = await getAnalyticsFunnel( + new Request("http://test.local/api/analytics/funnel"), + ); + const json = (await readJson(res)) as { error: string }; + expect(res.status).toBe(401); + expect(json.error).toBe("Unauthorized"); + }); +}); From df5a3b2fb66c096a54102385f1afea6d34eb136f Mon Sep 17 00:00:00 2001 From: Tanay Date: Wed, 18 Feb 2026 18:58:57 +0000 Subject: [PATCH 3/5] fix(analytics): enforce plan gating and tighten validation semantics --- src/app/api/analytics/funnel/route.ts | 11 +++ src/app/api/analytics/validation/route.ts | 11 +++ src/lib/analytics/validation.ts | 22 ++++-- tests/unit/api/analyticsFunnelRoute.test.ts | 22 ++++++ .../unit/api/analyticsValidationRoute.test.ts | 79 +++++++++++++++++++ tests/unit/lib/analyticsValidation.test.ts | 52 ++++++++++++ 6 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 tests/unit/api/analyticsValidationRoute.test.ts create mode 100644 tests/unit/lib/analyticsValidation.test.ts diff --git a/src/app/api/analytics/funnel/route.ts b/src/app/api/analytics/funnel/route.ts index 973ef26..18aaf51 100644 --- a/src/app/api/analytics/funnel/route.ts +++ b/src/app/api/analytics/funnel/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { requireWorkspaceSession } from "@/lib/server/auth-guards"; +import { getWorkspaceEntitlements } from "@/lib/billing/quota"; import { getFunnelData, getEventCountsLast24h, @@ -75,6 +76,16 @@ export async function GET(req: Request) { { status: 400 }, ); } + const entitlements = await getWorkspaceEntitlements(session.workspaceId); + if (!entitlements.hasAdvancedAnalytics) { + return NextResponse.json( + { + error: "Advanced analytics is available on paid plans", + code: "ADVANCED_ANALYTICS_REQUIRED", + }, + { status: 403 }, + ); + } const [funnelData, eventCounts, fullPaths] = await Promise.all([ getFunnelData(session.workspaceId, dateRange.startDate, dateRange.endDate), diff --git a/src/app/api/analytics/validation/route.ts b/src/app/api/analytics/validation/route.ts index e272297..6953c88 100644 --- a/src/app/api/analytics/validation/route.ts +++ b/src/app/api/analytics/validation/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { requireWorkspaceAdminSession } from "@/lib/server/admin-guards"; +import { getWorkspaceEntitlements } from "@/lib/billing/quota"; import { validateAnalyticsPipeline } from "@/lib/analytics/validation"; function authError(err: unknown) { @@ -15,6 +16,16 @@ export async function GET() { } catch (err) { return authError(err); } + const entitlements = await getWorkspaceEntitlements(session.workspaceId); + if (!entitlements.hasAdvancedAnalytics) { + return NextResponse.json( + { + error: "Advanced analytics is available on paid plans", + code: "ADVANCED_ANALYTICS_REQUIRED", + }, + { status: 403 }, + ); + } const result = await validateAnalyticsPipeline(session.workspaceId); diff --git a/src/lib/analytics/validation.ts b/src/lib/analytics/validation.ts index 1132330..268e27d 100644 --- a/src/lib/analytics/validation.ts +++ b/src/lib/analytics/validation.ts @@ -22,7 +22,7 @@ export async function validateAnalyticsPipeline( const checks: ValidationCheck[] = []; const homepageCount = await prisma.$queryRaw<{ count: bigint }[]>` - SELECT COUNT(*) as count + SELECT COUNT(DISTINCT COALESCE(user_id, anonymous_session_id)) as count FROM analytics_events WHERE workspace_id = ${workspaceId} AND event_name = 'homepage_view' @@ -40,21 +40,33 @@ export async function validateAnalyticsPipeline( WITH session_stages AS ( SELECT COALESCE(user_id, anonymous_session_id) as session_key, - ARRAY_AGG(DISTINCT event_name) as stages + MIN(CASE WHEN event_name = 'homepage_view' THEN event_ts END) as homepage_ts, + MIN(CASE WHEN event_name = 'signup_started' THEN event_ts END) as signup_started_ts, + MIN(CASE WHEN event_name = 'signup_completed' THEN event_ts END) as signup_completed_ts, + MIN(CASE WHEN event_name = 'onboarding_completed' THEN event_ts END) as onboarding_completed_ts, + MIN(CASE WHEN event_name = 'plan_activated' THEN event_ts END) as plan_activated_ts FROM analytics_events WHERE workspace_id = ${workspaceId} - AND event_name IN ('homepage_view', 'signup_completed', 'onboarding_completed', 'plan_activated') + AND event_name IN ('homepage_view', 'signup_started', 'signup_completed', 'onboarding_completed', 'plan_activated') GROUP BY COALESCE(user_id, anonymous_session_id) ) SELECT COUNT(*) as count FROM session_stages - WHERE stages @> ARRAY['homepage_view', 'signup_completed', 'onboarding_completed', 'plan_activated']::text[] + WHERE homepage_ts IS NOT NULL + AND signup_started_ts IS NOT NULL + AND signup_completed_ts IS NOT NULL + AND onboarding_completed_ts IS NOT NULL + AND plan_activated_ts IS NOT NULL + AND homepage_ts <= signup_started_ts + AND signup_started_ts <= signup_completed_ts + AND signup_completed_ts <= onboarding_completed_ts + AND onboarding_completed_ts <= plan_activated_ts `; const completeFunnels = Number(fullPaths[0]?.count ?? 0); checks.push({ id: "full_funnel_path", description: - "At least 1 full funnel path (homepage → signup → onboarding → plan)", + "At least 1 ordered full funnel path (homepage → signup started → signup completed → onboarding → plan)", passed: completeFunnels >= 1, value: completeFunnels, threshold: 1, diff --git a/tests/unit/api/analyticsFunnelRoute.test.ts b/tests/unit/api/analyticsFunnelRoute.test.ts index 838ef69..e1a8f07 100644 --- a/tests/unit/api/analyticsFunnelRoute.test.ts +++ b/tests/unit/api/analyticsFunnelRoute.test.ts @@ -2,6 +2,10 @@ jest.mock("@/lib/server/auth-guards", () => ({ requireWorkspaceSession: jest.fn(), })); +jest.mock("@/lib/billing/quota", () => ({ + getWorkspaceEntitlements: jest.fn(), +})); + jest.mock("@/lib/analytics/funnel", () => ({ getFunnelData: jest.fn(), getEventCountsLast24h: jest.fn(), @@ -16,6 +20,9 @@ import { const mockedGuards = jest.requireMock("@/lib/server/auth-guards") as { requireWorkspaceSession: jest.Mock; }; +const mockedQuota = jest.requireMock("@/lib/billing/quota") as { + getWorkspaceEntitlements: jest.Mock; +}; const mockedFunnel = jest.requireMock("@/lib/analytics/funnel") as { getFunnelData: jest.Mock; getEventCountsLast24h: jest.Mock; @@ -34,6 +41,9 @@ describe("analytics funnel route", () => { workspaceId: "ws_1", user: { id: "u_1" }, }); + mockedQuota.getWorkspaceEntitlements.mockResolvedValue({ + hasAdvancedAnalytics: true, + }); mockedFunnel.getFunnelData.mockResolvedValue({ stages: [], dropoffs: [], @@ -88,6 +98,18 @@ describe("analytics funnel route", () => { ); }); + test("returns 403 when advanced analytics is not enabled", async () => { + mockedQuota.getWorkspaceEntitlements.mockResolvedValue({ + hasAdvancedAnalytics: false, + }); + + const res = await getAnalyticsFunnel( + new Request("http://test.local/api/analytics/funnel"), + ); + expect(res.status).toBe(403); + expect(mockedFunnel.getFunnelData).not.toHaveBeenCalled(); + }); + test("resolveDateRange rejects invalid period values", () => { const out = resolveDateRange(new URLSearchParams("period=1y")); expect(out.ok).toBe(false); diff --git a/tests/unit/api/analyticsValidationRoute.test.ts b/tests/unit/api/analyticsValidationRoute.test.ts new file mode 100644 index 0000000..e27d5cc --- /dev/null +++ b/tests/unit/api/analyticsValidationRoute.test.ts @@ -0,0 +1,79 @@ +jest.mock("@/lib/server/admin-guards", () => ({ + requireWorkspaceAdminSession: jest.fn(), +})); + +jest.mock("@/lib/billing/quota", () => ({ + getWorkspaceEntitlements: jest.fn(), +})); + +jest.mock("@/lib/analytics/validation", () => ({ + validateAnalyticsPipeline: jest.fn(), +})); + +import { GET as getAnalyticsValidation } from "@/app/api/analytics/validation/route"; + +const mockedAdminGuards = jest.requireMock("@/lib/server/admin-guards") as { + requireWorkspaceAdminSession: jest.Mock; +}; +const mockedQuota = jest.requireMock("@/lib/billing/quota") as { + getWorkspaceEntitlements: jest.Mock; +}; +const mockedValidation = jest.requireMock("@/lib/analytics/validation") as { + validateAnalyticsPipeline: jest.Mock; +}; + +async function readJson(res: Response) { + const text = await res.text(); + return text ? JSON.parse(text) : null; +} + +describe("analytics validation route", () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedAdminGuards.requireWorkspaceAdminSession.mockResolvedValue({ + workspaceId: "ws_1", + user: { id: "u_1" }, + }); + mockedQuota.getWorkspaceEntitlements.mockResolvedValue({ + hasAdvancedAnalytics: true, + }); + mockedValidation.validateAnalyticsPipeline.mockResolvedValue({ + passed: true, + checks: [], + checkedAt: new Date("2026-02-18T00:00:00.000Z"), + summary: "ok", + }); + }); + + test("returns 401 when admin session is missing", async () => { + mockedAdminGuards.requireWorkspaceAdminSession.mockRejectedValue( + new Error("UNAUTHORIZED"), + ); + + const res = await getAnalyticsValidation(); + expect(res.status).toBe(401); + expect(mockedQuota.getWorkspaceEntitlements).not.toHaveBeenCalled(); + expect(mockedValidation.validateAnalyticsPipeline).not.toHaveBeenCalled(); + }); + + test("returns 403 when advanced analytics is not enabled", async () => { + mockedQuota.getWorkspaceEntitlements.mockResolvedValue({ + hasAdvancedAnalytics: false, + }); + + const res = await getAnalyticsValidation(); + const body = (await readJson(res)) as { code: string }; + + expect(res.status).toBe(403); + expect(body.code).toBe("ADVANCED_ANALYTICS_REQUIRED"); + expect(mockedValidation.validateAnalyticsPipeline).not.toHaveBeenCalled(); + }); + + test("passes workspaceId to validation library", async () => { + const res = await getAnalyticsValidation(); + expect(res.status).toBe(200); + expect(mockedValidation.validateAnalyticsPipeline).toHaveBeenCalledWith( + "ws_1", + ); + }); +}); diff --git a/tests/unit/lib/analyticsValidation.test.ts b/tests/unit/lib/analyticsValidation.test.ts new file mode 100644 index 0000000..516c483 --- /dev/null +++ b/tests/unit/lib/analyticsValidation.test.ts @@ -0,0 +1,52 @@ +jest.mock("@/lib/prisma", () => ({ + prisma: { + $queryRaw: jest.fn(), + }, +})); + +import { validateAnalyticsPipeline } from "@/lib/analytics/validation"; + +const mockedPrisma = jest.requireMock("@/lib/prisma").prisma as { + $queryRaw: jest.Mock; +}; + +function sqlFromTaggedCall(call: unknown[]): string { + const strings = call[0] as TemplateStringsArray; + return strings.join(" "); +} + +describe("validateAnalyticsPipeline", () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedPrisma.$queryRaw + .mockResolvedValueOnce([{ count: BigInt(10) }]) // homepage unique count + .mockResolvedValueOnce([{ count: BigInt(1) }]) // ordered full funnel + .mockResolvedValueOnce([{ count: BigInt(0) }]) // malformed events + .mockResolvedValueOnce([{ event_name: "homepage_view", count: BigInt(10) }]) // perf query + .mockResolvedValueOnce([{ event_name: "homepage_view", count: BigInt(10) }]) // event distribution + .mockResolvedValueOnce([{ count: BigInt(5) }]); // recent events + }); + + test("uses unique homepage identities and ordered full-funnel progression", async () => { + const result = await validateAnalyticsPipeline("ws_1"); + expect(result.passed).toBe(true); + + const homepageSql = sqlFromTaggedCall(mockedPrisma.$queryRaw.mock.calls[0]); + expect(homepageSql).toContain( + "COUNT(DISTINCT COALESCE(user_id, anonymous_session_id))", + ); + + const fullFunnelSql = sqlFromTaggedCall( + mockedPrisma.$queryRaw.mock.calls[1], + ); + expect(fullFunnelSql).toContain("signup_started"); + expect(fullFunnelSql).toContain("signup_completed"); + expect(fullFunnelSql).toContain("homepage_ts <= signup_started_ts"); + expect(fullFunnelSql).toContain( + "signup_started_ts <= signup_completed_ts", + ); + expect(fullFunnelSql).toContain( + "onboarding_completed_ts <= plan_activated_ts", + ); + }); +}); From b519599a9c93c9a90baa0db15d2a50333e985e4f Mon Sep 17 00:00:00 2001 From: Aditya Date: Fri, 27 Feb 2026 12:06:17 +0530 Subject: [PATCH 4/5] LIN-999: Fix resolveDateRange import in analytics funnel test --- tests/unit/api/analyticsFunnelRoute.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unit/api/analyticsFunnelRoute.test.ts b/tests/unit/api/analyticsFunnelRoute.test.ts index a0577c0..1c958b8 100644 --- a/tests/unit/api/analyticsFunnelRoute.test.ts +++ b/tests/unit/api/analyticsFunnelRoute.test.ts @@ -13,10 +13,8 @@ jest.mock("@/lib/analytics/funnel", () => ({ getTimeToFirstValueMetrics: jest.fn(), })); -import { - GET as getAnalyticsFunnel, - resolveDateRange, -} from "@/app/api/analytics/funnel/route"; +import { GET as getAnalyticsFunnel } from "@/app/api/analytics/funnel/route"; +import { resolveDateRange } from "@/lib/analytics/dateRange"; const mockedGuards = jest.requireMock("@/lib/server/auth-guards") as { requireWorkspaceSession: jest.Mock; From 978783c814fabddef6053cdd8d72c7423ee02cdc Mon Sep 17 00:00:00 2001 From: Aditya Date: Sat, 28 Feb 2026 11:18:25 +0530 Subject: [PATCH 5/5] RED-65: Outcome-first homepage redesign --- src/app/(public)/page.tsx | 377 ++---------------------- src/components/home/CTASection.tsx | 34 +++ src/components/home/FeaturesSection.tsx | 74 +++++ src/components/home/HeroSection.tsx | 95 ++++++ src/components/home/ProblemSection.tsx | 66 +++++ src/components/home/ProofSection.tsx | 105 +++++++ src/components/home/WorkflowSection.tsx | 71 +++++ src/components/home/index.ts | 6 + 8 files changed, 477 insertions(+), 351 deletions(-) create mode 100644 src/components/home/CTASection.tsx create mode 100644 src/components/home/FeaturesSection.tsx create mode 100644 src/components/home/HeroSection.tsx create mode 100644 src/components/home/ProblemSection.tsx create mode 100644 src/components/home/ProofSection.tsx create mode 100644 src/components/home/WorkflowSection.tsx create mode 100644 src/components/home/index.ts diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 65cabf4..e852ca6 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -1,106 +1,26 @@ -import Link from "next/link"; +import type { Metadata } from "next"; import { AnalyticsBeacon } from "@/components/analytics/AnalyticsBeacon"; -import { MaxWidth } from "@/components/public/MaxWidth"; - -const heroSignals = [ - "Safe output by default", - "Lower ban-risk workflow", - "Fast weekly execution loop", -]; - -const proofCards = [ - { - label: "Risk Score", - value: "12 / 100", - detail: "Draft checked against subreddit policy hints before approval", - }, - { - label: "Time To First Value", - value: "< 20 min", - detail: "Connect, generate first roadmap, and ship first safe draft cycle", - }, - { - label: "Approval Gate", - value: "100%", - detail: "Nothing posts without explicit human approval", - }, -]; - -const pillars = [ - { - title: "Find where you can post safely", - description: - "Map product goals to subreddits with rules, risk context, and realistic timing windows.", - }, - { - title: "Generate drafts without spam patterns", +import { + HeroSection, + ProblemSection, + WorkflowSection, + ProofSection, + FeaturesSection, + CTASection, +} from "@/components/home"; + +export const metadata: Metadata = { + title: + "RedditFast - Find Viral Reddit Content in Minutes | AI-Powered Discovery", + description: + "Discover trending Reddit posts, analyze subreddit rules, and get AI-generated drafts. Lower your ban risk while scaling your Reddit growth with 80% time saved.", + openGraph: { + title: "RedditFast - Find Viral Reddit Content in Minutes", description: - "Create variants with compliance cues and keep only drafts that pass risk checks.", + "Discover trending Reddit posts, analyze subreddit rules, and get AI-generated drafts. Start free today.", + type: "website", }, - { - title: "Ship fast with guardrails on", - description: - "Use approval-first scheduling with queue safety and post-publish feedback loops.", - }, -]; - -const steps = [ - { - title: "Create project + connect Reddit", - detail: - "Set tone, goals, and constraints. Then connect Reddit via OAuth with encrypted token storage.", - }, - { - title: "Generate recommendations + roadmap", - detail: - "Pick top subreddits and receive day-by-day post/comment tasks with suggested time windows.", - }, - { - title: "Approve, schedule, improve", - detail: - "Review every draft, schedule safely, and use analytics snapshots to improve the next cycle.", - }, -]; - -const featureGrid = [ - { - title: "Safety-first activation", - body: "Three clear steps to first value: create project, connect account, generate roadmap.", - }, - { - title: "Draft lifecycle controls", - body: "Move drafts through review states before scheduling to avoid accidental risky posting.", - }, - { - title: "Queue-backed execution", - body: "Use deterministic jobs and retries to prevent duplicate or unsafe publish behavior.", - }, - { - title: "Feedback loop analytics", - body: "Track risk, engagement, and timing to improve the next cycle instead of guessing.", - }, -]; - -const tools = [ - { - title: "Post generator", - description: - "Draft compliant post variants quickly with tone and structure guidance.", - href: "/tools/post-generator", - }, - { - title: "Subreddit analyzer", - description: - "Check rules, activity signals, and posting windows before committing to a subreddit.", - href: "/tools/subreddit-analyzer", - }, - { - title: "Shadowban check", - description: - "Validate visibility signals so you can slow down early if account risk rises.", - href: "/tools/shadowban-check", - }, -]; +}; export default function HomePage() { return ( @@ -110,257 +30,12 @@ export default function HomePage() { source="web_public" onceKey="public_homepage_view" /> -
    - -
    -
    -

    - Safety + quality + speed -

    -

    - Grow on Reddit without gambling your account. -

    -

    - ReditFast helps founders ship useful Reddit content with lower - ban risk: subreddit fit, safer drafts, approval-first - scheduling, and feedback loops. -

    -
    - - Start free - - - View plans - -
    -
    - {heroSignals.map((item) => ( -
    - {item} -
    - ))} -
    -
    -
    -
    -
    -
    -

    - Operator dashboard -

    -
    - {proofCards.map((card) => ( -
    -
    -

    {card.label}

    -

    - {card.value} -

    -
    -

    - {card.detail} -

    -
    - ))} -
    -
    -
    -
    - -
    - -
    - -
    - {pillars.map((item, index) => ( -
    -

    {item.title}

    -

    - {item.description} -

    -
    - ))} -
    -
    -
    - -
    - -
    -
    -

    - How it works -

    -

    - One operating loop from idea to measured outcome. -

    -

    - The product is built for durable systems, not one-off viral - attempts. Every task is planned, reviewed, and measured. -

    -
    -
    - {steps.map((step, index) => ( -
    -

    - Step {index + 1} -

    -

    {step.title}

    -

    - {step.detail} -

    -
    - ))} -
    -
    -
    -
    - -
    - -
    -
    -
    -

    - Product surface -

    -

    - Everything needed for a practical Reddit growth stack. -

    -
    - - Start activation flow - -
    -
    - {featureGrid.map((item, index) => ( -
    -

    {item.title}

    -

    - {item.body} -

    -
    - ))} -
    -
    -
    -
    - -
    - -
    -
    -

    - Free tools -

    -

    - Try core value before signup. -

    -
    - - View all tools - -
    -
    - {tools.map((tool, index) => ( - -

    {tool.title}

    -

    - {tool.description} -

    -

    - Open tool -

    - - ))} -
    -
    -
    - -
    - -
    -

    - Ready for safer Reddit growth with less guesswork? -

    -

    - Start with free tools, then move into the guided 3-step activation - flow and your first full execution cycle. -

    -
    - - Start free - - - View pricing - -
    -
    -
    -
    + + + + + + ); } diff --git a/src/components/home/CTASection.tsx b/src/components/home/CTASection.tsx new file mode 100644 index 0000000..70f0de6 --- /dev/null +++ b/src/components/home/CTASection.tsx @@ -0,0 +1,34 @@ +import Link from "next/link"; +import { MaxWidth } from "@/components/public/MaxWidth"; + +export function CTASection() { + return ( +
    + +
    +

    + Start Finding Viral Content Today +

    +

    + Join thousands of creators who have transformed their Reddit + strategy. Get started in minutes with our free tools. +

    +
    + + Start Free Trial + + + View Demo + +
    +
    +
    +
    + ); +} diff --git a/src/components/home/FeaturesSection.tsx b/src/components/home/FeaturesSection.tsx new file mode 100644 index 0000000..4720ba0 --- /dev/null +++ b/src/components/home/FeaturesSection.tsx @@ -0,0 +1,74 @@ +import { MaxWidth } from "@/components/public/MaxWidth"; +import { Target, ListTree, Wand2, Bell } from "lucide-react"; + +const features = [ + { + icon: Target, + title: "Viral Post Detection", + description: + "AI-powered scanning identifies trending posts across subreddits in real-time.", + }, + { + icon: ListTree, + title: "Subreddit Monitoring", + description: + "Track multiple subreddits with rule analysis and posting window alerts.", + }, + { + icon: Wand2, + title: "AI Content Suggestions", + description: + "Generate compliant post drafts optimized for each subreddit's audience.", + }, + { + icon: Bell, + title: "Trend Alerts", + description: + "Get notified immediately when topics in your niche start trending.", + }, +]; + +export function FeaturesSection() { + return ( +
    + +
    +

    + Features +

    +

    + Everything you need to grow on Reddit +

    +

    + Powerful tools designed for creators who value safety and + efficiency. +

    +
    +
    + {features.map((feature, index) => ( +
    +
    + +
    +

    {feature.title}

    +

    + {feature.description} +

    +
    + ))} +
    +
    +
    + ); +} diff --git a/src/components/home/HeroSection.tsx b/src/components/home/HeroSection.tsx new file mode 100644 index 0000000..e1adbf6 --- /dev/null +++ b/src/components/home/HeroSection.tsx @@ -0,0 +1,95 @@ +import Link from "next/link"; +import { MaxWidth } from "@/components/public/MaxWidth"; + +export function HeroSection() { + return ( +
    + +
    +
    +

    + Safety + quality + speed +

    +

    + Find Viral Reddit Content in Minutes, Not Hours. +

    +

    + Discover trending posts, analyze subreddit rules, and get + AI-generated drafts ready for approval—all in one platform. Lower + your ban risk while scaling your Reddit growth. +

    +
    + + Start Free + + + See How It Works + +
    +
    +
    + 10,000+ Posts Discovered +
    +
    + 80% Time Saved +
    +
    + Lower Ban Risk +
    +
    +
    +
    +
    +
    +
    +

    + Operator dashboard +

    +
    +
    +
    +

    Viral Posts Found

    +

    + 247 +

    +
    +

    + Trending content discovered this week +

    +
    +
    +
    +

    Time Saved

    +

    + 12h +

    +
    +

    + Manual research eliminated +

    +
    +
    +
    +

    Risk Score

    +

    + 12/100 +

    +
    +

    + Drafts checked against subreddit rules +

    +
    +
    +
    +
    +
    + +
    + ); +} diff --git a/src/components/home/ProblemSection.tsx b/src/components/home/ProblemSection.tsx new file mode 100644 index 0000000..132d11f --- /dev/null +++ b/src/components/home/ProblemSection.tsx @@ -0,0 +1,66 @@ +import { MaxWidth } from "@/components/public/MaxWidth"; +import { Search, Clock, TrendingUp } from "lucide-react"; + +const problems = [ + { + icon: Search, + title: "Finding Trending Posts Takes Hours", + description: + "Manually scrolling through subreddits to find viral content wastes precious time that could be spent creating.", + }, + { + icon: Clock, + title: "Missing Viral Opportunities", + description: + "By the time you discover a trending topic, the window for maximum engagement has already passed.", + }, + { + icon: TrendingUp, + title: "No Subreddit Research", + description: + "Posting to the wrong subreddit or at the wrong time leads to low engagement or account restrictions.", + }, +]; + +export function ProblemSection() { + return ( +
    + +
    +

    + The Problem +

    +

    + Stop Wasting Time on Manual Reddit Research +

    +

    + Content creators spend hours daily searching for trending topics. + Here's what's holding you back: +

    +
    +
    + {problems.map((problem, index) => ( +
    +
    + +
    +

    {problem.title}

    +

    + {problem.description} +

    +
    + ))} +
    +
    +
    + ); +} diff --git a/src/components/home/ProofSection.tsx b/src/components/home/ProofSection.tsx new file mode 100644 index 0000000..17c82b8 --- /dev/null +++ b/src/components/home/ProofSection.tsx @@ -0,0 +1,105 @@ +import { MaxWidth } from "@/components/public/MaxWidth"; +import { Quote } from "lucide-react"; + +const metrics = [ + { + value: "50,000+", + label: "Posts Discovered", + description: "Viral content found for creators", + }, + { + value: "80%", + label: "Time Saved", + description: "Average reduction in research time", + }, + { + value: "3x", + label: "Engagement Increase", + description: "Compared to manual posting", + }, +]; + +const testimonials = [ + { + name: "Alex Chen", + role: "Founder, TechStart", + quote: + "RedditFast helped me discover viral topics before competitors. My posts went from 50 upvotes to 500+ in just two weeks.", + }, + { + name: "Sarah Miller", + role: "Content Creator", + quote: + "I used to spend 3 hours daily searching Reddit. Now I have AI-generated drafts ready in 15 minutes. Game changer.", + }, + { + name: "James Wilson", + role: "Indie Hacker", + quote: + "The risk score feature saved my account multiple times. Finally, a tool that understands Reddit's rules.", + }, +]; + +export function ProofSection() { + return ( +
    + +
    +

    + Results +

    +

    + Results from creators using RedditFast +

    +
    + +
    + {metrics.map((metric, index) => ( +
    +

    {metric.value}

    +

    {metric.label}

    +

    + {metric.description} +

    +
    + ))} +
    + +
    + {testimonials.map((testimonial, index) => ( +
    + +

    + "{testimonial.quote}" +

    +
    +

    {testimonial.name}

    +

    + {testimonial.role} +

    +
    +
    + ))} +
    +
    +
    + ); +} diff --git a/src/components/home/WorkflowSection.tsx b/src/components/home/WorkflowSection.tsx new file mode 100644 index 0000000..4a15e2d --- /dev/null +++ b/src/components/home/WorkflowSection.tsx @@ -0,0 +1,71 @@ +import { MaxWidth } from "@/components/public/MaxWidth"; +import { Link2, Sparkles, FileText } from "lucide-react"; + +const steps = [ + { + number: 1, + icon: Link2, + title: "Connect Your Reddit Sources", + description: + "Link your Reddit account and select the subreddits you want to monitor. Our encrypted token storage keeps your data safe.", + }, + { + number: 2, + icon: Sparkles, + title: "AI Analyzes Trending Content", + description: + "Our AI scans thousands of posts in real-time, identifying viral content, emerging trends, and engagement opportunities.", + }, + { + number: 3, + icon: FileText, + title: "Get Ready-to-Use Viral Posts", + description: + "Receive AI-generated drafts optimized for each subreddit, complete with compliance checks and risk scores.", + }, +]; + +export function WorkflowSection() { + return ( +
    + +
    +

    + How RedditFast Works +

    +

    + Three Simple Steps to Viral Content +

    +

    + From discovery to draft—automated in minutes, not hours. +

    +
    +
    + {steps.map((step, index) => ( +
    +
    + {step.number} +
    +
    + +
    +

    {step.title}

    +

    + {step.description} +

    +
    + ))} +
    +
    +
    + ); +} diff --git a/src/components/home/index.ts b/src/components/home/index.ts new file mode 100644 index 0000000..24bcb2e --- /dev/null +++ b/src/components/home/index.ts @@ -0,0 +1,6 @@ +export { HeroSection } from "./HeroSection"; +export { ProblemSection } from "./ProblemSection"; +export { WorkflowSection } from "./WorkflowSection"; +export { ProofSection } from "./ProofSection"; +export { FeaturesSection } from "./FeaturesSection"; +export { CTASection } from "./CTASection";