/** * Aggregate analytics across an entire organization. * * Produces: * - totals for the period (views, reactions, comments, recordings) * - top videos by counted views / reactions / comments * - top creators (by recordings, views, engagement) * - day-by-day engagement trend (views + reactions + comments per day) * * Usage: * pnpm action get-organization-insights * pnpm action get-organization-insights --organizationId= --days=14 */ import { defineAction } from "@agent-native/core"; import { and, desc, eq, gte, inArray } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { requireOrganizationAccess } from "../server/lib/recordings.js"; function startOfDay(d: Date): Date { const out = new Date(d); out.setHours(0, 0, 0, 0); return out; } function isoDate(d: Date): string { return d.toISOString().slice(0, 10); } // Hard cap on how many recordings/engagement rows we pull into memory per // call so a very large organization can't turn this into an unbounded scan. // Insights are inherently approximate at this scale — the response flags // `truncated` so the UI can say "showing the most recent N recordings". const MAX_RECORDINGS = 2000; const MAX_ENGAGEMENT_ROWS = 20000; export default defineAction({ description: "Organization-wide insights for the Insights Hub. Returns totals for the period, top videos by views / reactions / comments, top creators, and a day-by-day engagement trend time series. Default period is the last 30 days.", schema: z.object({ organizationId: z .string() .optional() .describe( "Organization id — falls back to the caller's active organization when omitted.", ), days: z.coerce.number().int().min(1).max(365).default(30), topN: z.coerce.number().int().min(1).max(50).default(10), }), http: { method: "GET" }, run: async (args) => { const db = getDb(); const { organizationId } = await requireOrganizationAccess( args.organizationId, ); const now = new Date(); const start = startOfDay( new Date(now.getTime() - (args.days - 1) * 24 * 60 * 60 * 1000), ); const startIso = start.toISOString(); const endIso = now.toISOString(); // All recordings in this organization. Filter engagement down to this set. // Bounded to MAX_RECORDINGS (most recent first) and projected down to the // columns this action actually reads, instead of loading full rows. const recordings = await db .select({ id: schema.recordings.id, title: schema.recordings.title, ownerEmail: schema.recordings.ownerEmail, createdAt: schema.recordings.createdAt, trashedAt: schema.recordings.trashedAt, }) .from(schema.recordings) .where(eq(schema.recordings.organizationId, organizationId)) .orderBy(desc(schema.recordings.createdAt)) .limit(MAX_RECORDINGS); const truncatedRecordings = recordings.length >= MAX_RECORDINGS; const recordingIds = recordings.map((r) => r.id); const titleById = new Map(recordings.map((r) => [r.id, r.title] as const)); const ownerById = new Map( recordings.map((r) => [r.id, r.ownerEmail] as const), ); // Totals for the period. const totals = { views: 0, reactions: 0, comments: 0, recordings: recordings.filter( (r) => r.createdAt >= startIso && !r.trashedAt, ).length, }; // Views: counted viewers first-viewed within the period. Projected to the // two columns used below, and capped so a very active org can't force an // unbounded row load. const viewerRows = recordingIds.length ? await db .select({ recordingId: schema.recordingViewers.recordingId, firstViewedAt: schema.recordingViewers.firstViewedAt, }) .from(schema.recordingViewers) .where( and( inArray(schema.recordingViewers.recordingId, recordingIds), eq(schema.recordingViewers.countedView, true), gte(schema.recordingViewers.firstViewedAt, startIso), ), ) .limit(MAX_ENGAGEMENT_ROWS) : []; totals.views = viewerRows.length; const truncatedViewers = viewerRows.length >= MAX_ENGAGEMENT_ROWS; const reactionRows = recordingIds.length ? await db .select({ recordingId: schema.recordingReactions.recordingId, createdAt: schema.recordingReactions.createdAt, }) .from(schema.recordingReactions) .where( and( inArray(schema.recordingReactions.recordingId, recordingIds), gte(schema.recordingReactions.createdAt, startIso), ), ) .limit(MAX_ENGAGEMENT_ROWS) : []; totals.reactions = reactionRows.length; const truncatedReactions = reactionRows.length >= MAX_ENGAGEMENT_ROWS; const commentRows = recordingIds.length ? await db .select({ recordingId: schema.recordingComments.recordingId, createdAt: schema.recordingComments.createdAt, }) .from(schema.recordingComments) .where( and( inArray(schema.recordingComments.recordingId, recordingIds), gte(schema.recordingComments.createdAt, startIso), ), ) .limit(MAX_ENGAGEMENT_ROWS) : []; totals.comments = commentRows.length; const truncatedComments = commentRows.length >= MAX_ENGAGEMENT_ROWS; // Top videos. const viewsByRec: Record = {}; for (const v of viewerRows) { viewsByRec[v.recordingId] = (viewsByRec[v.recordingId] ?? 0) + 1; } const reactionsByRec: Record = {}; for (const r of reactionRows) { reactionsByRec[r.recordingId] = (reactionsByRec[r.recordingId] ?? 0) + 1; } const commentsByRec: Record = {}; for (const c of commentRows) { commentsByRec[c.recordingId] = (commentsByRec[c.recordingId] ?? 0) + 1; } const mk = (counts: Record) => Object.entries(counts) .map(([id, count]) => ({ id, title: titleById.get(id) ?? "Untitled", count, })) .sort((a, b) => b.count - a.count) .slice(0, args.topN); const topVideos = { byViews: mk(viewsByRec), byReactions: mk(reactionsByRec), byComments: mk(commentsByRec), }; // Top creators — combine views + reactions + comments per owner, over the period. const creatorStats: Record< string, { email: string; recordings: number; views: number; engagement: number } > = {}; for (const r of recordings) { const email = r.ownerEmail; creatorStats[email] ??= { email, recordings: 0, views: 0, engagement: 0, }; creatorStats[email].recordings += 1; } for (const v of viewerRows) { const email = ownerById.get(v.recordingId); if (email && creatorStats[email]) creatorStats[email].views += 1; } for (const r of reactionRows) { const email = ownerById.get(r.recordingId); if (email && creatorStats[email]) creatorStats[email].engagement += 1; } for (const c of commentRows) { const email = ownerById.get(c.recordingId); if (email && creatorStats[email]) creatorStats[email].engagement += 1; } const topCreators = Object.values(creatorStats) .sort((a, b) => b.views + b.engagement - (a.views + a.engagement)) .slice(0, args.topN); // Trend: per-day tallies for the period. const trendMap = new Map< string, { date: string; views: number; reactions: number; comments: number } >(); for (let i = 0; i < args.days; i++) { const d = new Date(start.getTime() + i * 24 * 60 * 60 * 1000); const key = isoDate(d); trendMap.set(key, { date: key, views: 0, reactions: 0, comments: 0 }); } function bumpTrend(kind: "views" | "reactions" | "comments", iso: string) { const day = iso.slice(0, 10); const entry = trendMap.get(day); if (entry) entry[kind] += 1; } for (const v of viewerRows) bumpTrend("views", v.firstViewedAt); for (const r of reactionRows) bumpTrend("reactions", r.createdAt); for (const c of commentRows) bumpTrend("comments", c.createdAt); const trend = Array.from(trendMap.values()); return { organizationId, period: { days: args.days, start: startIso, end: endIso }, totals, topVideos, topCreators, trend, // True when the organization has more recordings/engagement rows than // the bounded scan below covers — totals/trend reflect only the most // recent MAX_RECORDINGS recordings and/or the first MAX_ENGAGEMENT_ROWS // matching rows per engagement table in that case. truncated: truncatedRecordings || truncatedViewers || truncatedReactions || truncatedComments, }; }, });