feat: add website pageviews api
This commit is contained in:
parent
0d8b501cf7
commit
f8a372006f
@ -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,
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -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 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
@ -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';
|
||||||
|
}
|
||||||
|
@ -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
126
src/server/utils/prisma.ts
Normal 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]}')`;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user