All files / src/app/api/analytics/track route.ts

98.82% Statements 168/170
81.81% Branches 18/22
100% Functions 3/3
98.82% Lines 168/170

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 1711x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 38x 38x 38x 38x 36x 36x 38x 12x 12x 38x 22x 22x 24x 2x 2x 38x 1x 1x 1x 1x 1x 1x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 11x 11x 11x 11x 11x 11x 11x 11x 12x 10x 10x 10x 12x 11x 11x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x     10x 10x 10x 10x 10x 10x 10x 10x 10x 1x 1x 1x 1x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 22x 22x 22x  
/**
 * Analytics Event Tracking API Route
 *
 * Handles batched event tracking for custom events.
 * POST - Track one or more events
 */
 
import { NextRequest, NextResponse } from 'next/server';
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getClientIP, hashIP, sanitizeEventProperties } from "@/lib/analytics/utils";
import { logger } from "@/lib/logging";
import { Prisma } from "@prisma/client";
import {
  withErrorHandling,
  successResponse,
  ApiError,
  ApiSuccessResponse,
  ApiErrorResponse } from "@/lib/api";
 
// Schema for a single event
const eventSchema = z.object({
  eventType: z.string().max(50),
  eventName: z.string().max(100),
  sessionId: z.string().min(1),
  visitorId: z.string().min(1).max(50),
  path: z.string().max(500),
  referrer: z.string().max(500).optional(),
  properties: z.record(z.string(), z.unknown()).optional()});
 
// Schema for batch event tracking
const batchTrackSchema = z.object({
  events: z.array(eventSchema).min(1).max(50), // Max 50 events per batch
});
 
// Legacy schema for backwards compatibility
const legacyEventSchema = z.object({
  category: z.string(),
  action: z.string(),
  label: z.string().optional(),
  value: z.number().optional(),
  timestamp: z.string().optional(),
  userAgent: z.string().optional(),
  url: z.string().optional()});
 
/**
 * POST - Track events (supports batching and legacy format)
 */
async function handlePost(
  request: NextRequest
): Promise<NextResponse<ApiSuccessResponse<unknown> | ApiErrorResponse>> {
  const body = await request.json();
 
  // Check if this is the new batch format or legacy format
  if ("events" in body && Array.isArray(body.events)) {
    // New batch format
    return await handleBatchEvents(request, body);
  } else if ("category" in body && "action" in body) {
    // Legacy format - convert to new format
    return await handleLegacyEvent(request, body);
  } else {
    throw ApiError.badRequest("Invalid event format");
  }
}
 
export const POST = withErrorHandling(handlePost);
 
/**
 * Handle new batch event format
 */
async function handleBatchEvents(
  request: NextRequest,
  body: unknown
): Promise<NextResponse<ApiSuccessResponse<{ eventsTracked: number }> | ApiErrorResponse>> {
  const { events } = batchTrackSchema.parse(body);
 
  const userAgent = request.headers.get("user-agent") || undefined;
  const ip = getClientIP(request.headers);
  const ipHash = hashIP(ip);
 
  // Batch insert events
  await prisma.analyticsEvent.createMany({
    data: events.map((event) => ({
      eventType: event.eventType,
      eventName: event.eventName,
      sessionId: event.sessionId,
      visitorId: event.visitorId,
      path: event.path,
      referrer: event.referrer,
      userAgent,
      ipHash,
      properties: sanitizeEventProperties(event.properties) as Prisma.InputJsonValue | undefined}))});
 
  // Update session event counts
  const sessionCounts = new Map<string, number>();
  for (const event of events) {
    sessionCounts.set(event.sessionId, (sessionCounts.get(event.sessionId) || 0) + 1);
  }
 
  // Update each session's event count
  const updatePromises = Array.from(sessionCounts.entries()).map(([sessionId, count]) =>
    prisma.analyticsSession
      .update({
        where: { id: sessionId },
        data: {
          eventCount: { increment: count },
          lastSeenAt: new Date()}})
      .catch((error) => {
        // Session might not exist, log but don't fail
        logger.warn(`Failed to update session ${sessionId} event count`, { category: "API", error: error instanceof Error ? error.message : String(error) });
      })
  );
 
  await Promise.all(updatePromises);
 
  logger.info(`Tracked ${events.length} events`, { category: "API" });
 
  return successResponse({ eventsTracked: events.length });
}
 
/**
 * Handle legacy event format for backwards compatibility
 */
async function handleLegacyEvent(
  request: NextRequest,
  body: unknown
): Promise<NextResponse<ApiSuccessResponse<Record<string, never>> | ApiErrorResponse>> {
  const data = legacyEventSchema.parse(body);
 
  const { category, action, label, value, timestamp, userAgent, url } = data;
 
  // Log the event (legacy behavior)
  logger.info(`Event tracked: ${category}.${action}`, {
    category: "API",
    eventCategory: category,
    action,
    label,
    value,
    timestamp,
    userAgent,
    url});
 
  // Also store in database if we have enough info
  // This provides a migration path from legacy to new format
  if (url) {
    try {
      const ipHash = hashIP(getClientIP(request.headers));
 
      await prisma.analyticsEvent.create({
        data: {
          eventType: category,
          eventName: action,
          sessionId: null, // Legacy events don't have session tracking
          visitorId: "legacy",
          path: new URL(url).pathname,
          userAgent: userAgent || request.headers.get("user-agent") || undefined,
          ipHash,
          properties: {
            label,
            value,
            timestamp,
            legacy: true}}});
    } catch (error) {
      // Don't fail the request if DB insert fails for legacy events
      logger.warn("Failed to store legacy event in database", { category: "API", error: error instanceof Error ? error.message : String(error) });
    }
  }
 
  return successResponse({});
}