tianji/src/server/model/website.ts

446 lines
11 KiB
TypeScript

import { Prisma, Website, WebsiteSession } from '@prisma/client';
import { flattenJSON, hashUuid, isCuid, parseToken } from '../utils/common.js';
import { prisma } from './_client.js';
import { Request } from 'express';
import { getClientInfo } from '../utils/detect.js';
import {
DATA_TYPE,
EVENT_NAME_LENGTH,
EVENT_TYPE,
SESSION_COLUMNS,
URL_LENGTH,
} from '../utils/const.js';
import type { DynamicData } from '../utils/types.js';
import dayjs from 'dayjs';
import {
WebsiteQueryFilters,
getDateQuery,
getTimestampIntervalQuery,
parseWebsiteFilters,
} from '../utils/prisma.js';
export interface WebsiteEventPayload {
data?: object;
hostname: string;
language?: string;
referrer?: string;
screen?: string;
title?: string;
url?: string;
website: string;
name?: string;
}
export async function findSession(req: Request): Promise<
WebsiteSession & {
workspaceId: string;
}
> {
// Verify payload
const { payload } = req.body;
// Check if cache token is passed
const cacheToken = req.headers['x-tianji-cache'] as string;
if (cacheToken) {
const result = parseToken(cacheToken);
if (result) {
return result as any;
}
}
const {
website: websiteId,
hostname,
screen,
language,
} = payload as WebsiteEventPayload;
// Check the hostname value for legality to eliminate dirty data
const validHostnameRegex = /^[\w-.]+$/;
if (typeof hostname === 'string' && !validHostnameRegex.test(hostname)) {
throw new Error('Invalid hostname.');
}
if (!isCuid(websiteId)) {
throw new Error('Invalid website ID.');
}
// Find website
const website = await loadWebsite(websiteId);
if (!website) {
throw new Error(`Website not found: ${websiteId}.`);
}
const {
userAgent,
browser,
os,
ip,
country,
subdivision1,
subdivision2,
city,
longitude,
latitude,
accuracyRadius,
device,
} = await getClientInfo(req, payload);
const sessionId = hashUuid(websiteId, hostname!, ip, userAgent!);
// Find session
let session = await loadSession(sessionId);
// Create a session if not found
if (!session) {
try {
session = await prisma.websiteSession.create({
data: {
id: sessionId,
websiteId,
hostname,
browser,
os,
device,
screen,
language,
ip,
country,
subdivision1,
subdivision2,
city,
longitude,
latitude,
accuracyRadius,
},
});
} catch (e: any) {
if (!e.message.toLowerCase().includes('unique constraint')) {
throw e;
}
}
}
const res: any = { ...session!, workspaceId: website.workspaceId };
return res;
}
export async function loadWebsite(websiteId: string): Promise<Website | null> {
const website = await prisma.website.findUnique({
where: {
id: websiteId,
},
});
if (!website || website.deletedAt) {
return null;
}
return website;
}
async function loadSession(sessionId: string): Promise<WebsiteSession | null> {
const session = await prisma.websiteSession.findUnique({
where: {
id: sessionId,
},
});
if (!session) {
return null;
}
return session;
}
export async function saveWebsiteEvent(data: {
sessionId: string;
websiteId: string;
urlPath: string;
urlQuery?: string;
referrerPath?: string;
referrerQuery?: string;
referrerDomain?: string;
pageTitle?: string;
eventName?: string;
eventData?: any;
}) {
const {
websiteId,
sessionId,
urlPath,
urlQuery,
referrerPath,
referrerQuery,
referrerDomain,
eventName,
eventData,
pageTitle,
} = data;
const websiteEvent = await prisma.websiteEvent.create({
data: {
websiteId,
sessionId,
urlPath: urlPath?.substring(0, URL_LENGTH),
urlQuery: urlQuery?.substring(0, URL_LENGTH),
referrerPath: referrerPath?.substring(0, URL_LENGTH),
referrerQuery: referrerQuery?.substring(0, URL_LENGTH),
referrerDomain: referrerDomain?.substring(0, URL_LENGTH),
pageTitle,
eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
},
});
if (eventData) {
const jsonKeys = flattenJSON(eventData);
// id, websiteEventId, eventStringValue
const flattendData = jsonKeys.map((a) => ({
websiteEventId: websiteEvent.id,
websiteId,
eventKey: a.key,
stringValue:
a.dynamicDataType === DATA_TYPE.number
? parseFloat(a.value).toFixed(4)
: a.dynamicDataType === DATA_TYPE.date
? a.value.split('.')[0] + 'Z'
: a.value.toString(),
numberValue: a.dynamicDataType === DATA_TYPE.number ? a.value : null,
dateValue:
a.dynamicDataType === DATA_TYPE.date ? new Date(a.value) : null,
dataType: a.dynamicDataType,
}));
await prisma.websiteEventData.createMany({
data: flattendData,
});
}
return websiteEvent;
}
export async function saveWebsiteSessionData(data: {
websiteId: string;
sessionId: string;
sessionData: DynamicData;
}) {
const { websiteId, sessionId, sessionData } = data;
const jsonKeys = flattenJSON(sessionData);
const flattendData = jsonKeys.map((a) => ({
websiteId,
sessionId,
key: a.key,
stringValue:
a.dynamicDataType === DATA_TYPE.number
? parseFloat(a.value).toFixed(4)
: a.dynamicDataType === DATA_TYPE.date
? a.value.split('.')[0] + 'Z'
: a.value.toString(),
numberValue: a.dynamicDataType === DATA_TYPE.number ? a.value : null,
dateValue: a.dynamicDataType === DATA_TYPE.date ? new Date(a.value) : null,
dataType: a.dynamicDataType,
}));
return prisma.$transaction([
prisma.websiteSessionData.deleteMany({
where: {
sessionId,
},
}),
prisma.websiteSessionData.createMany({
data: flattendData,
}),
]);
}
export async function getWebsiteOnlineUserCount(
websiteId: string
): Promise<number> {
const startAt = dayjs().subtract(5, 'minutes').toDate();
interface Ret {
x: number;
}
const res = await prisma.$queryRaw<
Ret[]
>`SELECT count(distinct "sessionId") x FROM "WebsiteEvent" where "websiteId" = ${websiteId} AND "createdAt" >= ${startAt}`;
return Number(res?.[0].x ?? 0);
}
export async function getWebsiteSessionMetrics(
websiteId: string,
column: string,
filters: WebsiteQueryFilters
): Promise<{ x: string; y: number }[]> {
const { filterQuery, joinSession, params } = await parseWebsiteFilters(
websiteId,
{
...filters,
},
{
joinSession: SESSION_COLUMNS.includes(column),
}
);
const includeCountry = column === 'city' || column === 'subdivision1';
return prisma.$queryRaw`select
${Prisma.sql([`"${column}"`])} x,
count(distinct "WebsiteEvent"."sessionId") y
${includeCountry ? Prisma.sql([', country']) : Prisma.empty}
from "WebsiteEvent"
${joinSession}
where "WebsiteEvent"."websiteId" = ${websiteId}
and "WebsiteEvent"."createdAt"
between ${params.startDate}::timestamptz and ${
params.endDate
}::timestamptz
and "WebsiteEvent"."eventType" = ${EVENT_TYPE.pageView}
${filterQuery}
group by 1
${includeCountry ? Prisma.sql([', 3']) : Prisma.empty}
order by 2 desc
limit 100`;
}
export async function getWebsitePageviewMetrics(
websiteId: string,
column: string,
filters: WebsiteQueryFilters
): Promise<{ x: string; y: number }[]> {
const eventType =
column === 'eventName' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView;
const { filterQuery, joinSession, params } = await parseWebsiteFilters(
websiteId,
{
...filters,
},
{ joinSession: SESSION_COLUMNS.includes(column) }
);
let excludeDomain = Prisma.empty;
if (column === 'referrerDomain') {
excludeDomain = Prisma.sql`and ("WebsiteEvent"."referrerDomain" != ${params.websiteDomain} or "WebsiteEvent"."referrerDomain" is null)`;
}
return prisma.$queryRaw`
select ${Prisma.sql([`"${column}"`])} x, count(*) y
from "WebsiteEvent"
${joinSession}
where "WebsiteEvent"."websiteId" = ${websiteId}
and "WebsiteEvent"."createdAt"
between ${params.startDate}::timestamptz and ${
params.endDate
}::timestamptz
and "eventType" = ${eventType}
${excludeDomain}
${filterQuery}
group by 1
order by 2 desc
limit 100
`;
}
export async function getWorkspaceWebsitePageview(
websiteId: string,
filters: WebsiteQueryFilters
) {
const { timezone = 'utc', unit = 'day' } = filters;
const { filterQuery, joinSession, params } = await parseWebsiteFilters(
websiteId,
{
...filters,
}
);
return prisma.$queryRaw`
select
${getDateQuery('"WebsiteEvent"."createdAt"', unit, timezone)} x,
count(1) y
from "WebsiteEvent"
${joinSession}
where "WebsiteEvent"."websiteId" = ${params.websiteId}
and "WebsiteEvent"."createdAt" between ${
params.startDate
}::timestamptz and ${params.endDate}::timestamptz
and "WebsiteEvent"."eventType" = ${EVENT_TYPE.pageView}
${filterQuery}
group by 1
`;
}
export async function getWorkspaceWebsiteSession(
websiteId: string,
filters: WebsiteQueryFilters
) {
const { timezone = 'utc', unit = 'day' } = filters;
const { filterQuery, joinSession, params } = await parseWebsiteFilters(
websiteId,
{
...filters,
}
);
return prisma.$queryRaw`
select
${getDateQuery('"WebsiteEvent"."createdAt"', unit, timezone)} x,
count(distinct "WebsiteEvent"."sessionId") y
from "WebsiteEvent"
${joinSession}
where "WebsiteEvent"."websiteId" = ${params.websiteId}
and "WebsiteEvent"."createdAt" between ${
params.startDate
}::timestamptz and ${params.endDate}::timestamptz
and "WebsiteEvent"."eventType" = ${EVENT_TYPE.pageView}
${filterQuery}
group by 1
`;
}
export async function getWorkspaceWebsiteStats(
websiteId: string,
filters: WebsiteQueryFilters
): Promise<any> {
const { filterQuery, joinSession, params } = await parseWebsiteFilters(
websiteId,
{
...filters,
}
);
return prisma.$queryRaw`
select
sum(t.c) as "pageviews",
count(distinct t."sessionId") as "uniques",
sum(case when t.c = 1 then 1 else 0 end) as "bounces",
sum(t.time) as "totaltime"
from (
select
"WebsiteEvent"."sessionId",
${getDateQuery('"WebsiteEvent"."createdAt"', 'hour')},
count(*) as c,
${getTimestampIntervalQuery('"WebsiteEvent"."createdAt"')} as "time"
from "WebsiteEvent"
join "Website"
on "WebsiteEvent"."websiteId" = "Website"."id"
${joinSession}
where "Website"."id" = ${params.websiteId}
and "WebsiteEvent"."createdAt" between ${
params.startDate
}::timestamptz and ${params.endDate}::timestamptz
and "eventType" = ${EVENT_TYPE.pageView}
${filterQuery}
group by 1, 2
) as t
`;
}