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; 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({ const website = await prisma.website.findUnique({
where: { where: {
id: websiteId, id: websiteId,

View File

@ -1,4 +1,6 @@
import { prisma } from './_client'; 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) { export async function getWorkspaceUser(workspaceId: string, userId: string) {
const info = await prisma.workspacesOnUsers.findFirst({ const info = await prisma.workspacesOnUsers.findFirst({
@ -100,3 +102,54 @@ export async function deleteWorkspaceWebsite(
return website; 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, addWorkspaceWebsite,
deleteWorkspaceWebsite, deleteWorkspaceWebsite,
getWorkspaceWebsiteInfo, getWorkspaceWebsiteInfo,
getWorkspaceWebsitePageviewStats,
getWorkspaceWebsites, getWorkspaceWebsites,
updateWorkspaceWebsiteInfo, updateWorkspaceWebsiteInfo,
} from '../model/workspace'; } from '../model/workspace';
import { parseDateRange } from '../utils/common';
import { ROLES } from '../utils/const'; import { ROLES } from '../utils/const';
import { QueryFilters } from '../utils/prisma';
export const workspaceRouter = Router(); export const workspaceRouter = Router();
@ -36,11 +39,7 @@ workspaceRouter.get(
workspaceRouter.post( workspaceRouter.post(
'/website', '/website',
validate( validate(
body('workspaceId') body('workspaceId').isUUID().withMessage('workspaceId should be UUID'),
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID'),
body('name') body('name')
.isString() .isString()
.withMessage('name should be string') .withMessage('name should be string')
@ -66,16 +65,8 @@ workspaceRouter.post(
workspaceRouter.get( workspaceRouter.get(
'/website/:websiteId', '/website/:websiteId',
validate( validate(
query('workspaceId') query('workspaceId').isUUID().withMessage('workspaceId should be UUID'),
.isString() param('websiteId').isUUID().withMessage('workspaceId should be UUID')
.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')
), ),
auth(), auth(),
workspacePermission(), workspacePermission(),
@ -92,14 +83,9 @@ workspaceRouter.get(
workspaceRouter.post( workspaceRouter.post(
'/website/:websiteId', '/website/:websiteId',
validate( validate(
body('workspaceId') body('workspaceId').isUUID().withMessage('workspaceId should be UUID'),
.isString()
.withMessage('workspaceId should be string')
.isUUID()
.withMessage('workspaceId should be UUID'),
param('websiteId') param('websiteId')
.isString() .isString()
.withMessage('workspaceId should be string')
.isUUID() .isUUID()
.withMessage('workspaceId should be UUID'), .withMessage('workspaceId should be UUID'),
body('name') body('name')
@ -134,16 +120,8 @@ workspaceRouter.post(
workspaceRouter.delete( workspaceRouter.delete(
'/:workspaceId/website/:websiteId', '/:workspaceId/website/:websiteId',
validate( validate(
param('workspaceId') param('workspaceId').isUUID().withMessage('workspaceId should be UUID'),
.isString() param('websiteId').isUUID().withMessage('workspaceId should be UUID')
.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')
), ),
auth(), auth(),
workspacePermission([ROLES.owner]), workspacePermission([ROLES.owner]),
@ -156,3 +134,61 @@ workspaceRouter.delete(
res.json({ website }); 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 { DynamicDataType } from './types';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { max } from 'lodash-es';
export function isUuid(value: string) { export function isUuid(value: string) {
return validate(value); return validate(value);
@ -148,3 +149,67 @@ export function parseToken(token: string, secret = jwtSecret) {
return null; 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, date: 4,
array: 5, array: 5,
} as const; } 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]}')`;
}