Files
2025-12-14 15:46:55 +01:00

504 lines
14 KiB
TypeScript

import { Prisma } from "@prisma/client";
import prisma from "@/lib/prisma";
import { getTotalTeamDuration } from "@/lib/tinybird/pipes";
import { COUNTRIES } from "../constants";
// Country centroids (latitude, longitude) for distance calculations
const COUNTRY_CENTROIDS: { [key: string]: { lat: number; lng: number } } = {
AF: { lat: 33.94, lng: 67.71 },
AL: { lat: 41.15, lng: 20.17 },
DZ: { lat: 28.03, lng: 1.66 },
AD: { lat: 42.55, lng: 1.6 },
AO: { lat: -11.2, lng: 17.87 },
AR: { lat: -38.42, lng: -63.62 },
AM: { lat: 40.07, lng: 45.04 },
AU: { lat: -25.27, lng: 133.78 },
AT: { lat: 47.52, lng: 14.55 },
AZ: { lat: 40.14, lng: 47.58 },
BH: { lat: 25.93, lng: 50.64 },
BD: { lat: 23.68, lng: 90.36 },
BY: { lat: 53.71, lng: 27.95 },
BE: { lat: 50.5, lng: 4.47 },
BZ: { lat: 17.19, lng: -88.5 },
BJ: { lat: 9.31, lng: 2.32 },
BT: { lat: 27.51, lng: 90.43 },
BO: { lat: -16.29, lng: -63.59 },
BA: { lat: 43.92, lng: 17.68 },
BW: { lat: -22.33, lng: 24.68 },
BR: { lat: -14.24, lng: -51.93 },
BN: { lat: 4.54, lng: 114.73 },
BG: { lat: 42.73, lng: 25.49 },
BF: { lat: 12.24, lng: -1.56 },
BI: { lat: -3.37, lng: 29.92 },
KH: { lat: 12.57, lng: 104.99 },
CM: { lat: 7.37, lng: 12.35 },
CA: { lat: 56.13, lng: -106.35 },
CF: { lat: 6.61, lng: 20.94 },
TD: { lat: 15.45, lng: 18.73 },
CL: { lat: -35.68, lng: -71.54 },
CN: { lat: 35.86, lng: 104.2 },
CO: { lat: 4.57, lng: -74.3 },
CG: { lat: -0.23, lng: 15.83 },
CD: { lat: -4.04, lng: 21.76 },
CR: { lat: 9.75, lng: -83.75 },
CI: { lat: 7.54, lng: -5.55 },
HR: { lat: 45.1, lng: 15.2 },
CU: { lat: 21.52, lng: -77.78 },
CY: { lat: 35.13, lng: 33.43 },
CZ: { lat: 49.82, lng: 15.47 },
DK: { lat: 56.26, lng: 9.5 },
DO: { lat: 18.74, lng: -70.16 },
EC: { lat: -1.83, lng: -78.18 },
EG: { lat: 26.82, lng: 30.8 },
SV: { lat: 13.79, lng: -88.9 },
EE: { lat: 58.6, lng: 25.01 },
ET: { lat: 9.15, lng: 40.49 },
FI: { lat: 61.92, lng: 25.75 },
FR: { lat: 46.23, lng: 2.21 },
GA: { lat: -0.8, lng: 11.61 },
GE: { lat: 42.32, lng: 43.36 },
DE: { lat: 51.17, lng: 10.45 },
GH: { lat: 7.95, lng: -1.02 },
GR: { lat: 39.07, lng: 21.82 },
GT: { lat: 15.78, lng: -90.23 },
HN: { lat: 15.2, lng: -86.24 },
HK: { lat: 22.4, lng: 114.11 },
HU: { lat: 47.16, lng: 19.5 },
IS: { lat: 64.96, lng: -19.02 },
IN: { lat: 20.59, lng: 78.96 },
ID: { lat: -0.79, lng: 113.92 },
IR: { lat: 32.43, lng: 53.69 },
IQ: { lat: 33.22, lng: 43.68 },
IE: { lat: 53.41, lng: -8.24 },
IL: { lat: 31.05, lng: 34.85 },
IT: { lat: 41.87, lng: 12.57 },
JM: { lat: 18.11, lng: -77.3 },
JP: { lat: 36.2, lng: 138.25 },
JO: { lat: 30.59, lng: 36.24 },
KZ: { lat: 48.02, lng: 66.92 },
KE: { lat: -0.02, lng: 37.91 },
KR: { lat: 35.91, lng: 127.77 },
KW: { lat: 29.31, lng: 47.48 },
KG: { lat: 41.2, lng: 74.77 },
LA: { lat: 19.86, lng: 102.5 },
LV: { lat: 56.88, lng: 24.6 },
LB: { lat: 33.85, lng: 35.86 },
LY: { lat: 26.34, lng: 17.23 },
LT: { lat: 55.17, lng: 23.88 },
LU: { lat: 49.82, lng: 6.13 },
MO: { lat: 22.2, lng: 113.54 },
MK: { lat: 41.51, lng: 21.75 },
MG: { lat: -18.77, lng: 46.87 },
MY: { lat: 4.21, lng: 101.98 },
MV: { lat: 3.2, lng: 73.22 },
ML: { lat: 17.57, lng: -4.0 },
MT: { lat: 35.94, lng: 14.38 },
MX: { lat: 23.63, lng: -102.55 },
MD: { lat: 47.41, lng: 28.37 },
MN: { lat: 46.86, lng: 103.85 },
ME: { lat: 42.71, lng: 19.37 },
MA: { lat: 31.79, lng: -7.09 },
MZ: { lat: -18.67, lng: 35.53 },
MM: { lat: 21.91, lng: 95.96 },
NA: { lat: -22.96, lng: 18.49 },
NP: { lat: 28.39, lng: 84.12 },
NL: { lat: 52.13, lng: 5.29 },
NZ: { lat: -40.9, lng: 174.89 },
NI: { lat: 12.87, lng: -85.21 },
NE: { lat: 17.61, lng: 8.08 },
NG: { lat: 9.08, lng: 8.68 },
NO: { lat: 60.47, lng: 8.47 },
OM: { lat: 21.51, lng: 55.92 },
PK: { lat: 30.38, lng: 69.35 },
PA: { lat: 8.54, lng: -80.78 },
PY: { lat: -23.44, lng: -58.44 },
PE: { lat: -9.19, lng: -75.02 },
PH: { lat: 12.88, lng: 121.77 },
PL: { lat: 51.92, lng: 19.15 },
PT: { lat: 39.4, lng: -8.22 },
PR: { lat: 18.22, lng: -66.59 },
QA: { lat: 25.35, lng: 51.18 },
RO: { lat: 45.94, lng: 24.97 },
RU: { lat: 61.52, lng: 105.32 },
RW: { lat: -1.94, lng: 29.87 },
SA: { lat: 23.89, lng: 45.08 },
SN: { lat: 14.5, lng: -14.45 },
RS: { lat: 44.02, lng: 21.01 },
SG: { lat: 1.35, lng: 103.82 },
SK: { lat: 48.67, lng: 19.7 },
SI: { lat: 46.15, lng: 14.99 },
ZA: { lat: -30.56, lng: 22.94 },
ES: { lat: 40.46, lng: -3.75 },
LK: { lat: 7.87, lng: 80.77 },
SE: { lat: 60.13, lng: 18.64 },
CH: { lat: 46.82, lng: 8.23 },
TW: { lat: 23.7, lng: 120.96 },
TJ: { lat: 38.86, lng: 71.28 },
TZ: { lat: -6.37, lng: 34.89 },
TH: { lat: 15.87, lng: 100.99 },
TN: { lat: 33.89, lng: 9.54 },
TR: { lat: 38.96, lng: 35.24 },
TM: { lat: 38.97, lng: 59.56 },
UG: { lat: 1.37, lng: 32.29 },
UA: { lat: 48.38, lng: 31.17 },
AE: { lat: 23.42, lng: 53.85 },
GB: { lat: 55.38, lng: -3.44 },
US: { lat: 37.09, lng: -95.71 },
UY: { lat: -32.52, lng: -55.77 },
UZ: { lat: 41.38, lng: 64.59 },
VE: { lat: 6.42, lng: -66.59 },
VN: { lat: 14.06, lng: 108.28 },
YE: { lat: 15.55, lng: 48.52 },
ZM: { lat: -13.13, lng: 27.85 },
ZW: { lat: -19.02, lng: 29.15 },
};
// Haversine formula to calculate distance between two points on Earth
function calculateDistance(
lat1: number,
lng1: number,
lat2: number,
lng2: number,
): number {
const R = 6371; // Earth's radius in kilometers
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = ((lng2 - lng1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// Default origin (San Francisco) used when user's location is not available
const DEFAULT_ORIGIN = { lat: 37.77, lng: -122.42 };
// Calculate total distance traveled based on unique countries
// Uses the user's current location as the origin point
function calculateTotalDistance(
countryCodes: string[],
origin: { lat: number; lng: number },
): number {
let totalDistance = 0;
for (const code of countryCodes) {
const centroid = COUNTRY_CENTROIDS[code];
if (centroid) {
totalDistance += calculateDistance(
origin.lat,
origin.lng,
centroid.lat,
centroid.lng,
);
}
}
return Math.round(totalDistance);
}
export async function getYearInReviewStats(
teamId: string,
year?: number,
userGeo?: { latitude?: string; longitude?: string },
) {
const currentYear = year || new Date().getFullYear();
const yearStart = new Date(`${currentYear}-01-01`);
const yearEnd = new Date(`${currentYear + 1}-01-01`);
// First, get team member emails to exclude from "most viewed" and "most active viewer"
const teamMembers = await prisma.userTeam.findMany({
where: { teamId },
select: {
user: {
select: {
email: true,
},
},
},
});
const teamMemberEmails = teamMembers
.map((member) => member.user.email)
.filter((email): email is string => email !== null);
// Run all queries in parallel for a specific team
const [
documents,
links,
views,
mostViewedDoc,
mostActiveMonth,
dataroomCounts,
mostActiveViewer,
] = await Promise.all([
prisma.document.findMany({
where: {
teamId,
createdAt: {
gte: yearStart,
lt: yearEnd,
},
},
select: {
id: true,
},
}),
prisma.link.count({
where: {
teamId,
createdAt: {
gte: yearStart,
lt: yearEnd,
},
},
}),
prisma.view.count({
where: {
teamId,
viewedAt: {
gte: yearStart,
lt: yearEnd,
},
isArchived: false,
},
}),
// 2. Most viewed document
prisma.$queryRaw<
Array<{ documentId: string; documentName: string; viewCount: number }>
>(Prisma.sql`
WITH RankedDocuments AS (
SELECT
d."id" as "documentId",
d."name" as "documentName",
COUNT(v."id") as "viewCount",
ROW_NUMBER() OVER (ORDER BY COUNT(v."id") DESC) as rn
FROM "Document" d
LEFT JOIN "View" v ON v."documentId" = d."id"
WHERE
d."teamId" = ${teamId}
AND v."viewedAt" >= ${yearStart}
AND v."viewedAt" < ${yearEnd}
AND v."isArchived" = false
GROUP BY d."id", d."name"
)
SELECT
"documentId",
"documentName",
"viewCount"
FROM RankedDocuments
WHERE rn = 1
LIMIT 1
`),
// 3. Most active month
prisma.$queryRaw<Array<{ month: Date; viewCount: number }>>(Prisma.sql`
WITH MonthlyViews AS (
SELECT
DATE_TRUNC('month', "viewedAt") as month,
COUNT(*) as "viewCount",
ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) as rn
FROM "View"
WHERE
"teamId" = ${teamId}
AND "viewedAt" >= ${yearStart}
AND "viewedAt" < ${yearEnd}
AND "isArchived" = false
GROUP BY DATE_TRUNC('month', "viewedAt")
)
SELECT month, "viewCount"
FROM MonthlyViews
WHERE rn = 1
LIMIT 1;
`),
// 4. Total datarooms for the year
prisma.dataroom.count({
where: {
teamId,
createdAt: {
gte: yearStart,
lt: yearEnd,
},
},
}),
// 5. Most active viewer (person who viewed most, excluding team members)
teamMemberEmails.length > 0
? prisma.$queryRaw<
Array<{
viewerEmail: string;
viewerName: string | null;
viewCount: number;
}>
>(Prisma.sql`
WITH RankedViewers AS (
SELECT
v."viewerEmail",
MAX(v."viewerName") as "viewerName",
COUNT(*) as "viewCount",
ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) as rn
FROM "View" v
WHERE
v."teamId" = ${teamId}
AND v."viewedAt" >= ${yearStart}
AND v."viewedAt" < ${yearEnd}
AND v."isArchived" = false
AND v."viewerEmail" IS NOT NULL
AND v."viewerEmail" NOT IN (${Prisma.join(teamMemberEmails)})
GROUP BY v."viewerEmail"
)
SELECT
"viewerEmail",
"viewerName",
"viewCount"
FROM RankedViewers
WHERE rn = 1
LIMIT 1
`)
: prisma.$queryRaw<
Array<{
viewerEmail: string;
viewerName: string | null;
viewCount: number;
}>
>(Prisma.sql`
WITH RankedViewers AS (
SELECT
v."viewerEmail",
MAX(v."viewerName") as "viewerName",
COUNT(*) as "viewCount",
ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) as rn
FROM "View" v
WHERE
v."teamId" = ${teamId}
AND v."viewedAt" >= ${yearStart}
AND v."viewedAt" < ${yearEnd}
AND v."isArchived" = false
AND v."viewerEmail" IS NOT NULL
GROUP BY v."viewerEmail"
)
SELECT
"viewerEmail",
"viewerName",
"viewCount"
FROM RankedViewers
WHERE rn = 1
LIMIT 1
`),
]);
// skip if no documents
if (documents.length === 0) {
return {
year: currentYear,
totalDocuments: documents.length || 0,
totalLinks: links || 0,
totalViews: views || 0,
totalDatarooms: dataroomCounts || 0,
mostViewedDocument: null,
mostActiveMonth: null,
mostActiveViewer: null,
totalDuration: 0,
uniqueCountries: [],
distanceTraveled: 0,
};
}
// Batch document IDs to avoid URL length limits (max ~100 IDs per request)
const BATCH_SIZE = 100;
const documentIds = documents.map((doc) => doc.id);
const batches: string[][] = [];
for (let i = 0; i < documentIds.length; i += BATCH_SIZE) {
batches.push(documentIds.slice(i, i + BATCH_SIZE));
}
// Fetch all batches in parallel
const tinybirdResults = await Promise.all(
batches.map((batch) =>
getTotalTeamDuration({
documentIds: batch.join(","),
since: yearStart.getTime(),
until: yearEnd.getTime(),
}).catch(() => ({ data: [] })),
),
);
// Aggregate results from all batches
let totalDuration = 0;
const allCountries = new Set<string>();
for (const result of tinybirdResults) {
if (result.data[0]) {
totalDuration += result.data[0].total_duration || 0;
result.data[0].unique_countries?.forEach((country: string) =>
allCountries.add(country),
);
}
}
// Get country codes array for distance calculation
const countryCodes = Array.from(allCountries);
// Determine origin point from user's IP location or use default
const parsedLat =
userGeo?.latitude !== undefined ? Number(userGeo.latitude) : NaN;
const parsedLng =
userGeo?.longitude !== undefined ? Number(userGeo.longitude) : NaN;
const origin =
Number.isFinite(parsedLat) && Number.isFinite(parsedLng)
? { lat: parsedLat, lng: parsedLng }
: DEFAULT_ORIGIN;
// Calculate total distance traveled based on IP locations
const distanceTraveled = calculateTotalDistance(countryCodes, origin);
// Write full name of countries for display
const uniqueCountries = countryCodes.map((country: string) => {
// Map country codes to full names using COUNTRIES constant
return COUNTRIES[country] || country;
});
return {
year: currentYear,
totalDocuments: documents.length || 0,
totalLinks: links || 0,
totalViews: views || 0,
totalDatarooms: dataroomCounts || 0,
mostViewedDocument: mostViewedDoc[0]
? {
documentId: mostViewedDoc[0].documentId,
documentName: mostViewedDoc[0].documentName,
viewCount: Number(mostViewedDoc[0].viewCount),
}
: null,
mostActiveMonth: mostActiveMonth?.[0]
? {
month: new Date(mostActiveMonth[0].month).toLocaleString("en-US", {
month: "long",
}),
viewCount: Number(mostActiveMonth[0].viewCount),
}
: null,
mostActiveViewer: mostActiveViewer?.[0]
? {
email: mostActiveViewer[0].viewerEmail,
name: mostActiveViewer[0].viewerName,
viewCount: Number(mostActiveViewer[0].viewCount),
}
: null,
totalDuration: totalDuration || 0,
uniqueCountries: uniqueCountries || [],
distanceTraveled: distanceTraveled || 0,
};
}