/** * Aggregate analytics for a recording. * * Owner-only — uses assertAccess at editor level (owners always satisfy). * * Returns: views (total counted human view sessions, so a returning viewer * counts again), agentViews (outside agents reading the clip's agent APIs), * uniqueViewers (distinct people behind those views), completionRate, * dropOff (100 buckets), ctaConversionRate. * * Usage: * pnpm action get-recording-insights --recordingId= */ import { defineAction } from "@agent-native/core"; import { assertAccess } from "@agent-native/core/sharing"; import { count, eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { countRecordingAgentViews, listRecordingAgentViewers, } from "../server/lib/agent-views.js"; import { clampCompletionPct, displayViewerName, isCountedViewerRow, } from "../shared/view-analytics.js"; export default defineAction({ description: "Aggregate analytics for a recording — views, unique viewers, completion rate, drop-off curve, CTA conversion.", schema: z.object({ recordingId: z.string().describe("Recording ID"), }), http: { method: "GET" }, run: async (args) => { await assertAccess("recording", args.recordingId, "editor"); const db = getDb(); const viewerRows = await db .select() .from(schema.recordingViewers) .where(eq(schema.recordingViewers.recordingId, args.recordingId)); const events = await db .select() .from(schema.recordingEvents) .where(eq(schema.recordingEvents.recordingId, args.recordingId)); const [[viewLogRow], agentViews, agentViewers] = await Promise.all([ db .select({ value: count() }) .from(schema.recordingViews) .where(eq(schema.recordingViews.recordingId, args.recordingId)), countRecordingAgentViews(args.recordingId), listRecordingAgentViewers(args.recordingId), ]); // Same definition as `countedViewCondition`, applied to rows already in // memory so this action keeps its single viewer-row read. One row per // person, so this is the distinct-viewer count, not the view total. const countedViewers = viewerRows.filter(isCountedViewerRow).length; const uniqueViewers = new Set( viewerRows .filter(isCountedViewerRow) .map((v) => v.viewerEmail ?? `anon:${v.id}`), ).size; // Mirrors `countRecordingViews`: `recording_views` only exists from // migration v46, so clips recorded before it have zero log rows. Floor the // total at the counted-viewer count so those clips keep reporting a real // number instead of 0, and so total can never read below uniqueViewers. const views = Math.max(Number(viewLogRow?.value ?? 0), countedViewers); const completionRate = viewerRows.length === 0 ? 0 : viewerRows.reduce( (acc, v) => acc + clampCompletionPct(v.completedPct), 0, ) / viewerRows.length; // Drop-off: 100 buckets across the video's duration. // Use the recording's duration as the denominator. const [rec] = await db .select({ durationMs: schema.recordings.durationMs }) .from(schema.recordings) .where(eq(schema.recordings.id, args.recordingId)) .limit(1); const durationMs = Math.max(1, rec?.durationMs ?? 0); const buckets = Array.from({ length: 100 }, (_, i) => ({ bucket: i, watching: 0, })); for (const v of viewerRows) { const pct = clampCompletionPct(v.completedPct); // Each viewer contributes to all buckets up to their max reached. for (let i = 0; i < pct; i++) { buckets[i].watching += 1; } } const ctaClicks = events.filter((e) => e.kind === "cta-click").length; // CTA conversion is per person, so the denominator is counted viewers — not // `views`, which counts repeat sessions from the same viewer. const ctaConversionRate = countedViewers === 0 ? 0 : Math.min(100, (ctaClicks / countedViewers) * 100); // Top viewers by total watch ms const topViewers = viewerRows .slice() .sort((a, b) => (b.totalWatchMs ?? 0) - (a.totalWatchMs ?? 0)) .slice(0, 20) .map((v) => ({ viewerEmail: v.viewerEmail, viewerName: displayViewerName(v.viewerName), totalWatchMs: v.totalWatchMs ?? 0, completedPct: clampCompletionPct(v.completedPct), })); return { views, agentViews, agentViewers, uniqueViewers, completionRate, ctaConversionRate, dropOff: buckets, topViewers, durationMs, }; }, });