diff --git a/apps/backend/src/bootstrap/loaders/express.ts b/apps/backend/src/bootstrap/loaders/express.ts index 8f3463885..32ddf310b 100644 --- a/apps/backend/src/bootstrap/loaders/express.ts +++ b/apps/backend/src/bootstrap/loaders/express.ts @@ -12,6 +12,7 @@ import routeRedirectRoutes from "../../modules/route-redirect/routes"; import semanticSearchRoutes from "../../modules/semantic-search/routes"; import staffRoutes from "../../modules/staff/routes"; import targetedMessageRoutes from "../../modules/targeted-message/routes"; +import trackingRoutes from "../../modules/tracking/routes"; import passportLoader from "./passport"; export default async ( @@ -79,6 +80,9 @@ export default async ( targetedMessageRoutes(root, redis); } + // load tracking beacon route + trackingRoutes(app, redis); + // load semantic search routes app.use("/semantic-search", semanticSearchRoutes); diff --git a/apps/backend/src/bootstrap/loaders/index.ts b/apps/backend/src/bootstrap/loaders/index.ts index 9fd94dbdd..948b049a9 100644 --- a/apps/backend/src/bootstrap/loaders/index.ts +++ b/apps/backend/src/bootstrap/loaders/index.ts @@ -4,6 +4,7 @@ import { config } from "../../../../../packages/common/src/utils/config"; import { startBannerViewCountFlushJob } from "../../modules/banner/jobs/flush-view-counts"; import { startViewCountFlushJob } from "../../modules/class/jobs/flush-view-counts"; import { startClickEventsFlushJob } from "../../modules/click-tracking/jobs/flush-click-events"; +import { startTrackingEventsFlushJob } from "../../modules/tracking/jobs/flush-tracking-events"; import { startActivityScoreUpdateJob } from "../../modules/user/jobs/update-activity-scores"; // loaders import apolloLoader from "./apollo"; @@ -35,6 +36,7 @@ export default async (root: Application): Promise => { startBannerViewCountFlushJob(apolloRedis); startClickEventsFlushJob(apolloRedis); startActivityScoreUpdateJob(); + startTrackingEventsFlushJob(apolloRedis); // append backend path to all routes root.use(config.backendPath, app); diff --git a/apps/backend/src/modules/banner/routes.ts b/apps/backend/src/modules/banner/routes.ts index f5a44eb3f..2b3224c1d 100644 --- a/apps/backend/src/modules/banner/routes.ts +++ b/apps/backend/src/modules/banner/routes.ts @@ -4,6 +4,7 @@ import type { RedisClientType } from "redis"; import { BannerModel } from "@repo/common/models"; import { trackIntensiveClick } from "../click-tracking/controller"; +import { bufferTrackingEvents } from "../tracking/controller"; export default (app: Application, redis?: RedisClientType) => { // Redirect-based click tracking for all banners @@ -23,10 +24,25 @@ export default (app: Application, redis?: RedisClientType) => { return res.redirect("/"); } - // Track click event if enabled and redis is available - if (banner.clickEventLogging && redis) { - trackIntensiveClick(redis, req, bannerId, "banner").catch((error) => { - console.error("Error tracking banner click event:", error); + if (redis) { + // Legacy intensive click tracking (opt-in per banner) + if (banner.clickEventLogging) { + trackIntensiveClick(redis, req, bannerId, "banner").catch((error) => { + console.error("Error tracking banner click event:", error); + }); + } + + // Unified tracking — always emitted + bufferTrackingEvents(redis, req, [ + { + eventType: "click", + targetType: "banner", + targetId: bannerId, + metadata: { version: banner.currentVersion }, + timestamp: new Date().toISOString(), + }, + ]).catch((error) => { + console.error("Error buffering banner tracking event:", error); }); } diff --git a/apps/backend/src/modules/catalog/controller.ts b/apps/backend/src/modules/catalog/controller.ts index 736aae9ad..8ec7e3cf4 100644 --- a/apps/backend/src/modules/catalog/controller.ts +++ b/apps/backend/src/modules/catalog/controller.ts @@ -25,13 +25,13 @@ import { } from "@repo/common/models"; import { getFields, hasFieldPath } from "../../utils/graphql"; -import { searchSemantic } from "../semantic-search/client"; import { formatClass, formatSection } from "../class/formatter"; import type { ClassModule } from "../class/generated-types/module-types"; import { formatCourse } from "../course/formatter"; import { formatEnrollment } from "../enrollment/formatter"; import type { EnrollmentModule } from "../enrollment/generated-types/module-types"; import type { GradeDistributionModule } from "../grade-distribution/generated-types/module-types"; +import { searchSemantic } from "../semantic-search/client"; import { getCachedCatalog, getSearchIndex } from "./catalog-cache"; export interface CatalogQueryParams { diff --git a/apps/backend/src/modules/index.ts b/apps/backend/src/modules/index.ts index bb26fefdb..ad3dd7cd2 100644 --- a/apps/backend/src/modules/index.ts +++ b/apps/backend/src/modules/index.ts @@ -19,6 +19,7 @@ import Schedule from "./schedule"; import Staff from "./staff"; import TargetedMessage from "./targeted-message"; import Term from "./term"; +import Tracking from "./tracking"; import User from "./user"; const modules = [ @@ -42,6 +43,7 @@ const modules = [ RouteRedirect, Pod, TargetedMessage, + Tracking, ]; export const resolvers = merge(modules.map((module) => module.resolver)); diff --git a/apps/backend/src/modules/rating/formatter.ts b/apps/backend/src/modules/rating/formatter.ts index b148c82db..151480e27 100644 --- a/apps/backend/src/modules/rating/formatter.ts +++ b/apps/backend/src/modules/rating/formatter.ts @@ -36,8 +36,9 @@ export const formatUserRatings = (ratings: UserRatings): UserRatings => { reviewTitle: (userClass as UserClass & { reviewTitle?: string | null }) .reviewTitle, - reviewContent: (userClass as UserClass & { reviewContent?: string | null }) - .reviewContent, + reviewContent: ( + userClass as UserClass & { reviewContent?: string | null } + ).reviewContent, lastUpdated: userClass.lastUpdated?.toString(), })), }; diff --git a/apps/backend/src/modules/route-redirect/routes.ts b/apps/backend/src/modules/route-redirect/routes.ts index fd2b869bb..13cdb835c 100644 --- a/apps/backend/src/modules/route-redirect/routes.ts +++ b/apps/backend/src/modules/route-redirect/routes.ts @@ -4,6 +4,7 @@ import type { RedisClientType } from "redis"; import { RouteRedirectModel } from "@repo/common/models"; import { trackIntensiveClick } from "../click-tracking/controller"; +import { bufferTrackingEvents } from "../tracking/controller"; export default (app: Application, redis?: RedisClientType) => { // Server-side redirect handler for snappy redirects @@ -25,15 +26,30 @@ export default (app: Application, redis?: RedisClientType) => { return res.redirect("/"); } - // Track click event if enabled and redis is available - if (redirect.clickEventLogging && redis) { - trackIntensiveClick( - redis, - req, - redirect._id.toString(), - "redirect" - ).catch((error) => { - console.error("Error tracking redirect click event:", error); + if (redis) { + // Legacy intensive click tracking (opt-in per redirect) + if (redirect.clickEventLogging) { + trackIntensiveClick( + redis, + req, + redirect._id.toString(), + "redirect" + ).catch((error) => { + console.error("Error tracking redirect click event:", error); + }); + } + + // Unified tracking — always emitted + bufferTrackingEvents(redis, req, [ + { + eventType: "click", + targetType: "redirect", + targetId: redirect._id.toString(), + metadata: { fromPath, toPath: redirect.toPath }, + timestamp: new Date().toISOString(), + }, + ]).catch((error) => { + console.error("Error buffering redirect tracking event:", error); }); } diff --git a/apps/backend/src/modules/targeted-message/routes.ts b/apps/backend/src/modules/targeted-message/routes.ts index edf259e7a..3399bea52 100644 --- a/apps/backend/src/modules/targeted-message/routes.ts +++ b/apps/backend/src/modules/targeted-message/routes.ts @@ -4,6 +4,7 @@ import type { RedisClientType } from "redis"; import { TargetedMessageModel } from "@repo/common/models"; import { trackIntensiveClick } from "../click-tracking/controller"; +import { bufferTrackingEvents } from "../tracking/controller"; export default (app: Application, redis?: RedisClientType) => { // Redirect-based click tracking for targeted messages @@ -43,12 +44,41 @@ export default (app: Application, redis?: RedisClientType) => { }) : undefined; - // Track click event if enabled and redis is available - if (message.clickEventLogging && redis) { - trackIntensiveClick(redis, req, messageId, "targeted-message", { - additionalInfo: resolvedAdditionalInfo, - }).catch((error) => { - console.error("Error tracking targeted message click event:", error); + if (redis) { + // Legacy intensive click tracking (opt-in per message) + if (message.clickEventLogging) { + trackIntensiveClick(redis, req, messageId, "targeted-message", { + additionalInfo: resolvedAdditionalInfo, + }).catch((error) => { + console.error( + "Error tracking targeted message click event:", + error + ); + }); + } + + // Unified tracking — always emitted + bufferTrackingEvents(redis, req, [ + { + eventType: "click", + targetType: "targeted-message", + targetId: messageId, + metadata: matchedCourse + ? { + courseId: courseIdParam, + subject: matchedCourse.subject, + courseNumber: matchedCourse.courseNumber, + semester: semesterParam, + year: yearParam ? Number(yearParam) : undefined, + } + : undefined, + timestamp: new Date().toISOString(), + }, + ]).catch((error) => { + console.error( + "Error buffering targeted-message tracking event:", + error + ); }); } diff --git a/apps/backend/src/modules/tracking/controller.ts b/apps/backend/src/modules/tracking/controller.ts new file mode 100644 index 000000000..8aa84acd9 --- /dev/null +++ b/apps/backend/src/modules/tracking/controller.ts @@ -0,0 +1,241 @@ +import { createHash } from "crypto"; +import type { Request } from "express"; +import type { RedisClientType } from "redis"; + +import { TrackingEventModel } from "@repo/common/models"; + +import { getClientIP } from "../../utils/ip"; + +const REDIS_BUFFER_KEY = "tracking-events-buffer"; +const MAX_BATCH_SIZE = 50; +const MAX_BUFFER_SIZE = 100_000; + +// Rate limiting: max 30 calls per IP per minute +const RATE_LIMIT_WINDOW_S = 60; +const RATE_LIMIT_MAX_CALLS = 30; + +// Per-event field limits +const MAX_STRING_FIELD_LEN = 128; +const MAX_METADATA_BYTES = 1_024; +const MAX_METADATA_KEYS = 20; +const MAX_METADATA_DEPTH = 2; + +export interface TrackingEventInput { + eventType: string; + targetType: string; + targetId?: string; + metadata?: Record; + timestamp: string; +} + +interface BufferedTrackingEvent extends TrackingEventInput { + sessionId: string; + userId?: string; + ipHash: string; + userAgent?: string; + referrer?: string; +} + +const hashIP = (ip: string): string => { + return createHash("sha256").update(ip).digest("hex"); +}; + +// Allow alphanumeric, underscore, hyphen, dot, colon, slash — covers all real event/target type values +const sanitizeToken = (s: string, maxLen: number): string => + s.replace(/[^\w\-.:/]/g, "").slice(0, maxLen); + +const sanitizeMetadata = ( + obj: Record, + depth = 0 +): Record => { + if (depth >= MAX_METADATA_DEPTH) return {}; + const result: Record = {}; + let keyCount = 0; + for (const [k, v] of Object.entries(obj)) { + if (keyCount >= MAX_METADATA_KEYS) break; + // Strip MongoDB operator prefix ($) and dot notation to prevent query injection + const safeKey = k.replace(/[$.]/g, "_").slice(0, 64); + if (typeof v === "string") { + result[safeKey] = v.slice(0, 256); + } else if (typeof v === "number" || typeof v === "boolean" || v === null) { + result[safeKey] = v; + } else if (typeof v === "object" && !Array.isArray(v) && v !== null) { + result[safeKey] = sanitizeMetadata( + v as Record, + depth + 1 + ); + } else { + result[safeKey] = String(v).slice(0, 256); + } + keyCount++; + } + return result; +}; + +const sanitizeEvent = (event: TrackingEventInput): TrackingEventInput => { + const sanitized: TrackingEventInput = { + eventType: sanitizeToken(event.eventType, MAX_STRING_FIELD_LEN), + targetType: sanitizeToken(event.targetType, MAX_STRING_FIELD_LEN), + targetId: event.targetId + ? sanitizeToken(event.targetId, MAX_STRING_FIELD_LEN) + : undefined, + timestamp: event.timestamp, + metadata: undefined, + }; + + if (event.metadata) { + const sanitizedMeta = sanitizeMetadata(event.metadata); + if (JSON.stringify(sanitizedMeta).length <= MAX_METADATA_BYTES) { + sanitized.metadata = sanitizedMeta; + } + // Metadata exceeding 1KB after sanitization is dropped entirely + } + + return sanitized; +}; + +export const bufferTrackingEvents = async ( + redis: RedisClientType, + req: Request, + events: TrackingEventInput[] +): Promise => { + if (events.length > MAX_BATCH_SIZE) { + throw new Error(`Batch size exceeds maximum of ${MAX_BATCH_SIZE}`); + } + + const currentLen = await redis.lLen(REDIS_BUFFER_KEY); + if (currentLen >= MAX_BUFFER_SIZE) { + console.warn( + `[TrackingEvents] Buffer at capacity (${currentLen}), dropping batch of ${events.length}` + ); + return; + } + + const ip = getClientIP(req); + const ipHash = hashIP(ip); + + const rateLimitKey = `tracking:ratelimit:${ipHash}`; + const callCount = await redis.incr(rateLimitKey); + if (callCount === 1) await redis.expire(rateLimitKey, RATE_LIMIT_WINDOW_S); + if (callCount > RATE_LIMIT_MAX_CALLS) return; + + const userAgent = (req.get("user-agent") || "").slice(0, 500) || undefined; + const referrer = req.get("referer") || req.get("referrer") || undefined; + const sessionId = req.sessionID || "anonymous"; + const userId = (req.user as { _id?: string } | undefined)?._id; + + const buffered: BufferedTrackingEvent[] = events.map((event) => ({ + ...sanitizeEvent(event), + sessionId, + userId, + ipHash, + userAgent, + referrer, + })); + + // Push all events to a single Redis list + await redis.rPush( + REDIS_BUFFER_KEY, + buffered.map((e) => JSON.stringify(e)) + ); +}; + +const FLUSH_CHUNK_SIZE = 500; + +export const flushTrackingEvents = async ( + redis: RedisClientType +): Promise<{ flushed: number; errors: number }> => { + let flushed = 0; + let errors = 0; + + while (true) { + const raw = await redis.lRange(REDIS_BUFFER_KEY, 0, FLUSH_CHUNK_SIZE - 1); + if (raw.length === 0) break; + + // Parse individually — one malformed entry discarded, not blocking entire chunk + const documents: object[] = []; + for (const json of raw) { + try { + const event = JSON.parse(json) as BufferedTrackingEvent; + documents.push({ + eventType: event.eventType, + targetType: event.targetType, + targetId: event.targetId, + metadata: event.metadata, + sessionId: event.sessionId, + userId: event.userId, + timestamp: new Date(event.timestamp), + ipHash: event.ipHash, + userAgent: event.userAgent, + referrer: event.referrer, + }); + } catch { + console.warn("[TrackingEvents Flush] Discarding malformed event"); + errors++; + } + } + + if (documents.length > 0) { + try { + await TrackingEventModel.insertMany(documents, { ordered: false }); + flushed += documents.length; + await redis.lTrim(REDIS_BUFFER_KEY, raw.length, -1); + } catch (error) { + console.error("[TrackingEvents Flush] insertMany error:", error); + errors++; + break; + } + } else { + await redis.lTrim(REDIS_BUFFER_KEY, raw.length, -1); + } + + if (raw.length < FLUSH_CHUNK_SIZE) break; + } + + return { flushed, errors }; +}; + +export interface TrackingEventTimeSeriesPoint { + date: string; + count: number; +} + +export const getTrackingEventsTimeSeries = async ( + eventType?: string, + targetType?: string, + targetId?: string, + startDate?: Date, + endDate?: Date +): Promise => { + const match: Record = {}; + + if (eventType) match.eventType = eventType; + if (targetType) match.targetType = targetType; + if (targetId) match.targetId = targetId; + + if (startDate || endDate) { + match.timestamp = {}; + if (startDate) { + (match.timestamp as Record).$gte = startDate; + } + if (endDate) { + (match.timestamp as Record).$lte = endDate; + } + } + + const results = await TrackingEventModel.aggregate<{ + _id: string; + count: number; + }>([ + { $match: match }, + { + $group: { + _id: { $dateToString: { format: "%Y-%m-%d", date: "$timestamp" } }, + count: { $sum: 1 }, + }, + }, + { $sort: { _id: 1 } }, + ]); + + return results.map((r) => ({ date: r._id, count: r.count })); +}; diff --git a/apps/backend/src/modules/tracking/index.ts b/apps/backend/src/modules/tracking/index.ts new file mode 100644 index 000000000..b67ee7fbe --- /dev/null +++ b/apps/backend/src/modules/tracking/index.ts @@ -0,0 +1,8 @@ +import { trackingTypeDef } from "@repo/gql-typedefs"; + +import resolver from "./resolver"; + +export default { + resolver, + typeDef: trackingTypeDef, +}; diff --git a/apps/backend/src/modules/tracking/jobs/flush-tracking-events.ts b/apps/backend/src/modules/tracking/jobs/flush-tracking-events.ts new file mode 100644 index 000000000..0627b3f65 --- /dev/null +++ b/apps/backend/src/modules/tracking/jobs/flush-tracking-events.ts @@ -0,0 +1,34 @@ +import type { RedisClientType } from "redis"; + +import { flushTrackingEvents } from "../controller"; + +const FLUSH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes +const STARTUP_DELAY_MS = 90 * 1000; // 1.5 minutes (staggered from other jobs) +const LOCK_KEY = "tracking-events-flush-lock"; +const LOCK_TTL_S = 10 * 60; // 10 minutes — long enough to cover a slow flush + +export const startTrackingEventsFlushJob = (redis: RedisClientType) => { + const runFlush = async () => { + const acquired = await redis.set(LOCK_KEY, "1", { + NX: true, + EX: LOCK_TTL_S, + }); + if (!acquired) return; + + try { + const result = await flushTrackingEvents(redis); + if (result.flushed > 0 || result.errors > 0) { + console.log( + `[TrackingEvents Flush] Flushed ${result.flushed} events, ${result.errors} errors` + ); + } + } catch (error) { + console.error("[TrackingEvents Flush] Error:", error); + } finally { + await redis.del(LOCK_KEY); + } + }; + + setInterval(runFlush, FLUSH_INTERVAL_MS); + setTimeout(runFlush, STARTUP_DELAY_MS); +}; diff --git a/apps/backend/src/modules/tracking/resolver.ts b/apps/backend/src/modules/tracking/resolver.ts new file mode 100644 index 000000000..d54a1af2a --- /dev/null +++ b/apps/backend/src/modules/tracking/resolver.ts @@ -0,0 +1,93 @@ +import type { Request } from "express"; +import { GraphQLError } from "graphql"; +import type { RedisClientType } from "redis"; + +import { StaffMemberModel } from "@repo/common/models"; + +import { + TrackingEventInput, + bufferTrackingEvents, + getTrackingEventsTimeSeries, +} from "./controller"; + +interface RequestContext { + user: { + _id: string; + isAuthenticated: boolean; + }; + req: Request; + redis: RedisClientType; +} + +const requireStaffMember = async (context: RequestContext) => { + if (!context.user?._id) { + throw new GraphQLError("Not authenticated", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + + const staffMember = await StaffMemberModel.findOne({ + userId: context.user._id, + }).lean(); + + if (!staffMember) { + throw new GraphQLError("Only staff members can perform this action", { + extensions: { code: "FORBIDDEN" }, + }); + } + + return staffMember; +}; + +const resolvers = { + Mutation: { + trackEvents: async ( + _: unknown, + { events }: { events: TrackingEventInput[] }, + context: RequestContext + ) => { + if (events.length === 0) return true; + + if (events.length > 50) { + throw new GraphQLError("Maximum 50 events per batch", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + await bufferTrackingEvents(context.redis, context.req, events); + return true; + }, + }, + + Query: { + trackingEventsTimeSeries: async ( + _: unknown, + { + eventType, + targetType, + targetId, + startDate, + endDate, + }: { + eventType?: string; + targetType?: string; + targetId?: string; + startDate?: string; + endDate?: string; + }, + context: RequestContext + ) => { + await requireStaffMember(context); + + return getTrackingEventsTimeSeries( + eventType, + targetType, + targetId, + startDate ? new Date(startDate) : undefined, + endDate ? new Date(endDate) : undefined + ); + }, + }, +}; + +export default resolvers; diff --git a/apps/backend/src/modules/tracking/routes.ts b/apps/backend/src/modules/tracking/routes.ts new file mode 100644 index 000000000..0f21dcff2 --- /dev/null +++ b/apps/backend/src/modules/tracking/routes.ts @@ -0,0 +1,27 @@ +import type { Application, Request, Response } from "express"; +import type { RedisClientType } from "redis"; + +import { TrackingEventInput, bufferTrackingEvents } from "./controller"; + +export default (app: Application, redis: RedisClientType) => { + app.post("/tracking/beacon", async (req: Request, res: Response) => { + const body = req.body as { events?: TrackingEventInput[] }; + const events = body?.events; + + if (!Array.isArray(events) || events.length === 0) { + return res.status(400).end(); + } + + if (events.length > 50) { + return res.status(400).end(); + } + + try { + await bufferTrackingEvents(redis, req, events); + } catch { + // Drop silently — sendBeacon callers never retry + } + + return res.status(204).end(); + }); +}; diff --git a/apps/backend/src/scripts/migrate-click-events.ts b/apps/backend/src/scripts/migrate-click-events.ts new file mode 100644 index 000000000..27be1ef41 --- /dev/null +++ b/apps/backend/src/scripts/migrate-click-events.ts @@ -0,0 +1,92 @@ +/** + * One-time migration script: copies historical ClickEvent documents into the + * new TrackingEvent collection. Safe to re-run — uses insertMany with + * ordered: false and ignores duplicate key errors. + * + * Usage: + * npx ts-node -e "require('./src/scripts/migrate-click-events')" + * OR run via the npm script: npm run migrate:click-events + */ +import mongoose from "mongoose"; + +import { ClickEventModel } from "@repo/common/models"; +import { TrackingEventModel } from "@repo/common/models"; + +const BATCH_SIZE = 500; + +async function migrate() { + const uri = process.env.MONGODB_URI; + if (!uri) throw new Error("MONGODB_URI env var is required"); + await mongoose.connect(uri); + console.log("Connected to MongoDB"); + + const total = await ClickEventModel.countDocuments(); + console.log(`Found ${total} ClickEvent documents to migrate`); + + let migrated = 0; + let skipped = 0; + const cursor = ClickEventModel.find().lean().cursor(); + + const batch: object[] = []; + + const flush = async () => { + if (batch.length === 0) return; + try { + await TrackingEventModel.insertMany(batch, { ordered: false }); + } catch (err: unknown) { + // E11000 = duplicate key — already migrated, safe to ignore + if ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: number }).code !== 11000 + ) { + throw err; + } + skipped += (err as { result?: { nInserted?: number } })?.result?.nInserted + ? 0 + : batch.length; + } + migrated += batch.length; + batch.length = 0; + process.stdout.write(`\r Migrated ${migrated}/${total}...`); + }; + + for await (const doc of cursor) { + batch.push({ + // Preserve original _id so re-runs hit the unique index and skip duplicates + _id: doc._id, + // Map old ClickEvent fields → TrackingEvent fields + eventType: "click", + targetType: doc.targetType, // "banner" | "redirect" | "targeted-message" + targetId: doc.targetId.toString(), + metadata: { + ...(doc.targetVersion !== undefined && { version: doc.targetVersion }), + ...(doc.additionalInfo && { additionalInfo: doc.additionalInfo }), + }, + // Use sessionFingerprint as a stand-in for sessionId (best available) + sessionId: doc.sessionFingerprint, + userId: doc.userId, + timestamp: doc.timestamp, + ipHash: doc.ipHash, + userAgent: doc.userAgent, + referrer: doc.referrer, + }); + + if (batch.length >= BATCH_SIZE) { + await flush(); + } + } + + await flush(); + + console.log( + `\nDone. Migrated ${migrated} events, skipped ${skipped} duplicates.` + ); + await mongoose.disconnect(); +} + +migrate().catch((err) => { + console.error("Migration failed:", err); + process.exit(1); +}); diff --git a/apps/datapuller/src/pullers/classes.ts b/apps/datapuller/src/pullers/classes.ts index 15510d313..fcd267634 100644 --- a/apps/datapuller/src/pullers/classes.ts +++ b/apps/datapuller/src/pullers/classes.ts @@ -121,7 +121,10 @@ export const updateTermsCatalogDataFlags = async (log: Config["log"]) => { ]); const catalogDataSet = new Set( - termsWithClasses.map((t: { _id: { year: number; semester: string } }) => `${t._id.year} ${t._id.semester}`) + termsWithClasses.map( + (t: { _id: { year: number; semester: string } }) => + `${t._id.year} ${t._id.semester}` + ) ); const bulkOps = allTerms diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 443959924..4fa7ce727 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { ThemeProvider } from "@repo/theme"; import Layout from "@/components/Layout"; import RootWrapper from "@/components/RootWrapper"; import SuspenseBoundary from "@/components/SuspenseBoundary"; +import { TrackingProvider } from "@/providers/TrackingProvider"; import UserProvider from "@/providers/UserProvider"; const Landing = lazy(() => import("@/app/Landing")); @@ -410,11 +411,13 @@ const client = new ApolloClient({ export default function App() { return ( - - - - - + + + + + + + ); } diff --git a/apps/frontend/src/components/Banner/index.tsx b/apps/frontend/src/components/Banner/index.tsx index 1a6c43bed..dc5e38412 100644 --- a/apps/frontend/src/components/Banner/index.tsx +++ b/apps/frontend/src/components/Banner/index.tsx @@ -8,6 +8,7 @@ import { useIncrementBannerDismiss, useTrackBannerView, } from "@/hooks/api/banner"; +import { useTracking } from "@/hooks/api/tracking/useTracking"; import { isBannerSessionDismissed, isBannerViewed, @@ -23,6 +24,7 @@ export default function Banner() { const { data: banners, loading, error } = useAllBanners(); const { incrementDismiss } = useIncrementBannerDismiss(); const { trackView } = useTrackBannerView(); + const { trackView: trackUnifiedView, trackDismiss } = useTracking(); const [dismissedBanners, setDismissedBanners] = useState>( new Set() ); @@ -82,13 +84,15 @@ export default function Banner() { trackedViewsRef.current.add(activeBanner.id); trackView(activeBanner.id); - }, [activeBanner, trackView]); + trackUnifiedView("banner", activeBanner.id); + }, [activeBanner, trackView, trackUnifiedView]); const handleDismiss = () => { if (!activeBanner) return; // Track dismissal (always on now) incrementDismiss(activeBanner.id); + trackDismiss("banner", activeBanner.id); // Mark as dismissed in this session (in-memory state) setDismissedBanners((prev) => new Set(prev).add(activeBanner.id)); diff --git a/apps/frontend/src/components/Class/index.tsx b/apps/frontend/src/components/Class/index.tsx index 496961f46..6338e1a03 100644 --- a/apps/frontend/src/components/Class/index.tsx +++ b/apps/frontend/src/components/Class/index.tsx @@ -39,6 +39,7 @@ import { ReservedSeatingHoverCard } from "@/components/ReservedSeatingHoverCard" import Units from "@/components/Units"; import ClassContext from "@/contexts/ClassContext"; import { useGetClass } from "@/hooks/api/classes/useGetClass"; +import { useTracking } from "@/hooks/api/tracking/useTracking"; import useUser from "@/hooks/useUser"; import { IClassCourse, @@ -322,6 +323,7 @@ export default function Class({ }, [_class]); const [trackView] = useMutation(TRACK_CLASS_VIEW); + const { trackView: trackUnifiedView } = useTracking(); useEffect(() => { if (!_class) return; @@ -337,10 +339,18 @@ export default function Class({ number: _class.number, }, }).catch(() => {}); + + trackUnifiedView("class", _class.courseId ?? undefined, { + subject: _class.subject, + courseNumber: _class.courseNumber, + year: _class.year, + semester: _class.semester, + number: _class.number, + }); }, VIEW_TRACKING_DELAY_MS); return () => clearTimeout(timer); - }, [_class, trackView]); + }, [_class, trackView, trackUnifiedView]); const ratingsCount = useMemo(() => { const metrics = _course?.aggregatedRatings?.metrics; @@ -922,7 +932,7 @@ export default function Class({ initialCourse={{ subject: _class.subject, number: _class.courseNumber, - courseId: "" + courseId: "", }} onSubmit={handleUnlockRatingSubmit} userRatedClasses={userRatedClasses} diff --git a/apps/frontend/src/components/ClassBrowser/Header/index.tsx b/apps/frontend/src/components/ClassBrowser/Header/index.tsx index 4d927b862..90d270ee1 100644 --- a/apps/frontend/src/components/ClassBrowser/Header/index.tsx +++ b/apps/frontend/src/components/ClassBrowser/Header/index.tsx @@ -1,8 +1,12 @@ import classNames from "classnames"; - import { AnimatePresence, motion } from "framer-motion"; -import { Filter, FilterSolid, Search, Sparks, SparksSolid } from "iconoir-react"; - +import { + Filter, + FilterSolid, + Search, + Sparks, + SparksSolid, +} from "iconoir-react"; import { Button, IconButton } from "@repo/theme"; diff --git a/apps/frontend/src/components/ClassBrowser/List/index.tsx b/apps/frontend/src/components/ClassBrowser/List/index.tsx index 01eabdd86..d297125b8 100644 --- a/apps/frontend/src/components/ClassBrowser/List/index.tsx +++ b/apps/frontend/src/components/ClassBrowser/List/index.tsx @@ -417,5 +417,5 @@ export default function List({ onSelect }: ListProps) { )} - ) + ); } diff --git a/apps/frontend/src/components/ClassBrowser/hooks/useCatalogBrowser.ts b/apps/frontend/src/components/ClassBrowser/hooks/useCatalogBrowser.ts index a60e825e2..e02bac549 100644 --- a/apps/frontend/src/components/ClassBrowser/hooks/useCatalogBrowser.ts +++ b/apps/frontend/src/components/ClassBrowser/hooks/useCatalogBrowser.ts @@ -45,7 +45,8 @@ export default function useCatalogBrowser({ fetch("/api/semantic-search/health") .then((r) => r.json()) .then((data) => { - const indexes: { year: number; semester: string }[] = data?.indexes ?? []; + const indexes: { year: number; semester: string }[] = + data?.indexes ?? []; const available = indexes.some( (idx) => idx.year === year && idx.semester === semester ); diff --git a/apps/frontend/src/components/ClassBrowser/hooks/useCatalogQuery.ts b/apps/frontend/src/components/ClassBrowser/hooks/useCatalogQuery.ts index c8e8b3894..caa42b54a 100644 --- a/apps/frontend/src/components/ClassBrowser/hooks/useCatalogQuery.ts +++ b/apps/frontend/src/components/ClassBrowser/hooks/useCatalogQuery.ts @@ -138,7 +138,10 @@ export default function useCatalogQuery({ ); // Server-side catalog query (always requests first page) - const { data, loading, error, fetchMore } = useQuery(GET_CATALOG_SEARCH, { + const { data, loading, error, fetchMore } = useQuery< + GetCatalogSearchQuery, + GetCatalogSearchQueryVariables + >(GET_CATALOG_SEARCH, { variables: { ...catalogQueryVariables, page: 1, @@ -215,7 +218,9 @@ export default function useCatalogQuery({ const isFirstPageLoading = loading && localPage === 1 && !isLoadingNextPage; const semanticError = semanticSearch && error - ? (error.graphQLErrors?.[0]?.message ?? error.message ?? "AI search failed") + ? (error.graphQLErrors?.[0]?.message ?? + error.message ?? + "AI search failed") : null; return { diff --git a/apps/frontend/src/components/CourseSearch/index.tsx b/apps/frontend/src/components/CourseSearch/index.tsx index 37125dc55..8b7ded69f 100644 --- a/apps/frontend/src/components/CourseSearch/index.tsx +++ b/apps/frontend/src/components/CourseSearch/index.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@apollo/client/react"; import { Search } from "iconoir-react"; import { Badge, Color, LoadingIndicator } from "@repo/theme"; +import { useTracking } from "@/hooks/api/tracking/useTracking"; import { ICourse } from "@/lib/api"; import { GetCourseNamesDocument } from "@/lib/generated/graphql"; import { Recent, RecentType, getRecents } from "@/lib/recent"; @@ -32,6 +33,7 @@ export default function CourseSearch({ const wrapperRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const { trackSearch, trackSearchClick } = useTracking(); const [recentCourses, setRecentCourses] = useState< Recent[] @@ -72,6 +74,35 @@ export default function CourseSearch({ .map(({ refIndex }) => catalogCourses[refIndex]); }, [catalogCourses, index, searchQuery]); + // Track search queries with a 1s debounce + const searchDebounceRef = useRef | null>(null); + useEffect(() => { + if (!searchQuery) return; + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = setTimeout(() => { + trackSearch(searchQuery, currentCourses.length); + }, 1000); + return () => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + }; + }, [searchQuery, currentCourses.length, trackSearch]); + + const handleResultClick = useCallback( + (course: Pick, resultIndex: number) => { + if (searchQuery) { + trackSearchClick( + searchQuery, + `${course.subject}-${course.number}`, + resultIndex + ); + } + onSelect?.(course); + setSearchQuery(""); + setIsOpen(false); + }, + [searchQuery, trackSearchClick, onSelect] + ); + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( @@ -156,10 +187,10 @@ export default function CourseSearch({ c.number === course.number ); if (full) { - onSelect?.(full); - setSearchQuery(""); + handleResultClick(full, index); + } else { + setIsOpen(false); } - setIsOpen(false); }} label={`${course.subject} ${course.number}`} color={Color.Zinc} @@ -179,7 +210,7 @@ export default function CourseSearch({ > {!minimal &&

CATALOG

}
- {currentCourses.map((course) => { + {currentCourses.map((course, resultIndex) => { const isRated = isCourseRated( course.subject, course.number @@ -190,9 +221,7 @@ export default function CourseSearch({ className={`${styles.catalogItem} ${isRated ? styles.ratedItem : ""}`} onClick={() => { if (!isRated) { - onSelect?.(course); - setSearchQuery(""); - setIsOpen(false); + handleResultClick(course, resultIndex); } }} disabled={isRated} diff --git a/apps/frontend/src/components/RootWrapper/index.tsx b/apps/frontend/src/components/RootWrapper/index.tsx index aa27f9bd7..e539fdfae 100644 --- a/apps/frontend/src/components/RootWrapper/index.tsx +++ b/apps/frontend/src/components/RootWrapper/index.tsx @@ -7,6 +7,7 @@ import { useAllRouteRedirects, useIncrementRouteRedirectClick, } from "@/hooks/api/route-redirect"; +import { useTracking } from "@/hooks/api/tracking/useTracking"; // Module-level tracking to prevent duplicate increments let lastIncrementedPath: string | null = null; @@ -20,6 +21,7 @@ export default function RootWrapper() { const location = useLocation(); const { data: redirects, loading } = useAllRouteRedirects(); const { incrementClick } = useIncrementRouteRedirectClick(); + const { trackClick, flushBeacon } = useTracking(); // Check for redirects immediately on route change useEffect(() => { @@ -44,9 +46,18 @@ export default function RootWrapper() { lastIncrementedPath = currentPath; lastIncrementTime = now; incrementClick(matchingRedirect.id); + trackClick("redirect", matchingRedirect.id, { fromPath: currentPath }); + flushBeacon(); window.location.href = matchingRedirect.toPath; } - }, [location.pathname, redirects, loading, incrementClick]); + }, [ + location.pathname, + redirects, + loading, + incrementClick, + trackClick, + flushBeacon, + ]); return ( <> diff --git a/apps/frontend/src/hooks/api/tracking/useTracking.ts b/apps/frontend/src/hooks/api/tracking/useTracking.ts new file mode 100644 index 000000000..cca85b9ee --- /dev/null +++ b/apps/frontend/src/hooks/api/tracking/useTracking.ts @@ -0,0 +1 @@ +export { useTrackingContext as useTracking } from "@/providers/TrackingProvider"; diff --git a/apps/frontend/src/lib/api/tracking.ts b/apps/frontend/src/lib/api/tracking.ts new file mode 100644 index 000000000..bc2dc2b1f --- /dev/null +++ b/apps/frontend/src/lib/api/tracking.ts @@ -0,0 +1,7 @@ +import { gql } from "@apollo/client"; + +export const TRACK_EVENTS = gql` + mutation TrackEvents($events: [TrackingEventInput!]!) { + trackEvents(events: $events) + } +`; diff --git a/apps/frontend/src/providers/TrackingProvider.tsx b/apps/frontend/src/providers/TrackingProvider.tsx new file mode 100644 index 000000000..a78ed0a4f --- /dev/null +++ b/apps/frontend/src/providers/TrackingProvider.tsx @@ -0,0 +1,190 @@ +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useRef, +} from "react"; + +import { useMutation } from "@apollo/client/react"; + +import { + TrackEventsDocument, + TrackEventsMutation, + TrackEventsMutationVariables, + TrackingEventInput, +} from "@/lib/generated/graphql"; + +const BATCH_INTERVAL_MS = 30_000; +const MAX_BATCH_SIZE = 50; +const BEACON_URL = "/api/tracking/beacon"; + +const sendBeacon = (events: TrackingEventInput[]): void => { + if (events.length === 0) return; + const blob = new Blob([JSON.stringify({ events })], { + type: "application/json", + }); + navigator.sendBeacon(BEACON_URL, blob); +}; + +export interface TrackingContextValue { + trackClick: ( + targetType: string, + targetId?: string, + metadata?: Record + ) => void; + trackView: ( + targetType: string, + targetId?: string, + metadata?: Record + ) => void; + trackDismiss: ( + targetType: string, + targetId?: string, + metadata?: Record + ) => void; + trackSearch: (query: string, resultCount: number) => void; + trackSearchClick: ( + query: string, + targetId: string, + resultIndex: number + ) => void; + flushBeacon: () => void; +} + +const TrackingContext = createContext(null); + +export function TrackingProvider({ children }: { children: ReactNode }) { + const queueRef = useRef([]); + const timerRef = useRef | null>(null); + + const [mutate] = useMutation< + TrackEventsMutation, + TrackEventsMutationVariables + >(TrackEventsDocument); + + const flush = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + + if (queueRef.current.length === 0) return; + + const batch = queueRef.current.splice(0, MAX_BATCH_SIZE); + mutate({ variables: { events: batch } }).catch(() => { + // Silently drop failed tracking events — not worth retrying + }); + }, [mutate]); + + const flushBeacon = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + const remaining = queueRef.current.splice(0); + sendBeacon(remaining); + }, []); + + const enqueue = useCallback( + (event: Omit) => { + queueRef.current.push({ + ...event, + timestamp: new Date().toISOString(), + }); + + if (queueRef.current.length >= MAX_BATCH_SIZE) { + flush(); + } else if (!timerRef.current) { + timerRef.current = setTimeout(flush, BATCH_INTERVAL_MS); + } + }, + [flush] + ); + + useEffect(() => { + const handlePageHide = () => flushBeacon(); + window.addEventListener("pagehide", handlePageHide); + return () => window.removeEventListener("pagehide", handlePageHide); + }, [flushBeacon]); + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + if (queueRef.current.length > 0) { + mutate({ variables: { events: queueRef.current } }).catch(() => {}); + } + }; + }, [mutate]); + + const trackClick = useCallback( + ( + targetType: string, + targetId?: string, + metadata?: Record + ) => enqueue({ eventType: "click", targetType, targetId, metadata }), + [enqueue] + ); + + const trackView = useCallback( + ( + targetType: string, + targetId?: string, + metadata?: Record + ) => enqueue({ eventType: "view", targetType, targetId, metadata }), + [enqueue] + ); + + const trackDismiss = useCallback( + ( + targetType: string, + targetId?: string, + metadata?: Record + ) => enqueue({ eventType: "dismiss", targetType, targetId, metadata }), + [enqueue] + ); + + const trackSearch = useCallback( + (query: string, resultCount: number) => + enqueue({ + eventType: "search", + targetType: "course", + metadata: { query, resultCount }, + }), + [enqueue] + ); + + const trackSearchClick = useCallback( + (query: string, targetId: string, resultIndex: number) => + enqueue({ + eventType: "search_click", + targetType: "course", + targetId, + metadata: { query, resultIndex }, + }), + [enqueue] + ); + + return ( + + {children} + + ); +} + +export function useTrackingContext(): TrackingContextValue { + const ctx = useContext(TrackingContext); + if (!ctx) + throw new Error("useTrackingContext must be used within TrackingProvider"); + return ctx; +} diff --git a/apps/staff-frontend/src/app/Analytics/components/OutreachAnalytics.tsx b/apps/staff-frontend/src/app/Analytics/components/OutreachAnalytics.tsx index 11e8bfd37..337d4c24a 100644 --- a/apps/staff-frontend/src/app/Analytics/components/OutreachAnalytics.tsx +++ b/apps/staff-frontend/src/app/Analytics/components/OutreachAnalytics.tsx @@ -18,9 +18,9 @@ import { createChartConfig, } from "@/components/Chart"; import { useAllBanners } from "@/hooks/api/banner"; -import { useClickEventsTimeSeries } from "@/hooks/api/click-tracking"; import { useAllRouteRedirects } from "@/hooks/api/route-redirect"; import { useAllTargetedMessages } from "@/hooks/api/targeted-message"; +import { useTrackingEventsTimeSeries } from "@/hooks/api/tracking"; import { AnalyticsCard, TimeRange } from "./AnalyticsCard"; @@ -169,9 +169,10 @@ export function OutreachPanelBlock() { data: seriesData, loading, error, - } = useClickEventsTimeSeries({ + } = useTrackingEventsTimeSeries({ + eventType: "click", + targetType: selectedTarget?.targetType ?? null, targetId: selectedTarget?.targetId ?? null, - targetType: selectedTarget?.targetType ?? "redirect", startDate: startDateStr, endDate: endDateStr, }); diff --git a/apps/staff-frontend/src/hooks/api/tracking/index.ts b/apps/staff-frontend/src/hooks/api/tracking/index.ts new file mode 100644 index 000000000..2235bc3f1 --- /dev/null +++ b/apps/staff-frontend/src/hooks/api/tracking/index.ts @@ -0,0 +1 @@ +export * from "./useTrackingEventsTimeSeries"; diff --git a/apps/staff-frontend/src/hooks/api/tracking/useTrackingEventsTimeSeries.ts b/apps/staff-frontend/src/hooks/api/tracking/useTrackingEventsTimeSeries.ts new file mode 100644 index 000000000..e5d8599a8 --- /dev/null +++ b/apps/staff-frontend/src/hooks/api/tracking/useTrackingEventsTimeSeries.ts @@ -0,0 +1,45 @@ +import { useQuery } from "@apollo/client"; + +import { + TRACKING_EVENTS_TIME_SERIES, + TrackingEventTimeSeriesPoint, +} from "../../../lib/api/tracking"; + +interface TrackingEventsTimeSeriesResponse { + trackingEventsTimeSeries: TrackingEventTimeSeriesPoint[]; +} + +interface UseTrackingEventsTimeSeriesParams { + eventType?: string | null; + targetType?: string | null; + targetId?: string | null; + startDate?: string | null; + endDate?: string | null; +} + +export const useTrackingEventsTimeSeries = ({ + eventType, + targetType, + targetId, + startDate, + endDate, +}: UseTrackingEventsTimeSeriesParams) => { + const query = useQuery( + TRACKING_EVENTS_TIME_SERIES, + { + variables: { + eventType: eventType ?? undefined, + targetType: targetType ?? undefined, + targetId: targetId ?? undefined, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + }, + skip: !targetId && !targetType, + } + ); + + return { + ...query, + data: query.data?.trackingEventsTimeSeries ?? [], + }; +}; diff --git a/apps/staff-frontend/src/lib/api/tracking.ts b/apps/staff-frontend/src/lib/api/tracking.ts new file mode 100644 index 000000000..4c5ea8ce3 --- /dev/null +++ b/apps/staff-frontend/src/lib/api/tracking.ts @@ -0,0 +1,27 @@ +import { gql } from "@apollo/client"; + +export interface TrackingEventTimeSeriesPoint { + date: string; + count: number; +} + +export const TRACKING_EVENTS_TIME_SERIES = gql` + query TrackingEventsTimeSeries( + $eventType: String + $targetType: String + $targetId: String + $startDate: String + $endDate: String + ) { + trackingEventsTimeSeries( + eventType: $eventType + targetType: $targetType + targetId: $targetId + startDate: $startDate + endDate: $endDate + ) { + date + count + } + } +`; diff --git a/apps/staff-frontend/vite.config.ts b/apps/staff-frontend/vite.config.ts index 7a41685da..77b22bdf8 100644 --- a/apps/staff-frontend/vite.config.ts +++ b/apps/staff-frontend/vite.config.ts @@ -43,4 +43,4 @@ export default defineConfig({ localsConvention: "camelCaseOnly", }, }, -}); \ No newline at end of file +}); diff --git a/packages/common/src/models/index.ts b/packages/common/src/models/index.ts index 04de52b06..1d08e9066 100644 --- a/packages/common/src/models/index.ts +++ b/packages/common/src/models/index.ts @@ -22,3 +22,4 @@ export * from "./route-redirect"; export * from "./click-event"; export * from "./targeted-message"; export * from "./catalog-class"; +export * from "./tracking-event"; diff --git a/packages/common/src/models/tracking-event.ts b/packages/common/src/models/tracking-event.ts new file mode 100644 index 000000000..3cce744d3 --- /dev/null +++ b/packages/common/src/models/tracking-event.ts @@ -0,0 +1,41 @@ +import { Model, Schema, model } from "mongoose"; + +export interface ITrackingEvent { + eventType: string; + targetType: string; + targetId?: string; + metadata?: Record; + sessionId: string; + userId?: string; + timestamp: Date; + ipHash: string; + userAgent?: string; + referrer?: string; +} + +const trackingEventSchema = new Schema({ + eventType: { type: String, required: true }, + targetType: { type: String, required: true }, + targetId: { type: String }, + metadata: { type: Schema.Types.Mixed }, + sessionId: { type: String, required: true }, + userId: { type: String }, + timestamp: { type: Date, required: true, default: Date.now }, + ipHash: { type: String, required: true }, + userAgent: { type: String, maxlength: 500 }, + referrer: { type: String }, +}); + +// Query by event + target type (e.g. "all search events for courses") +trackingEventSchema.index({ eventType: 1, targetType: 1, timestamp: -1 }); +// Query by specific target (e.g. "all events for banner X") +trackingEventSchema.index({ targetId: 1, timestamp: -1 }); +// Query by session (reconstruct a user journey) +trackingEventSchema.index({ sessionId: 1, timestamp: -1 }); +// Query by user (all events for a logged-in user) +trackingEventSchema.index({ userId: 1, timestamp: -1 }, { sparse: true }); + +export const TrackingEventModel: Model = model( + "trackingevents", + trackingEventSchema +); diff --git a/packages/gql-typedefs/index.ts b/packages/gql-typedefs/index.ts index fa24706d9..055561cf6 100644 --- a/packages/gql-typedefs/index.ts +++ b/packages/gql-typedefs/index.ts @@ -20,3 +20,4 @@ export * from "./staff"; export * from "./stats"; export * from "./click-tracking"; export * from "./targeted-message"; +export * from "./tracking"; diff --git a/packages/gql-typedefs/tracking.ts b/packages/gql-typedefs/tracking.ts new file mode 100644 index 000000000..873c3cf33 --- /dev/null +++ b/packages/gql-typedefs/tracking.ts @@ -0,0 +1,53 @@ +import { gql } from "graphql-tag"; + +export const trackingTypeDef = gql` + """ + Input for a single tracking event. Clients should batch multiple events + into a single trackEvents mutation call. + """ + input TrackingEventInput { + "The type of interaction: click, view, dismiss, search, search_click, etc." + eventType: String! + + "What was acted on: banner, redirect, targeted-message, class, course, schedule, etc." + targetType: String! + + "Identifier of the target, if applicable." + targetId: String + + "Arbitrary JSON payload for event-specific context." + metadata: JSON + + "Client-set ISO 8601 timestamp for accurate timing." + timestamp: String! + } + + """ + A single point in a tracking events time series (e.g. one day). + """ + type TrackingEventTimeSeriesPoint { + date: String! + count: Int! + } + + type Mutation { + """ + Record tracking events. Accepts up to 50 events per call. + Server enriches each event with session, user, and request metadata. + """ + trackEvents(events: [TrackingEventInput!]!): Boolean + } + + type Query { + """ + Get event counts per day for a given filter. Staff only. + """ + trackingEventsTimeSeries( + eventType: String + targetType: String + targetId: String + startDate: String + endDate: String + ): [TrackingEventTimeSeriesPoint!]! @auth + } +`; diff --git a/tests/api/tracking.spec.ts b/tests/api/tracking.spec.ts new file mode 100644 index 000000000..ec8fc6f67 --- /dev/null +++ b/tests/api/tracking.spec.ts @@ -0,0 +1,135 @@ +import { expect, test } from "@playwright/test"; + +const validEvent = { + eventType: "click", + targetType: "course", + targetId: "abc123", + metadata: { source: "test" }, + timestamp: new Date().toISOString(), +}; + +test.describe("Tracking beacon endpoint", () => { + test("accepts a valid batch and returns 204", async ({ request }) => { + const response = await request.post("/api/tracking/beacon", { + data: { events: [validEvent] }, + }); + expect(response.status()).toBe(204); + }); + + test("accepts max batch size of 50 events", async ({ request }) => { + const events = Array.from({ length: 50 }, () => ({ ...validEvent })); + const response = await request.post("/api/tracking/beacon", { + data: { events }, + }); + expect(response.status()).toBe(204); + }); + + test("rejects batch over 50 events with 400", async ({ request }) => { + const events = Array.from({ length: 51 }, () => ({ ...validEvent })); + const response = await request.post("/api/tracking/beacon", { + data: { events }, + }); + expect(response.status()).toBe(400); + }); + + test("rejects empty events array with 400", async ({ request }) => { + const response = await request.post("/api/tracking/beacon", { + data: { events: [] }, + }); + expect(response.status()).toBe(400); + }); + + test("rejects missing events field with 400", async ({ request }) => { + const response = await request.post("/api/tracking/beacon", { + data: {}, + }); + expect(response.status()).toBe(400); + }); + + test("rate limit: silently drops after 30 calls but still returns 204", async ({ + request, + }) => { + // First 30 calls should all succeed + for (let i = 0; i < 30; i++) { + const response = await request.post("/api/tracking/beacon", { + data: { events: [validEvent] }, + }); + expect(response.status()).toBe(204); + } + + // 31st call is over the limit — still 204 (silent drop, not an error) + const overLimit = await request.post("/api/tracking/beacon", { + data: { events: [validEvent] }, + }); + expect(overLimit.status()).toBe(204); + }); +}); + +test.describe("Tracking GraphQL mutation", () => { + test("trackEvents returns true for valid batch", async ({ request }) => { + const response = await request.post("/api/graphql", { + data: { + query: ` + mutation TrackEvents($events: [TrackingEventInput!]!) { + trackEvents(events: $events) + } + `, + variables: { + events: [validEvent], + }, + }, + }); + + expect(response.ok()).toBeTruthy(); + const { data, errors } = await response.json(); + expect(errors).toBeUndefined(); + expect(data.trackEvents).toBe(true); + }); + + test("trackEvents errors on batch over 50", async ({ request }) => { + const events = Array.from({ length: 51 }, () => ({ ...validEvent })); + const response = await request.post("/api/graphql", { + data: { + query: ` + mutation TrackEvents($events: [TrackingEventInput!]!) { + trackEvents(events: $events) + } + `, + variables: { events }, + }, + }); + + expect(response.ok()).toBeTruthy(); + const { errors } = await response.json(); + expect(errors).toBeDefined(); + expect(errors[0].extensions.code).toBe("BAD_USER_INPUT"); + }); + + test("trackEvents accepts events with oversized metadata without error", async ({ + request, + }) => { + // Sanitization drops oversized metadata silently — mutation still succeeds + const bigMetadata: Record = {}; + for (let i = 0; i < 25; i++) { + bigMetadata[`key${i}`] = "x".repeat(300); + } + + const response = await request.post("/api/graphql", { + data: { + query: ` + mutation TrackEvents($events: [TrackingEventInput!]!) { + trackEvents(events: $events) + } + `, + variables: { + events: [{ ...validEvent, metadata: bigMetadata }], + }, + }, + }); + + expect(response.ok()).toBeTruthy(); + const { data, errors } = await response.json(); + expect(errors).toBeUndefined(); + expect(data.trackEvents).toBe(true); + }); +});