feat: add website pageviews api

This commit is contained in:
moonrailgun 2023-09-12 23:04:39 +08:00
parent 0d8b501cf7
commit f8a372006f
6 changed files with 357 additions and 32 deletions

View File

@ -124,7 +124,7 @@ export async function findSession(req: Request): Promise<{
return res;
}
async function loadWebsite(websiteId: string): Promise<Website | null> {
export async function loadWebsite(websiteId: string): Promise<Website | null> {
const website = await prisma.website.findUnique({
where: {
id: websiteId,

View File

@ -1,4 +1,6 @@
import { prisma } from './_client';
import { QueryFilters, parseFilters, getDateQuery } from '../utils/prisma';
import { DEFAULT_RESET_DATE, EVENT_TYPE } from '../utils/const';
export async function getWorkspaceUser(workspaceId: string, userId: string) {
const info = await prisma.workspacesOnUsers.findFirst({
@ -100,3 +102,54 @@ export async function deleteWorkspaceWebsite(
return website;
}
export async function getWorkspaceWebsitePageviewStats(
websiteId: string,
filters: QueryFilters
) {
const { timezone = 'utc', unit = 'day' } = filters;
const { filterQuery, joinSession, params } = await parseFilters(websiteId, {
...filters,
eventType: EVENT_TYPE.pageView,
});
return prisma.$queryRaw`
select
${getDateQuery('website_event.created_at', unit, timezone)} x,
count(*) y
from website_event
${joinSession}
where website_event.website_id = ${params.websiteId}
and website_event.created_at
between ${params.startDate} and ${(params as any).endDate}
and event_type = {{eventType}}
${filterQuery}
group by 1
`;
}
export async function getWorkspaceWebsiteDateRange(websiteId: string) {
const { params } = await parseFilters(websiteId, {
startDate: new Date(DEFAULT_RESET_DATE),
});
const res = await prisma.websiteEvent.aggregate({
_max: {
createdAt: true,
},
_min: {
createdAt: true,
},
where: {
websiteId,
createdAt: {
gt: params.startDate,
},
},
});
return {
max: res._max.createdAt,
min: res._min.createdAt,
};
}

View File

@ -6,10 +6,13 @@ import {
addWorkspaceWebsite,
deleteWorkspaceWebsite,
getWorkspaceWebsiteInfo,
getWorkspaceWebsitePageviewStats,
getWorkspaceWebsites,
updateWorkspaceWebsiteInfo,
} from '../model/workspace';
import { parseDateRange } from '../utils/common';
import { ROLES } from '../utils/const';
import { QueryFilters } from '../utils/prisma';
export const workspaceRouter = Router();
@ -36,11 +39,7 @@ workspaceRouter.get(
workspaceRouter.post(
'/website',
validate(
body('workspaceId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID'),
body('workspaceId').isUUID().withMessage('workspaceId should be UUID'),
body('name')
.isString()
.withMessage('name should be string')
@ -66,16 +65,8 @@ workspaceRouter.post(
workspaceRouter.get(
'/website/:websiteId',
validate(
query('workspaceId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID'),
param('websiteId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID')
query('workspaceId').isUUID().withMessage('workspaceId should be UUID'),
param('websiteId').isUUID().withMessage('workspaceId should be UUID')
),
auth(),
workspacePermission(),
@ -92,14 +83,9 @@ workspaceRouter.get(
workspaceRouter.post(
'/website/:websiteId',
validate(
body('workspaceId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID'),
body('workspaceId').isUUID().withMessage('workspaceId should be UUID'),
param('websiteId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID'),
body('name')
@ -134,16 +120,8 @@ workspaceRouter.post(
workspaceRouter.delete(
'/:workspaceId/website/:websiteId',
validate(
param('workspaceId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID'),
param('websiteId')
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID')
param('workspaceId').isUUID().withMessage('workspaceId should be UUID'),
param('websiteId').isUUID().withMessage('workspaceId should be UUID')
),
auth(),
workspacePermission([ROLES.owner]),
@ -156,3 +134,61 @@ workspaceRouter.delete(
res.json({ website });
}
);
workspaceRouter.get(
'/:workspaceId/website/:websiteId/pageviews',
validate(
param('workspaceId').isUUID().withMessage('workspaceId should be UUID'),
param('websiteId').isUUID().withMessage('workspaceId should be UUID')
),
auth(),
workspacePermission(),
async (req, res) => {
const workspaceId = req.params.workspaceId;
const websiteId = req.params.websiteId;
const {
timezone,
url,
referrer,
title,
os,
browser,
device,
country,
region,
city,
startAt,
endAt,
} = req.query;
const { startDate, endDate, unit } = await parseDateRange({
websiteId,
startAt: Number(startAt),
endAt: Number(endAt),
unit: String(req.query.unit),
});
const filters = {
startDate,
endDate,
timezone,
unit,
url,
referrer,
title,
os,
browser,
device,
country,
region,
city,
};
const website = await getWorkspaceWebsitePageviewStats(
websiteId,
filters as QueryFilters
);
res.json({ website });
}
);

View File

@ -4,6 +4,7 @@ import { DATA_TYPE } from './const';
import { DynamicDataType } from './types';
import dayjs from 'dayjs';
import jwt from 'jsonwebtoken';
import { max } from 'lodash-es';
export function isUuid(value: string) {
return validate(value);
@ -148,3 +149,67 @@ export function parseToken(token: string, secret = jwtSecret) {
return null;
}
}
export function maxDate(...args: any[]) {
return max(args.filter((n) => dayjs(n).isValid()));
}
export async function parseDateRange({
websiteId,
startAt,
endAt,
unit,
}: {
websiteId: string;
startAt: number;
endAt: number;
unit: string;
}) {
// All-time
if (+startAt === 0 && +endAt === 1) {
const result = await getWorkspaceWebsiteDateRange(websiteId as string);
const { min, max } = result[0];
const startDate = new Date(min);
const endDate = new Date(max);
return {
startDate,
endDate,
unit: getMinimumUnit(startDate, endDate),
};
}
const startDate = new Date(+startAt);
const endDate = new Date(+endAt);
const minUnit = getMinimumUnit(startDate, endDate);
return {
startDate,
endDate,
unit: (getAllowedUnits(startDate, endDate).includes(unit as string)
? unit
: minUnit) as string,
};
}
export function getAllowedUnits(startDate: Date, endDate: Date) {
const units = ['minute', 'hour', 'day', 'month', 'year'];
const minUnit = getMinimumUnit(startDate, endDate);
const index = units.indexOf(minUnit);
return index >= 0 ? units.splice(index) : [];
}
export function getMinimumUnit(startDate: Date, endDate: Date) {
if (dayjs(endDate).diff(startDate, 'minutes') <= 60) {
return 'minute';
} else if (dayjs(endDate).diff(startDate, 'hours') <= 48) {
return 'hour';
} else if (dayjs(endDate).diff(startDate, 'days') <= 90) {
return 'day';
} else if (dayjs(endDate).diff(startDate, 'months') <= 24) {
return 'month';
}
return 'year';
}

View File

@ -66,3 +66,48 @@ export const DATA_TYPE = {
date: 4,
array: 5,
} as const;
export const SESSION_COLUMNS = [
'browser',
'os',
'device',
'screen',
'language',
'country',
'region',
'city',
];
export const OPERATORS = {
equals: 'eq',
notEquals: 'neq',
set: 's',
notSet: 'ns',
contains: 'c',
doesNotContain: 'dnc',
true: 't',
false: 'f',
greaterThan: 'gt',
lessThan: 'lt',
greaterThanEquals: 'gte',
lessThanEquals: 'lte',
before: 'bf',
after: 'af',
} as const;
export const FILTER_COLUMNS = {
url: 'url_path',
referrer: 'referrer_domain',
title: 'page_title',
query: 'url_query',
os: 'os',
browser: 'browser',
device: 'device',
country: 'country',
region: 'subdivision1',
city: 'city',
language: 'language',
event: 'event_name',
};
export const DEFAULT_RESET_DATE = '2000-01-01';

126
src/server/utils/prisma.ts Normal file
View File

@ -0,0 +1,126 @@
import { get } from 'lodash-es';
import { loadWebsite } from '../model/website';
import { maxDate } from './common';
import { FILTER_COLUMNS, OPERATORS, SESSION_COLUMNS } from './const';
const POSTGRESQL_DATE_FORMATS = {
minute: 'YYYY-MM-DD HH24:MI:00',
hour: 'YYYY-MM-DD HH24:00:00',
day: 'YYYY-MM-DD',
month: 'YYYY-MM-01',
year: 'YYYY-01-01',
};
export interface QueryFilters {
startDate?: Date;
endDate?: Date;
timezone?: string;
unit?: keyof typeof POSTGRESQL_DATE_FORMATS;
eventType?: number;
url?: string;
referrer?: string;
title?: string;
query?: string;
os?: string;
browser?: string;
device?: string;
country?: string;
region?: string;
city?: string;
language?: string;
event?: string;
}
export interface QueryOptions {
joinSession?: boolean;
columns?: { [key: string]: string };
}
export async function parseFilters(
websiteId: string,
filters: QueryFilters = {},
options: QueryOptions = {}
) {
const website = await loadWebsite(websiteId);
if (!website) {
throw new Error('Not found website');
}
return {
joinSession:
options?.joinSession ||
Object.keys(filters).find((key) => SESSION_COLUMNS.includes(key))
? `inner join session on website_event.session_id = session.session_id`
: '',
filterQuery: getFilterQuery(filters, options),
params: {
...normalizeFilters(filters),
websiteId,
startDate: maxDate(filters.startDate, website.resetAt),
websiteDomain: website.domain,
},
};
}
function normalizeFilters(filters: Record<string, any> = {}) {
return Object.keys(filters).reduce((obj, key) => {
const value = filters[key];
obj[key] = value?.value ?? value;
return obj;
}, {} as Record<string, any>);
}
export function getFilterQuery(
filters: QueryFilters = {},
options: QueryOptions = {}
): string {
const query = Object.keys(filters).reduce<string[]>((arr, name) => {
const value: any = filters[name as keyof QueryFilters];
const operator = value?.filter ?? OPERATORS.equals;
const column = get(FILTER_COLUMNS, name, options?.columns?.[name]);
if (value !== undefined && column) {
arr.push(`and ${mapFilter(column, operator, name)}`);
if (name === 'referrer') {
arr.push(
'and (website_event.referrer_domain != {{websiteDomain}} or website_event.referrer_domain is null)'
);
}
}
return arr;
}, []);
return query.join('\n');
}
function mapFilter(
column: string,
operator: (typeof OPERATORS)[keyof typeof OPERATORS],
name: string,
type = 'varchar'
) {
switch (operator) {
case OPERATORS.equals:
return `${column} = {{${name}::${type}}}`;
case OPERATORS.notEquals:
return `${column} != {{${name}::${type}}}`;
default:
return '';
}
}
export function getDateQuery(
field: string,
unit: keyof typeof POSTGRESQL_DATE_FORMATS,
timezone?: string
): string {
if (timezone) {
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
}
return `to_char(date_trunc('${unit}', ${field}), '${POSTGRESQL_DATE_FORMATS[unit]}')`;
}