diff --git a/src/server/model/website.ts b/src/server/model/website.ts index b0939eb..cbe6d9c 100644 --- a/src/server/model/website.ts +++ b/src/server/model/website.ts @@ -124,7 +124,7 @@ export async function findSession(req: Request): Promise<{ return res; } -async function loadWebsite(websiteId: string): Promise { +export async function loadWebsite(websiteId: string): Promise { const website = await prisma.website.findUnique({ where: { id: websiteId, diff --git a/src/server/model/workspace.ts b/src/server/model/workspace.ts index 8b3e4a6..0f85527 100644 --- a/src/server/model/workspace.ts +++ b/src/server/model/workspace.ts @@ -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, + }; +} diff --git a/src/server/router/workspace.ts b/src/server/router/workspace.ts index 6374eb2..c6c0b5a 100644 --- a/src/server/router/workspace.ts +++ b/src/server/router/workspace.ts @@ -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 }); + } +); diff --git a/src/server/utils/common.ts b/src/server/utils/common.ts index 0d360a0..bc41b86 100644 --- a/src/server/utils/common.ts +++ b/src/server/utils/common.ts @@ -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'; +} diff --git a/src/server/utils/const.ts b/src/server/utils/const.ts index 1f75368..a947285 100644 --- a/src/server/utils/const.ts +++ b/src/server/utils/const.ts @@ -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'; diff --git a/src/server/utils/prisma.ts b/src/server/utils/prisma.ts new file mode 100644 index 0000000..c435316 --- /dev/null +++ b/src/server/utils/prisma.ts @@ -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 = {}) { + return Object.keys(filters).reduce((obj, key) => { + const value = filters[key]; + + obj[key] = value?.value ?? value; + + return obj; + }, {} as Record); +} + +export function getFilterQuery( + filters: QueryFilters = {}, + options: QueryOptions = {} +): string { + const query = Object.keys(filters).reduce((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]}')`; +}