mirror of
https://github.com/hanzoai/dataroom.git
synced 2026-11-02 22:22:45 +00:00
270 lines
7.7 KiB
TypeScript
270 lines
7.7 KiB
TypeScript
import { NextApiRequest, NextApiResponse } from "next";
|
|
|
|
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
|
import { getServerSession } from "next-auth/next";
|
|
|
|
import prisma from "@/lib/prisma";
|
|
import { getVideoEventsByDocument } from "@/lib/tinybird/pipes";
|
|
import { CustomUser } from "@/lib/types";
|
|
|
|
interface AnalyticsResponse {
|
|
overall: {
|
|
unique_views: number;
|
|
total_views: number;
|
|
total_watch_time: number;
|
|
avg_view_duration: number;
|
|
last_viewed_at: string;
|
|
first_viewed_at: string;
|
|
view_distribution: Array<{
|
|
start_time: number;
|
|
unique_views: number;
|
|
total_views: number;
|
|
}>;
|
|
} | null;
|
|
}
|
|
|
|
function calculateAnalytics(
|
|
events: Array<{
|
|
timestamp: string;
|
|
view_id: string;
|
|
event_type: string;
|
|
start_time: number;
|
|
end_time: number;
|
|
playback_rate: number;
|
|
volume: number;
|
|
is_muted: number;
|
|
is_focused: number;
|
|
is_fullscreen: number;
|
|
}>,
|
|
videoLength: number,
|
|
): AnalyticsResponse {
|
|
if (!events || events.length === 0) {
|
|
return {
|
|
overall: null,
|
|
};
|
|
}
|
|
|
|
try {
|
|
// Filter for valid events and ensure valid time ranges > 1 second
|
|
const validEvents = events.filter((event) => {
|
|
// Check if event has required properties
|
|
if (!event || typeof event.event_type !== "string" || !event.view_id) {
|
|
console.warn("Invalid event structure:", event);
|
|
return false;
|
|
}
|
|
|
|
// Check if event has valid time properties
|
|
if (
|
|
typeof event.start_time !== "number" ||
|
|
typeof event.end_time !== "number"
|
|
) {
|
|
console.warn("Invalid time properties:", event);
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
(event.event_type === "played" ||
|
|
event.event_type === "muted" ||
|
|
event.event_type === "unmuted" ||
|
|
event.event_type === "rate_changed") &&
|
|
event.end_time > event.start_time &&
|
|
event.end_time - event.start_time >= 1 &&
|
|
event.start_time >= 0 &&
|
|
event.end_time <= videoLength + 10
|
|
); // Allow some buffer
|
|
});
|
|
|
|
// Get all unique view_ids from any event type
|
|
const uniqueViewIds = new Set(events.map((e) => e.view_id));
|
|
|
|
// Calculate total watch time
|
|
let totalWatchTime = 0;
|
|
validEvents.forEach((event) => {
|
|
const duration = event.end_time - event.start_time;
|
|
totalWatchTime += duration;
|
|
});
|
|
|
|
// Create a baseline array with zeros for every second
|
|
const viewDistributionMap = new Map<
|
|
number,
|
|
{ uniqueViewers: Set<string>; viewDurations: Map<string, number> }
|
|
>();
|
|
for (let t = 0; t <= videoLength; t++) {
|
|
viewDistributionMap.set(t, {
|
|
uniqueViewers: new Set(),
|
|
viewDurations: new Map(), // Map of view_id to number of times this second was viewed
|
|
});
|
|
}
|
|
|
|
// Fill in the actual playback periods
|
|
validEvents.forEach((event) => {
|
|
// For each second in the duration, track the view
|
|
for (
|
|
let t = Math.floor(event.start_time);
|
|
t < Math.ceil(event.end_time);
|
|
t++
|
|
) {
|
|
const stats = viewDistributionMap.get(t);
|
|
if (!stats) {
|
|
console.warn(`No stats found for time ${t}, skipping`);
|
|
continue;
|
|
}
|
|
stats.uniqueViewers.add(event.view_id);
|
|
|
|
// Increment the count for this view_id at this second
|
|
const currentCount = stats.viewDurations.get(event.view_id) || 0;
|
|
stats.viewDurations.set(event.view_id, currentCount + 1);
|
|
}
|
|
});
|
|
|
|
// Sort events by timestamp to find first and last view
|
|
const sortedEvents = [...validEvents].sort(
|
|
(a, b) =>
|
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
|
);
|
|
|
|
// Convert view distribution to sorted array with both metrics
|
|
const distributionArray = Array.from(viewDistributionMap.entries())
|
|
.map(([start_time, stats]) => {
|
|
// Sum up all view durations for this second
|
|
let totalViews = 0;
|
|
stats.viewDurations.forEach((count) => {
|
|
totalViews += count;
|
|
});
|
|
|
|
return {
|
|
start_time,
|
|
unique_views: stats.uniqueViewers.size,
|
|
total_views: totalViews,
|
|
};
|
|
})
|
|
.sort((a, b) => a.start_time - b.start_time);
|
|
|
|
return {
|
|
overall: {
|
|
unique_views: uniqueViewIds.size,
|
|
total_views: uniqueViewIds.size,
|
|
total_watch_time: totalWatchTime,
|
|
avg_view_duration:
|
|
uniqueViewIds.size > 0 ? totalWatchTime / uniqueViewIds.size : 0,
|
|
first_viewed_at:
|
|
sortedEvents.length > 0 ? sortedEvents[0].timestamp : "",
|
|
last_viewed_at:
|
|
sortedEvents.length > 0
|
|
? sortedEvents[sortedEvents.length - 1].timestamp
|
|
: "",
|
|
view_distribution: distributionArray,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error("Error calculating analytics:", error);
|
|
console.error("Events data:", JSON.stringify(events, null, 2));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export default async function handle(
|
|
req: NextApiRequest,
|
|
res: NextApiResponse,
|
|
) {
|
|
if (req.method !== "GET") {
|
|
return res.status(405).json({ message: "Method not allowed" });
|
|
}
|
|
|
|
try {
|
|
const session = await getServerSession(req, res, authOptions);
|
|
const user = session?.user as CustomUser;
|
|
|
|
if (!user?.id) {
|
|
return res.status(401).json({ message: "Unauthorized" });
|
|
}
|
|
|
|
const { teamId, id: documentId } = req.query as {
|
|
teamId: string;
|
|
id: string;
|
|
};
|
|
|
|
// Check if user has access to this document and team
|
|
const document = await prisma.document.findFirst({
|
|
where: {
|
|
id: documentId,
|
|
teamId,
|
|
team: {
|
|
users: {
|
|
some: {
|
|
userId: user.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
include: {
|
|
versions: {
|
|
where: {
|
|
isPrimary: true,
|
|
},
|
|
select: {
|
|
length: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!document) {
|
|
return res.status(404).json({ message: "Document not found" });
|
|
}
|
|
|
|
const videoLength = document.versions[0]?.length || 51;
|
|
if (!videoLength) {
|
|
return res.status(400).json({ message: "Video length not found" });
|
|
}
|
|
|
|
try {
|
|
// Fetch video events from Tinybird
|
|
const response = await getVideoEventsByDocument({
|
|
document_id: documentId,
|
|
});
|
|
|
|
if (!response || !response.data) {
|
|
console.error("Invalid response from Tinybird:", response);
|
|
return res
|
|
.status(500)
|
|
.json({ message: "Invalid response from analytics service" });
|
|
}
|
|
|
|
// Validate that response.data is an array
|
|
if (!Array.isArray(response.data)) {
|
|
console.error("Response data is not an array:", response.data);
|
|
return res
|
|
.status(500)
|
|
.json({ message: "Invalid data format from analytics service" });
|
|
}
|
|
|
|
const analytics = calculateAnalytics(response.data, videoLength);
|
|
return res.status(200).json(analytics);
|
|
} catch (error) {
|
|
console.error("Tinybird error details:", {
|
|
error,
|
|
message: error instanceof Error ? error.message : "Unknown error",
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
return res.status(500).json({
|
|
message: "Error fetching video analytics",
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"Error in /api/teams/[teamId]/documents/[id]/video-analytics:",
|
|
{
|
|
error,
|
|
message: error instanceof Error ? error.message : "Unknown error",
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
},
|
|
);
|
|
return res.status(500).json({
|
|
message: "Internal Server Error",
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
});
|
|
}
|
|
}
|