diff --git a/cypress/e2e/api-website.cy.ts b/cypress/e2e/api-website.cy.ts index 3fba48d3..7f7d17c3 100644 --- a/cypress/e2e/api-website.cy.ts +++ b/cypress/e2e/api-website.cy.ts @@ -1,3 +1,5 @@ +import { uuid } from '../../src/lib/crypto'; + describe('Website API tests', () => { Cypress.session.clearAllSavedSessions(); @@ -65,6 +67,37 @@ describe('Website API tests', () => { }); }); + it('Creates a website with a fixed ID.', () => { + cy.fixture('websites').then(data => { + const websiteCreate = data.websiteCreate; + const fixedId = uuid(); + cy.request({ + method: 'POST', + url: '/api/websites', + headers: { + 'Content-Type': 'application/json', + Authorization: Cypress.env('authorization'), + }, + body: { ...websiteCreate, id: fixedId }, + }).then(response => { + expect(response.status).to.eq(200); + expect(response.body).to.have.property('id', fixedId); + expect(response.body).to.have.property('name', 'Cypress Website'); + expect(response.body).to.have.property('domain', 'cypress.com'); + + // cleanup + cy.request({ + method: 'DELETE', + url: `/api/websites/${fixedId}`, + headers: { + 'Content-Type': 'application/json', + Authorization: Cypress.env('authorization'), + }, + }); + }); + }); + }); + it('Returns all tracked websites.', () => { cy.request({ method: 'GET', diff --git a/db/postgresql/migrations/11_add_segment/migration.sql b/db/postgresql/migrations/11_add_segment/migration.sql index 7fbb0867..1ae66ecb 100644 --- a/db/postgresql/migrations/11_add_segment/migration.sql +++ b/db/postgresql/migrations/11_add_segment/migration.sql @@ -2,8 +2,9 @@ CREATE TABLE "segment" ( "segment_id" UUID NOT NULL, "website_id" UUID NOT NULL, + "type" VARCHAR(200) NOT NULL, "name" VARCHAR(200) NOT NULL, - "filters" JSONB NOT NULL, + "parameters" JSONB NOT NULL, "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(6), diff --git a/db/postgresql/migrations/13_add_revenue/migration.sql b/db/postgresql/migrations/13_add_revenue/migration.sql index 96e11661..47f5db22 100644 --- a/db/postgresql/migrations/13_add_revenue/migration.sql +++ b/db/postgresql/migrations/13_add_revenue/migration.sql @@ -1,6 +1,3 @@ --- AlterTable -ALTER TABLE "segment" ADD COLUMN "type" VARCHAR(200) NOT NULL; - -- CreateTable CREATE TABLE "revenue" ( "revenue_id" UUID NOT NULL, diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index c297f4c4..39dac240 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -46,7 +46,7 @@ model Session { websiteEvent WebsiteEvent[] sessionData SessionData[] - revenue Revenue[] + revenue Revenue[] @@index([createdAt]) @@index([websiteId]) @@ -224,7 +224,7 @@ model Report { type String @db.VarChar(200) name String @db.VarChar(200) description String @db.VarChar(500) - parameters Json + parameters Json createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) @@ -239,13 +239,13 @@ model Report { } model Segment { - id String @id() @unique() @map("segment_id") @db.Uuid - websiteId String @map("website_id") @db.Uuid - type String @db.VarChar(200) - name String @db.VarChar(200) - filters Json - createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + id String @id() @unique() @map("segment_id") @db.Uuid + websiteId String @map("website_id") @db.Uuid + type String @db.VarChar(200) + name String @db.VarChar(200) + parameters Json + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) website Website @relation(fields: [websiteId], references: [id]) @@ -254,14 +254,14 @@ model Segment { } model Revenue { - id String @id() @unique() @map("revenue_id") @db.Uuid - websiteId String @map("website_id") @db.Uuid - sessionId String @map("session_id") @db.Uuid - eventId String @map("event_id") @db.Uuid - eventName String @map("event_name") @db.VarChar(50) - currency String @db.VarChar(100) - revenue Decimal? @db.Decimal(19, 4) - createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + id String @id() @unique() @map("revenue_id") @db.Uuid + websiteId String @map("website_id") @db.Uuid + sessionId String @map("session_id") @db.Uuid + eventId String @map("event_id") @db.Uuid + eventName String @map("event_name") @db.VarChar(50) + currency String @db.VarChar(100) + revenue Decimal? @db.Decimal(19, 4) + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) website Website @relation(fields: [websiteId], references: [id]) session Session @relation(fields: [sessionId], references: [id]) diff --git a/src/app/(main)/settings/SettingsLayout.tsx b/src/app/(main)/settings/SettingsLayout.tsx index 6d00a6ff..4110820e 100644 --- a/src/app/(main)/settings/SettingsLayout.tsx +++ b/src/app/(main)/settings/SettingsLayout.tsx @@ -24,11 +24,6 @@ export function SettingsLayout({ children }: { children: ReactNode }) { label: formatMessage(labels.websites), url: '/settings/websites', }, - user.isAdmin && { - id: 'users', - label: formatMessage(labels.users), - url: '/settings/users', - }, ].filter(n => n); const value = items.find(({ url }) => pathname.includes(url))?.id; diff --git a/src/app/api/realtime/[websiteId]/route.ts b/src/app/api/realtime/[websiteId]/route.ts index f86bfccc..f78688c3 100644 --- a/src/app/api/realtime/[websiteId]/route.ts +++ b/src/app/api/realtime/[websiteId]/route.ts @@ -21,7 +21,7 @@ export async function GET( return unauthorized(); } - const filters = await getQueryFilters({ + const filters = getQueryFilters({ ...query, websiteId, startAt: subMinutes(startOfMinute(new Date()), REALTIME_RANGE).getTime(), diff --git a/src/app/api/users/[userId]/usage/route.ts b/src/app/api/users/[userId]/usage/route.ts index 677e0bd7..466d97e5 100644 --- a/src/app/api/users/[userId]/usage/route.ts +++ b/src/app/api/users/[userId]/usage/route.ts @@ -22,7 +22,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user } const { userId } = await params; - const filters = await getQueryFilters(query); + const filters = getQueryFilters(query); const websites = await getAllUserWebsitesIncludingTeamOwner(userId); diff --git a/src/app/api/websites/[websiteId]/event-data/events/route.ts b/src/app/api/websites/[websiteId]/event-data/events/route.ts index eb984207..6b819609 100644 --- a/src/app/api/websites/[websiteId]/event-data/events/route.ts +++ b/src/app/api/websites/[websiteId]/event-data/events/route.ts @@ -26,7 +26,7 @@ export async function GET( } const { event } = query; - const filters = await getQueryFilters(query); + const filters = getQueryFilters(query); const data = await getEventDataEvents(websiteId, { ...filters, diff --git a/src/app/api/websites/[websiteId]/event-data/fields/route.ts b/src/app/api/websites/[websiteId]/event-data/fields/route.ts index 34aa6162..9448c748 100644 --- a/src/app/api/websites/[websiteId]/event-data/fields/route.ts +++ b/src/app/api/websites/[websiteId]/event-data/fields/route.ts @@ -25,7 +25,7 @@ export async function GET( return unauthorized(); } - const filters = await getQueryFilters(query); + const filters = getQueryFilters(query); const data = await getEventDataFields(websiteId, filters); diff --git a/src/app/api/websites/[websiteId]/event-data/properties/route.ts b/src/app/api/websites/[websiteId]/event-data/properties/route.ts index ad39e37a..918d51a2 100644 --- a/src/app/api/websites/[websiteId]/event-data/properties/route.ts +++ b/src/app/api/websites/[websiteId]/event-data/properties/route.ts @@ -27,7 +27,7 @@ export async function GET( } const { propertyName } = query; - const filters = await getQueryFilters(query); + const filters = getQueryFilters(query); const data = await getEventDataProperties(websiteId, { ...filters, propertyName }); diff --git a/src/app/api/websites/[websiteId]/event-data/stats/route.ts b/src/app/api/websites/[websiteId]/event-data/stats/route.ts index 79dd0059..ffc57f96 100644 --- a/src/app/api/websites/[websiteId]/event-data/stats/route.ts +++ b/src/app/api/websites/[websiteId]/event-data/stats/route.ts @@ -26,7 +26,7 @@ export async function GET( return unauthorized(); } - const filters = await getQueryFilters(query); + const filters = getQueryFilters(query); const data = await getEventDataStats(websiteId, filters); diff --git a/src/app/api/websites/[websiteId]/event-data/values/route.ts b/src/app/api/websites/[websiteId]/event-data/values/route.ts index 1eab63b5..9377c4a4 100644 --- a/src/app/api/websites/[websiteId]/event-data/values/route.ts +++ b/src/app/api/websites/[websiteId]/event-data/values/route.ts @@ -28,7 +28,7 @@ export async function GET( } const { eventName, propertyName } = query; - const filters = await getQueryFilters(query); + const filters = getQueryFilters(query); const data = await getEventDataValues(websiteId, { ...filters, diff --git a/src/app/api/websites/[websiteId]/events/route.ts b/src/app/api/websites/[websiteId]/events/route.ts index 0bf6f72c..1db9be97 100644 --- a/src/app/api/websites/[websiteId]/events/route.ts +++ b/src/app/api/websites/[websiteId]/events/route.ts @@ -27,7 +27,7 @@ export async function GET( return unauthorized(); } - const filters = await getQueryFilters(query); + const filters = getQueryFilters(query); const data = await getWebsiteEvents(websiteId, filters); diff --git a/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts b/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts new file mode 100644 index 00000000..fd2442cb --- /dev/null +++ b/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts @@ -0,0 +1,92 @@ +import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/lib/auth'; +import { parseRequest } from '@/lib/request'; +import { json, notFound, ok, unauthorized } from '@/lib/response'; +import { segmentTypeParam } from '@/lib/schema'; +import { deleteSegment, getSegment, updateSegment } from '@/queries'; +import { z } from 'zod'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; segmentId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, segmentId } = await params; + + const segment = await getSegment(segmentId); + + if (websiteId && !(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + return json(segment); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string; segmentId: string }> }, +) { + const schema = z.object({ + type: segmentTypeParam, + name: z.string().max(200), + parameters: z.object({}).passthrough(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId, segmentId } = await params; + const { type, name, parameters } = body; + + const segment = await getSegment(segmentId); + + if (!segment) { + return notFound(); + } + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const result = await updateSegment(segmentId, { + type, + name, + parameters, + } as any); + + return json(result); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ websiteId: string; segmentId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, segmentId } = await params; + + const segment = await getSegment(segmentId); + + if (!segment) { + return notFound(); + } + + if (!(await canDeleteWebsite(auth, websiteId))) { + return unauthorized(); + } + + await deleteSegment(segmentId); + + return ok(); +} diff --git a/src/app/api/websites/[websiteId]/segments/route.ts b/src/app/api/websites/[websiteId]/segments/route.ts new file mode 100644 index 00000000..2cf8388f --- /dev/null +++ b/src/app/api/websites/[websiteId]/segments/route.ts @@ -0,0 +1,67 @@ +import { canUpdateWebsite, canViewWebsite } from '@/lib/auth'; +import { uuid } from '@/lib/crypto'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { segmentTypeParam } from '@/lib/schema'; +import { createSegment, getWebsiteSegments } from '@/queries'; +import { z } from 'zod'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: segmentTypeParam, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { type } = query; + + if (websiteId && !(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const segments = await getWebsiteSegments(websiteId, type); + + return json(segments); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: segmentTypeParam, + name: z.string().max(200), + parameters: z.object({}).passthrough(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { type, name, parameters } = body; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const result = await createSegment({ + id: uuid(), + websiteId, + type, + name, + parameters, + } as any); + + return json(result); +} diff --git a/src/app/api/websites/[websiteId]/session-data/properties/route.ts b/src/app/api/websites/[websiteId]/session-data/properties/route.ts index bdc7c53c..f38ec556 100644 --- a/src/app/api/websites/[websiteId]/session-data/properties/route.ts +++ b/src/app/api/websites/[websiteId]/session-data/properties/route.ts @@ -22,7 +22,7 @@ export async function GET( const { websiteId } = await params; const { propertyName } = query; - const filters = await getQueryFilters(query); + const filters = getQueryFilters(query); if (!(await canViewWebsite(auth, websiteId))) { return unauthorized(); diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts index 5daf2dfb..c15d1135 100644 --- a/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts @@ -25,7 +25,7 @@ export async function GET( return unauthorized(); } - const filters = await getQueryFilters(query); + const filters = getQueryFilters(query); const data = await getSessionActivity(websiteId, sessionId, filters); diff --git a/src/app/api/websites/[websiteId]/sessions/weekly/route.ts b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts index 757e9da3..fb0eb1db 100644 --- a/src/app/api/websites/[websiteId]/sessions/weekly/route.ts +++ b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts @@ -28,7 +28,7 @@ export async function GET( return unauthorized(); } - const filters = await getQueryFilters(query); + const filters = getQueryFilters(query); const data = await getWebsiteSessionsWeekly(websiteId, filters); diff --git a/src/app/api/websites/[websiteId]/values/route.ts b/src/app/api/websites/[websiteId]/values/route.ts index 290e0db8..8e5a88ac 100644 --- a/src/app/api/websites/[websiteId]/values/route.ts +++ b/src/app/api/websites/[websiteId]/values/route.ts @@ -1,9 +1,10 @@ -import { z } from 'zod'; import { canViewWebsite } from '@/lib/auth'; -import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; -import { getValues } from '@/queries'; -import { parseRequest, getQueryFilters } from '@/lib/request'; +import { EVENT_COLUMNS, FILTER_COLUMNS, FILTER_GROUPS, SESSION_COLUMNS } from '@/lib/constants'; +import { getQueryFilters, parseRequest } from '@/lib/request'; import { badRequest, json, unauthorized } from '@/lib/response'; +import { getWebsiteSegments, getValues } from '@/queries'; +import { z } from 'zod'; +import { dateRangeParams, searchParams } from '@/lib/schema'; export async function GET( request: Request, @@ -11,9 +12,8 @@ export async function GET( ) { const schema = z.object({ type: z.string(), - startAt: z.coerce.number().int(), - endAt: z.coerce.number().int(), - search: z.string().optional(), + ...dateRangeParams, + ...searchParams, }); const { auth, query, error } = await parseRequest(request, schema); @@ -23,19 +23,25 @@ export async function GET( } const { websiteId } = await params; - const { type } = query; if (!(await canViewWebsite(auth, websiteId))) { return unauthorized(); } - if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type)) { + const { type } = query; + + if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type) && !FILTER_GROUPS[type]) { return badRequest('Invalid type.'); } - const filters = await getQueryFilters(query); + let values; - const values = await getValues(websiteId, FILTER_COLUMNS[type], { ...filters }); + if (FILTER_GROUPS[type]) { + values = (await getWebsiteSegments(websiteId, type)).map(segment => ({ value: segment.name })); + } else { + const filters = getQueryFilters(query); + values = await getValues(websiteId, FILTER_COLUMNS[type], filters); + } return json(values.filter(n => n).sort()); } diff --git a/src/app/api/websites/route.ts b/src/app/api/websites/route.ts index 78140864..96c45333 100644 --- a/src/app/api/websites/route.ts +++ b/src/app/api/websites/route.ts @@ -13,6 +13,7 @@ export async function POST(request: Request) { domain: z.string().max(500), shareId: z.string().max(50).nullable().optional(), teamId: z.string().nullable().optional(), + id: z.string().uuid().nullable().optional(), }); const { auth, body, error } = await parseRequest(request, schema); @@ -21,14 +22,14 @@ export async function POST(request: Request) { return error(); } - const { name, domain, shareId, teamId } = body; + const { id, name, domain, shareId, teamId } = body; if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) { return unauthorized(); } const data: any = { - id: uuid(), + id: id ?? uuid(), createdBy: auth.user.id, name, domain, diff --git a/src/components/common/LinkButton.tsx b/src/components/common/LinkButton.tsx index 69a025d1..ca1baccb 100644 --- a/src/components/common/LinkButton.tsx +++ b/src/components/common/LinkButton.tsx @@ -17,20 +17,13 @@ export function LinkButton({ scroll = true, target, children, - isDisabled, ...props }: LinkButtonProps) { const { dir } = useLocale(); return ( - diff --git a/src/components/hooks/useFields.ts b/src/components/hooks/useFields.ts index 5a3bc89b..f48b2dda 100644 --- a/src/components/hooks/useFields.ts +++ b/src/components/hooks/useFields.ts @@ -5,6 +5,9 @@ export function useFields() { const fields = [ { name: 'path', type: 'string', label: formatMessage(labels.path) }, + // { name: 'cohort', type: 'string', label: formatMessage(labels.cohort) }, + // { name: 'segment', type: 'string', label: formatMessage(labels.segment) }, + { name: 'url', type: 'string', label: formatMessage(labels.url) }, { name: 'title', type: 'string', label: formatMessage(labels.pageTitle) }, { name: 'referrer', type: 'string', label: formatMessage(labels.referrer) }, //{ name: 'query', type: 'string', label: formatMessage(labels.query) }, diff --git a/src/components/messages.ts b/src/components/messages.ts index 4f211533..6eaf743a 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -101,6 +101,8 @@ export const labels = defineMessages({ countries: { id: 'label.countries', defaultMessage: 'Countries' }, languages: { id: 'label.languages', defaultMessage: 'Languages' }, tags: { id: 'label.tags', defaultMessage: 'Tags' }, + segments: { id: 'label.segments', defaultMessage: 'Segments' }, + cohorts: { id: 'label.cohorts', defaultMessage: 'Cohorts' }, count: { id: 'label.count', defaultMessage: 'Count' }, average: { id: 'label.average', defaultMessage: 'Average' }, sum: { id: 'label.sum', defaultMessage: 'Sum' }, @@ -239,6 +241,8 @@ export const labels = defineMessages({ device: { id: 'label.device', defaultMessage: 'Device' }, pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' }, tag: { id: 'label.tag', defaultMessage: 'Tag' }, + segment: { id: 'label.segment', defaultMessage: 'Segment' }, + cohort: { id: 'label.cohort', defaultMessage: 'Cohort' }, day: { id: 'label.day', defaultMessage: 'Day' }, date: { id: 'label.date', defaultMessage: 'Date' }, pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' }, @@ -324,7 +328,6 @@ export const labels = defineMessages({ links: { id: 'label.links', defaultMessage: 'Links' }, pixels: { id: 'label.pixels', defaultMessage: 'Pixels' }, addBoard: { id: 'label.add-board', defaultMessage: 'Add board' }, - cohort: { id: 'label.cohort', defaultMessage: 'Cohort' }, maximize: { id: 'label.maximize', defaultMessage: 'Maximize' }, remaining: { id: 'label.remaining', defaultMessage: 'Remaining' }, conversion: { id: 'label.conversion', defaultMessage: 'Conversion' }, diff --git a/src/lang/vi-VN.json b/src/lang/vi-VN.json index 6cc39735..5c9a78a2 100644 --- a/src/lang/vi-VN.json +++ b/src/lang/vi-VN.json @@ -3,336 +3,277 @@ "label.actions": "Hành động", "label.activity": "Nhật ký hoạt động", "label.add": "Thêm", - "label.add-board": "Thêm bảng", "label.add-description": "Thêm mô tả", "label.add-member": "Thêm thành viên", "label.add-step": "Thêm bước", "label.add-website": "Thêm website", "label.admin": "Quản trị", - "label.affiliate": "Liên kết", - "label.after": "Sau", + "label.after": "Sau đó", "label.all": "Tất cả", "label.all-time": "Toàn thời gian", "label.analytics": "Phân tích", - "label.apply": "Áp dụng", - "label.attribution": "Phân bổ", - "label.attribution-description": "Xem cách người dùng tương tác với tiếp thị của bạn và điều gì thúc đẩy chuyển đổi.", "label.average": "Trung bình", - "label.back": "Quay về", - "label.before": "Trước", - "label.boards": "Bảng", + "label.back": "Quay lại", + "label.before": "Trước đó", "label.bounce-rate": "Tỷ lệ thoát trang", "label.breakdown": "Phân tích chi tiết", "label.browser": "Trình duyệt", - "label.browsers": "Trình duyệt", - "label.campaigns": "Chiến dịch", - "label.cancel": "Huỷ bỏ", + "label.browsers": "Các trình duyệt", + "label.cancel": "Hủy bỏ", "label.change-password": "Đổi mật khẩu", - "label.channels": "Kênh", - "label.cities": "Thành phố", + "label.cities": "Các thành phố", "label.city": "Thành phố", "label.clear-all": "Xóa tất cả", - "label.cohort": "Nhóm người dùng", "label.compare": "So sánh", - "label.compare-dates": "So sánh ngày", "label.confirm": "Xác nhận", "label.confirm-password": "Xác nhận mật khẩu", "label.contains": "Chứa", - "label.content": "Nội dung", "label.continue": "Tiếp tục", - "label.conversion": "Chuyển đổi", - "label.conversion-rate": "Tỷ lệ chuyển đổi", - "label.conversion-step": "Bước chuyển đổi", "label.count": "Số lượng", - "label.countries": "Quốc gia", + "label.countries": "Các quốc gia", "label.country": "Quốc gia", "label.create": "Tạo", "label.create-report": "Tạo báo cáo", "label.create-team": "Tạo nhóm", "label.create-user": "Tạo người dùng", "label.created": "Đã tạo", - "label.created-by": "Tạo bởi", - "label.currency": "Tiền tệ", + "label.created-by": "Được tạo bởi", "label.current": "Hiện tại", "label.current-password": "Mật khẩu hiện tại", - "label.custom-range": "Phạm vi ngày tuỳ chọn", + "label.custom-range": "Phạm vi tùy chỉnh", "label.dashboard": "Bảng điều khiển", "label.data": "Dữ liệu", "label.date": "Ngày", "label.date-range": "Phạm vi ngày", "label.day": "Ngày", "label.default-date-range": "Khoảng thời gian mặc định", - "label.delete": "Xoá", + "label.delete": "Xóa", "label.delete-report": "Xóa báo cáo", "label.delete-team": "Xóa nhóm", "label.delete-user": "Xóa người dùng", "label.delete-website": "Xóa website", "label.description": "Mô tả", - "label.desktop": "Máy bàn", + "label.desktop": "Máy tính để bàn", "label.details": "Chi tiết", "label.device": "Thiết bị", - "label.devices": "Thiết bị", - "label.direct": "Trực tiếp", - "label.dismiss": "Loại trừ", - "label.distinct-id": "ID riêng biệt", + "label.devices": "Các thiết bị", + "label.dismiss": "Bỏ qua", "label.does-not-contain": "Không chứa", - "label.does-not-include": "Không bao gồm", - "label.doest-not-exist": "Không tồn tại", "label.domain": "Tên miền", - "label.dropoff": "Rời bỏ", + "label.dropoff": "Tỷ lệ bỏ qua", "label.edit": "Chỉnh sửa", "label.edit-dashboard": "Chỉnh sửa bảng điều khiển", "label.edit-member": "Chỉnh sửa thành viên", - "label.email": "Email", - "label.enable-share-url": "Bật khả năng chia sẻ URL", + "label.enable-share-url": "Bật chia sẻ URL", "label.end-step": "Bước kết thúc", - "label.entry": "URL vào", + "label.entry": "URL truy cập", "label.event": "Sự kiện", "label.event-data": "Dữ liệu sự kiện", - "label.event-name": "Tên sự kiện", - "label.events": "Sự kiện", - "label.exists": "Tồn tại", + "label.events": "Các sự kiện", "label.exit": "URL thoát", "label.false": "Sai", "label.field": "Trường", "label.fields": "Các trường", - "label.filter": "Bộ lọc", - "label.filter-combined": "Kết hợp", - "label.filter-raw": "Gốc", + "label.filter": "Lọc", + "label.filter-combined": "Kết hợp lọc", + "label.filter-raw": "Lọc thô", "label.filters": "Bộ lọc", - "label.first-click": "Nhấp đầu tiên", - "label.first-seen": "Lần đầu thấy", + "label.first-seen": "Lần đầu tiên nhìn thấy", "label.funnel": "Phễu", - "label.funnel-description": "Hiểu tỷ lệ chuyển đổi và rời bỏ của người dùng.", - "label.funnels": "Phễu", + "label.funnel-description": "Tìm hiểu tỷ lệ chuyển đổi và bỏ qua của người dùng.", "label.goal": "Mục tiêu", - "label.goals": "Mục tiêu", - "label.goals-description": "Theo dõi mục tiêu lượt xem trang và sự kiện.", + "label.goals": "Các mục tiêu", + "label.goals-description": "Theo dõi các mục tiêu của bạn cho lượt xem trang và sự kiện.", "label.greater-than": "Lớn hơn", "label.greater-than-equals": "Lớn hơn hoặc bằng", - "label.grouped": "Nhóm lại", - "label.hostname": "Tên máy chủ", - "label.includes": "Bao gồm", - "label.insight": "Thông tin chi tiết", - "label.insights": "Insights", - "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.host": "Máy chủ", + "label.hosts": "Các máy chủ", + "label.insights": "Thông tin chi tiết", + "label.insights-description": "Tìm hiểu sâu hơn về dữ liệu của bạn bằng cách sử dụng phân đoạn và bộ lọc.", "label.is": "Là", - "label.is-false": "Sai", - "label.is-not": "Không là", - "label.is-not-set": "Chưa đặt", + "label.is-not": "Không phải là", + "label.is-not-set": "Chưa được đặt", "label.is-set": "Đã đặt", - "label.is-true": "Đúng", "label.join": "Tham gia", "label.join-team": "Tham gia nhóm", "label.journey": "Hành trình", - "label.journey-description": "Hiểu cách người dùng di chuyển qua website của bạn.", - "label.journeys": "Hành trình", + "label.journey-description": "Hiểu cách người dùng điều hướng qua website của bạn.", "label.language": "Ngôn ngữ", - "label.languages": "Ngôn ngữ", - "label.laptop": "Laptop", - "label.last-click": "Nhấp cuối cùng", + "label.languages": "Các ngôn ngữ", + "label.laptop": "Máy tính xách tay", "label.last-days": "{x} ngày gần nhất", "label.last-hours": "{x} giờ gần nhất", "label.last-months": "{x} tháng gần nhất", - "label.last-seen": "Lần cuối thấy", - "label.leave": "Rời đi", + "label.last-seen": "Lần cuối cùng nhìn thấy", + "label.leave": "Rời khỏi", "label.leave-team": "Rời nhóm", "label.less-than": "Nhỏ hơn", "label.less-than-equals": "Nhỏ hơn hoặc bằng", - "label.links": "Liên kết", "label.login": "Đăng nhập", "label.logout": "Đăng xuất", "label.manage": "Quản lý", - "label.manager": "Quản lý viên", + "label.manager": "Quản lý", "label.max": "Tối đa", - "label.maximize": "Mở rộng", - "label.medium": "Trung bình", "label.member": "Thành viên", "label.members": "Các thành viên", "label.min": "Tối thiểu", "label.mobile": "Di động", - "label.model": "Mô hình", "label.more": "Thêm", "label.my-account": "Tài khoản của tôi", - "label.my-websites": "Website của tôi", + "label.my-websites": "Các website của tôi", "label.name": "Tên", "label.new-password": "Mật khẩu mới", - "label.none": "Không có", - "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.none": "Không", + "label.number-of-records": "{x} {x, plural, one {bản ghi} other {bản ghi}}", "label.ok": "OK", - "label.online": "Online", - "label.organic-search": "Tìm kiếm tự nhiên", - "label.organic-shopping": "Mua sắm tự nhiên", - "label.organic-social": "Mạng xã hội tự nhiên", - "label.organic-video": "Video tự nhiên", - "label.os": "OS", - "label.other": "Khác", - "label.overview": "Overview", + "label.os": "Hệ điều hành", + "label.overview": "Tổng quan", "label.owner": "Chủ sở hữu", - "label.page": "Trang", - "label.page-of": "Page {current} of {total}", - "label.page-views": "Lượt xem", - "label.pageTitle": "Page title", - "label.pages": "Trang", - "label.paid-ads": "Quảng cáo trả phí", - "label.paid-search": "Tìm kiếm trả phí", - "label.paid-shopping": "Mua sắm trả phí", - "label.paid-social": "Mạng xã hội trả phí", - "label.paid-video": "Video trả phí", + "label.page-of": "Trang {current} trên {total}", + "label.page-views": "Lượt xem trang", + "label.pageTitle": "Tiêu đề trang", + "label.pages": "Các trang", "label.password": "Mật khẩu", "label.path": "Đường dẫn", "label.paths": "Các đường dẫn", - "label.pixels": "Pixel", - "label.powered-by": "Bản quyền thuộc về {name}", - "label.previous": "Trước đó", - "label.previous-period": "Giai đoạn trước", + "label.powered-by": "Được cung cấp bởi {name}", + "label.previous": "Trước", + "label.previous-period": "Kỳ trước", "label.previous-year": "Năm trước", "label.profile": "Hồ sơ", "label.properties": "Thuộc tính", "label.property": "Thuộc tính", - "label.queries": "Queries", - "label.query": "Query", - "label.query-parameters": "Query parameters", + "label.queries": "Truy vấn", + "label.query": "Truy vấn", + "label.query-parameters": "Tham số truy vấn", "label.realtime": "Thời gian thực", - "label.referral": "Giới thiệu", - "label.referrer": "Referrer", - "label.referrers": "Liên kết giới thiệu", + "label.referrer": "Nguồn giới thiệu", + "label.referrers": "Các nguồn giới thiệu", "label.refresh": "Làm mới", - "label.regenerate": "Regenerate", - "label.region": "Region", - "label.regions": "Regions", - "label.remaining": "Còn lại", - "label.remove": "Remove", - "label.remove-member": "Remove member", - "label.reports": "Reports", + "label.regenerate": "Tạo lại", + "label.region": "Vùng", + "label.regions": "Các vùng", + "label.remove": "Xóa", + "label.remove-member": "Xóa thành viên", + "label.reports": "Báo cáo", "label.required": "Yêu cầu", - "label.reset": "Tái thiết lập", - "label.reset-website": "Tái thiết lập thống kê", - "label.retention": "Retention", - "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.reset": "Đặt lại", + "label.reset-website": "Đặt lại thống kê website", + "label.retention": "Tỷ lệ giữ chân", + "label.retention-description": "Đo lường mức độ gắn bó của website bằng cách theo dõi tần suất người dùng quay lại.", "label.revenue": "Doanh thu", - "label.revenue-description": "Xem doanh thu của bạn theo thời gian.", - "label.role": "Role", - "label.run-query": "Run query", + "label.revenue-description": "Xem xét doanh thu của bạn theo thời gian.", + "label.revenue-property": "Thuộc tính doanh thu", + "label.role": "Vai trò", + "label.run-query": "Chạy truy vấn", "label.save": "Lưu", - "label.screens": "Screens", - "label.search": "Search", - "label.select": "Select", - "label.select-date": "Select date", - "label.select-filter": "Chọn bộ lọc", - "label.select-role": "Select role", - "label.select-website": "Select website", + "label.screens": "Màn hình", + "label.search": "Tìm kiếm", + "label.select": "Chọn", + "label.select-date": "Chọn ngày", + "label.select-role": "Chọn vai trò", + "label.select-website": "Chọn website", "label.session": "Phiên", - "label.session-data": "Dữ liệu phiên", - "label.sessions": "Sessions", + "label.sessions": "Các phiên", "label.settings": "Cài đặt", - "label.share": "Chia sẻ", "label.share-url": "Chia sẻ URL", - "label.single-day": "Trong ngày", - "label.sms": "SMS", - "label.sources": "Nguồn", - "label.start-step": "Start Step", - "label.steps": "Steps", - "label.sum": "Sum", + "label.single-day": "Một ngày", + "label.start-step": "Bước bắt đầu", + "label.steps": "Các bước", + "label.sum": "Tổng", "label.tablet": "Máy tính bảng", - "label.tag": "Thẻ", - "label.tags": "Các thẻ", "label.team": "Nhóm", "label.team-id": "ID nhóm", "label.team-manager": "Quản lý nhóm", "label.team-member": "Thành viên nhóm", "label.team-name": "Tên nhóm", - "label.team-owner": "Chủ nhóm", - "label.team-settings": "Cài đặt nhóm", - "label.team-view-only": "Team view only", - "label.team-websites": "Team websites", - "label.teams": "Teams", - "label.terms": "Điều khoản", - "label.theme": "Giao diện", + "label.team-owner": "Chủ sở hữu nhóm", + "label.team-view-only": "Chỉ xem nhóm", + "label.team-websites": "Các website của nhóm", + "label.teams": "Các nhóm", + "label.theme": "Chủ đề", "label.this-month": "Tháng này", "label.this-week": "Tuần này", "label.this-year": "Năm nay", "label.timezone": "Múi giờ", - "label.title": "Title", + "label.title": "Tiêu đề", "label.today": "Hôm nay", "label.toggle-charts": "Bật/tắt biểu đồ", - "label.total": "Total", - "label.total-records": "Total records", + "label.total": "Tổng", + "label.total-records": "Tổng số bản ghi", "label.tracking-code": "Mã theo dõi", - "label.transactions": "Transactions", - "label.transfer": "Transfer", - "label.transfer-website": "Transfer website", - "label.true": "True", - "label.type": "Type", - "label.unique": "Unique", - "label.unique-visitors": "Khách truy cập một lần", - "label.uniqueCustomers": "Unique Customers", + "label.transactions": "Giao dịch", + "label.transfer": "Chuyển giao", + "label.transfer-website": "Chuyển giao website", + "label.true": "Đúng", + "label.type": "Loại", + "label.unique": "Duy nhất", + "label.unique-visitors": "Khách truy cập duy nhất", + "label.uniqueCustomers": "Khách hàng duy nhất", "label.unknown": "Không rõ", - "label.untitled": "Untitled", - "label.update": "Update", - "label.user": "User", + "label.untitled": "Không có tiêu đề", + "label.update": "Cập nhật", + "label.url": "URL", + "label.urls": "Các URL", + "label.user": "Người dùng", + "label.user-property": "Thuộc tính người dùng", "label.username": "Tên đăng nhập", - "label.users": "Users", + "label.users": "Người dùng", "label.utm": "UTM", - "label.utm-description": "Track your campaigns through UTM parameters.", - "label.value": "Value", - "label.view": "View", + "label.utm-description": "Theo dõi các chiến dịch của bạn thông qua các tham số UTM.", + "label.value": "Giá trị", + "label.view": "Xem", "label.view-details": "Xem chi tiết", - "label.view-only": "View only", - "label.views": "Xem", - "label.views-per-visit": "Views per visit", - "label.visit-duration": "Thời gian truy cập trung bình", - "label.visitors": "Khách", - "label.visits": "Visits", + "label.view-only": "Chỉ xem", + "label.views": "Lượt xem", + "label.views-per-visit": "Lượt xem trên mỗi lượt truy cập", + "label.visit-duration": "Thời lượng truy cập", + "label.visitors": "Khách truy cập", + "label.visits": "Lượt truy cập", "label.website": "Website", - "label.website-id": "Website ID", - "label.websites": "Websites", - "label.window": "Window", - "label.yesterday": "Yesterday", - "message.action-confirmation": "Type {confirmation} in the box below to confirm.", - "message.active-users": "{x} hiện tại {x, plural, one {một} other {trên}}", - "message.bad-request": "Bad request", - "message.collected-data": "Collected data", - "message.confirm-delete": "Bạn có chắc chắn muốn xoá {target}?", - "message.confirm-leave": "Are you sure you want to leave {target}?", - "message.confirm-remove": "Are you sure you want to remove {target}?", - "message.confirm-reset": "Bạn có chắc chắn muốn tái thiết lập thống kê {target}?", - "message.delete-team-warning": "Deleting a team will also delete all team websites.", - "message.delete-website-warning": "Tất cả các dữ liệu liên quan cũng sẽ bị xoá.", + "label.website-id": "ID website", + "label.websites": "Các website", + "label.window": "Cửa sổ", + "label.yesterday": "Hôm qua", + "message.action-confirmation": "Nhập {confirmation} vào ô bên dưới để xác nhận.", + "message.active-users": "{x} {x, plural, one {người dùng} other {người dùng}} đang hoạt động", + "message.collected-data": "Dữ liệu đã thu thập", + "message.confirm-delete": "Bạn có chắc chắn muốn xóa {target}?", + "message.confirm-leave": "Bạn có chắc chắn muốn rời {target}?", + "message.confirm-remove": "Bạn có chắc chắn muốn xóa {target}?", + "message.confirm-reset": "Bạn có chắc chắn muốn đặt lại thống kê {target}?", + "message.delete-team-warning": "Việc xóa một nhóm cũng sẽ xóa tất cả các website của nhóm.", + "message.delete-website-warning": "Tất cả dữ liệu liên quan cũng sẽ bị xóa.", "message.error": "Đã xảy ra lỗi.", - "message.event-log": "{event} on {url}", - "message.forbidden": "Forbidden", - "message.go-to-settings": "Chuyển tới cài đặt", + "message.event-log": "{event} trên {url}", + "message.go-to-settings": "Chuyển đến cài đặt", "message.incorrect-username-password": "Sai tên đăng nhập/mật khẩu.", "message.invalid-domain": "Tên miền không hợp lệ", - "message.min-password-length": "Minimum length of {n} characters", - "message.new-version-available": "A new version of Umami {version} is available!", + "message.min-password-length": "Độ dài tối thiểu {n} ký tự", + "message.new-version-available": "Có phiên bản mới của Umami {version}!", "message.no-data-available": "Không có dữ liệu.", - "message.no-event-data": "No event data is available.", - "message.no-match-password": "Mật khẩu không đồng nhất", - "message.no-results-found": "No results were found.", - "message.no-team-websites": "This team does not have any websites.", - "message.no-teams": "You have not created any teams.", - "message.no-users": "There are no users.", - "message.no-websites-configured": "Bạn chưa có bất cứ website nào.", - "message.not-found": "Not found", - "message.nothing-selected": "Nothing selected.", - "message.page-not-found": "Trang không tìm thấy.", - "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", - "message.reset-website-warning": "Tất cả số liệu thống kê của website này sẽ bị xoá, nhưng mã theo dõi sẽ vẫn giữ nguyên.", + "message.no-event-data": "Không có dữ liệu sự kiện.", + "message.no-match-password": "Mật khẩu không khớp", + "message.no-results-found": "Không tìm thấy kết quả nào.", + "message.no-team-websites": "Nhóm này không có bất kỳ website nào.", + "message.no-teams": "Bạn chưa tạo nhóm nào.", + "message.no-users": "Không có người dùng nào.", + "message.no-websites-configured": "Bạn chưa cấu hình bất kỳ website nào.", + "message.page-not-found": "Không tìm thấy trang.", + "message.reset-website": "Để đặt lại website này, nhập {confirmation} vào ô bên dưới để xác nhận.", + "message.reset-website-warning": "Tất cả số liệu thống kê của website này sẽ bị xóa, nhưng mã theo dõi sẽ vẫn giữ nguyên.", "message.saved": "Đã lưu thành công.", - "message.sever-error": "Server error", "message.share-url": "Đây là đường dẫn URL cho {target}.", - "message.team-already-member": "You are already a member of the team.", - "message.team-not-found": "Team not found.", - "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.team-already-member": "Bạn đã là thành viên của nhóm.", + "message.team-not-found": "Không tìm thấy nhóm.", + "message.team-websites-info": "Bất kỳ ai trong nhóm đều có thể xem các website.", "message.tracking-code": "Mã theo dõi", - "message.transfer-team-website-to-user": "Transfer this website to your account?", - "message.transfer-user-website-to-team": "Select the team to transfer this website to.", - "message.transfer-website": "Transfer website ownership to your account or another team.", - "message.triggered-event": "Triggered event", - "message.unauthorized": "Unauthorized", - "message.user-deleted": "User deleted.", - "message.viewed-page": "Viewed page", - "message.visitor-log": "Khách từ {country} đang dùng {browser} trên {os} {device}" + "message.transfer-team-website-to-user": "Chuyển website này sang tài khoản của bạn?", + "message.transfer-user-website-to-team": "Chọn nhóm để chuyển website này đến.", + "message.transfer-website": "Chuyển quyền sở hữu website sang tài khoản của bạn hoặc một nhóm khác.", + "message.triggered-event": "Sự kiện được kích hoạt", + "message.user-deleted": "Người dùng đã bị xóa.", + "message.viewed-page": "Đã xem trang", + "message.visitor-log": "Khách từ {country} đang sử dụng {browser} trên {os} {device}", + "message.visitors-dropped-off": "Khách truy cập đã rời đi" } diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts index 698b3278..1b608cfc 100644 --- a/src/lib/clickhouse.ts +++ b/src/lib/clickhouse.ts @@ -87,6 +87,21 @@ function mapFilter(column: string, operator: string, name: string, type: string } } +function mapCohortFilter(column: string, operator: string, value: string) { + switch (operator) { + case OPERATORS.equals: + return `${column} = '${value}'`; + case OPERATORS.notEquals: + return `${column} != '${value}'`; + case OPERATORS.contains: + return `positionCaseInsensitive(${column}, '${value}') > 0`; + case OPERATORS.doesNotContain: + return `positionCaseInsensitive(${column}, '${value}') = 0`; + default: + return ''; + } +} + function getFilterQuery(filters: Record, options: QueryOptions = {}) { const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => { if (column) { @@ -103,6 +118,42 @@ function getFilterQuery(filters: Record, options: QueryOptions = {} return query.join('\n'); } +function getCohortQuery(websiteId: string, filters: QueryFilters = {}, options: QueryOptions = {}) { + const query = filtersToArray(filters, options).reduce( + (arr, { name, column, operator, value }) => { + if (column) { + arr.push( + `${arr.length === 0 ? 'where' : 'and'} ${mapCohortFilter(column, operator, value)}`, + ); + + if (name === 'referrer') { + arr.push(`and referrer_domain != hostname`); + } + } + + return arr; + }, + [], + ); + + if (query.length > 0) { + // add website and date range filters + query.push(`and website_id = '${websiteId}'`); + query.push( + `and created_at between parseDateTimeBestEffort('${filters.startDate}') and parseDateTimeBestEffort('${filters.endDate}')`, + ); + + return `join + (select distinct session_id + from website_event + ${query.join('\n')}) cohort + on cohort.session_id = website_event.session_id + `; + } + + return ''; +} + function getDateQuery(filters: Record) { const { startDate, endDate, timezone } = filters; @@ -141,6 +192,7 @@ function parseFilters(filters: Record, options?: QueryOptions) { filterQuery: getFilterQuery(filters, options), dateQuery: getDateQuery(filters), queryParams: getQueryParams(filters), + cohortQuery: getCohortQuery(filters?.cohort), }; } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 35c269fd..aa91ea4f 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -49,6 +49,11 @@ export const SESSION_COLUMNS = [ 'hostname', ]; +export const FILTER_GROUPS = { + segment: 'segment', + cohort: 'cohort', +}; + export const FILTER_COLUMNS = { path: 'url_path', entry: 'url_path', diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 00baea12..cc184b6a 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -104,6 +104,24 @@ function mapFilter(column: string, operator: string, name: string, type: string } } +function mapCohortFilter(column: string, operator: string, value: string) { + const db = getDatabaseType(); + const like = db === POSTGRESQL ? 'ilike' : 'like'; + + switch (operator) { + case OPERATORS.equals: + return `${column} = '${value}'`; + case OPERATORS.notEquals: + return `${column} != '${value}'`; + case OPERATORS.contains: + return `${column} ${like} '${value}'`; + case OPERATORS.doesNotContain: + return `${column} not ${like} '${value}'`; + default: + return ''; + } +} + function getFilterQuery(filters: Record, options: QueryOptions = {}): string { const query = filtersToArray(filters, options).reduce( (arr, { name, column, operator, prefix = '' }) => { @@ -125,6 +143,43 @@ function getFilterQuery(filters: Record, options: QueryOptions = {} return query.join('\n'); } +function getCohortQuery(websiteId: string, filters: QueryFilters = {}, options: QueryOptions = {}) { + const query = filtersToArray(filters, options).reduce( + (arr, { name, column, operator, value }) => { + if (column) { + arr.push( + `${arr.length === 0 ? 'where' : 'and'} ${mapCohortFilter(column, operator, value)}`, + ); + + if (name === 'referrer') { + arr.push(`and referrer_domain != hostname`); + } + } + + return arr; + }, + [], + ); + + if (query.length > 0) { + // add website and date range filters + query.push(`and website_event.website_id = '${websiteId}'`); + query.push( + `and website_event.created_at between '${filters.startDate}'::timestamptz and '${filters.endDate}'::timestamptz`, + ); + + return `join + (select distinct website_event.session_id + from website_event + join session on session.session_id = website_event.session_id + ${query.join('\n')}) cohort + on cohort.session_id = website_event.session_id + `; + } + + return ''; +} + function getDateQuery(filters: Record) { const { startDate, endDate } = filters; @@ -165,6 +220,7 @@ function parseFilters(filters: Record, options?: QueryOptions) { dateQuery: getDateQuery(filters), filterQuery: getFilterQuery(filters, options), queryParams: getQueryParams(filters), + cohortQuery: getCohortQuery(filters?.cohort), }; } diff --git a/src/lib/request.ts b/src/lib/request.ts index ef5d1d90..b0df42a3 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,10 +1,11 @@ import { z } from 'zod/v4'; -import { FILTER_COLUMNS, DEFAULT_PAGE_SIZE } from '@/lib/constants'; +import { FILTER_COLUMNS, DEFAULT_PAGE_SIZE, FILTER_GROUPS } from '@/lib/constants'; import { badRequest, unauthorized } from '@/lib/response'; import { getAllowedUnits, getMinimumUnit, maxDate } from '@/lib/date'; import { checkAuth } from '@/lib/auth'; import { fetchWebsite } from '@/lib/load'; import { QueryFilters } from '@/lib/types'; +import { getWebsiteSegment } from '@/queries'; export async function parseRequest( request: Request, @@ -65,16 +66,30 @@ export function getRequestDateRange(query: Record) { }; } -export function getRequestFilters(query: Record) { - return Object.keys(FILTER_COLUMNS).reduce((obj, key) => { +export async function getRequestFilters(query: Record, websiteId?: string) { + const result: Record = {}; + + for (const key of Object.keys(FILTER_COLUMNS)) { const value = query[key]; - if (value !== undefined) { - obj[key] = value; + result[key] = value; } + } - return obj; - }, {}); + for (const key of Object.keys(FILTER_GROUPS)) { + const value = query[key]; + if (value !== undefined) { + const segment = await getWebsiteSegment(websiteId, key, value); + if (key === 'segment') { + // merge filters into result + Object.assign(result, segment.parameters); + } else { + result[key] = segment.parameters; + } + } + } + + return result; } export async function setWebsiteDate(websiteId: string, data: Record) { diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 85a05dd6..d44da1f9 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -35,6 +35,8 @@ export const filterParams = { hostname: z.string().optional(), language: z.string().optional(), event: z.string().optional(), + segment: z.string().optional(), + cohort: z.string().optional(), }; export const searchParams = { @@ -272,3 +274,5 @@ export const reportResultSchema = z.intersection( }), reportTypeSchema, ); + +export const segmentTypeParam = z.enum(['segment', 'cohort']); diff --git a/src/queries/index.ts b/src/queries/index.ts index 76e0dc4f..7ce5a54f 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -1,4 +1,5 @@ export * from '@/queries/prisma/report'; +export * from '@/queries/prisma/segment'; export * from '@/queries/prisma/team'; export * from '@/queries/prisma/teamUser'; export * from '@/queries/prisma/user'; diff --git a/src/queries/prisma/segment.ts b/src/queries/prisma/segment.ts new file mode 100644 index 00000000..1f962e8f --- /dev/null +++ b/src/queries/prisma/segment.ts @@ -0,0 +1,45 @@ +import prisma from '@/lib/prisma'; +import { Prisma, Segment } from '@prisma/client'; + +async function findSegment(criteria: Prisma.SegmentFindUniqueArgs): Promise { + return prisma.client.Segment.findUnique(criteria); +} + +export async function getSegment(segmentId: string): Promise { + return findSegment({ + where: { + id: segmentId, + }, + }); +} + +export async function getWebsiteSegment( + websiteId: string, + type: string, + name: string, +): Promise { + return prisma.client.segment.findFirst({ + where: { websiteId, type, name }, + }); +} + +export async function getWebsiteSegments(websiteId: string, type: string): Promise { + return prisma.client.Segment.findMany({ + where: { websiteId, type }, + }); +} + +export async function createSegment(data: Prisma.SegmentUncheckedCreateInput): Promise { + return prisma.client.Segment.create({ data }); +} + +export async function updateSegment( + SegmentId: string, + data: Prisma.SegmentUpdateInput, +): Promise { + return prisma.client.Segment.update({ where: { id: SegmentId }, data }); +} + +export async function deleteSegment(SegmentId: string): Promise { + return prisma.client.Segment.delete({ where: { id: SegmentId } }); +} diff --git a/src/queries/sql/events/getEventDataFields.ts b/src/queries/sql/events/getEventDataFields.ts index 8f3a88c3..91f9771e 100644 --- a/src/queries/sql/events/getEventDataFields.ts +++ b/src/queries/sql/events/getEventDataFields.ts @@ -12,7 +12,7 @@ export async function getEventDataFields(...args: [websiteId: string, filters: Q async function relationalQuery(websiteId: string, filters: QueryFilters) { const { rawQuery, parseFilters, getDateSQL } = prisma; - const { filterQuery, queryParams } = parseFilters(filters); + const { filterQuery, cohortQuery, queryParams } = parseFilters(filters); return rawQuery( ` @@ -27,6 +27,9 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { count(*) as "total" from event_data join website_event on website_event.event_id = event_data.website_event_id + and website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${cohortQuery} where event_data.website_id = {{websiteId::uuid}} and event_data.created_at between {{startDate}} and {{endDate}} ${filterQuery} @@ -43,7 +46,7 @@ async function clickhouseQuery( filters: QueryFilters, ): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> { const { rawQuery, parseFilters } = clickhouse; - const { filterQuery, queryParams } = parseFilters(filters); + const { filterQuery, cohortQuery, queryParams } = parseFilters(filters); return rawQuery( ` @@ -54,7 +57,8 @@ async function clickhouseQuery( data_type = 4, toString(date_trunc('hour', date_value)), string_value) as "value", count(*) as "total" - from event_data + from event_data website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} ${filterQuery} diff --git a/src/queries/sql/events/getEventDataProperties.ts b/src/queries/sql/events/getEventDataProperties.ts index 9577b40c..90b17774 100644 --- a/src/queries/sql/events/getEventDataProperties.ts +++ b/src/queries/sql/events/getEventDataProperties.ts @@ -17,7 +17,7 @@ async function relationalQuery( filters: QueryFilters & { propertyName?: string }, ) { const { rawQuery, parseFilters } = prisma; - const { filterQuery, queryParams } = parseFilters(filters, { + const { filterQuery, cohortQuery, queryParams } = parseFilters(filters, { columns: { propertyName: 'data_key' }, }); @@ -29,6 +29,9 @@ async function relationalQuery( count(*) as "total" from event_data join website_event on website_event.event_id = event_data.website_event_id + and website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${cohortQuery} where event_data.website_id = {{websiteId::uuid}} and event_data.created_at between {{startDate}} and {{endDate}} ${filterQuery} @@ -45,7 +48,7 @@ async function clickhouseQuery( filters: QueryFilters & { propertyName?: string }, ): Promise<{ eventName: string; propertyName: string; total: number }[]> { const { rawQuery, parseFilters } = clickhouse; - const { filterQuery, queryParams } = parseFilters(filters, { + const { filterQuery, cohortQuery, queryParams } = parseFilters(filters, { columns: { propertyName: 'data_key' }, }); @@ -55,7 +58,8 @@ async function clickhouseQuery( event_name as eventName, data_key as propertyName, count(*) as total - from event_data + from event_data website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} ${filterQuery} diff --git a/src/queries/sql/events/getEventDataStats.ts b/src/queries/sql/events/getEventDataStats.ts index a597096a..01b5db34 100644 --- a/src/queries/sql/events/getEventDataStats.ts +++ b/src/queries/sql/events/getEventDataStats.ts @@ -18,7 +18,7 @@ export async function getEventDataStats( async function relationalQuery(websiteId: string, filters: QueryFilters) { const { rawQuery, parseFilters } = prisma; - const { filterQuery, queryParams } = parseFilters({ ...filters, websiteId }); + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); return rawQuery( ` @@ -32,8 +32,12 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { data_key, count(*) as "total" from event_data - where website_id = {{websiteId::uuid}} - and created_at between {{startDate}} and {{endDate}} + join website_event on website_event.event_id = event_data.website_event_id + and website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${cohortQuery} + where event_data.website_id = {{websiteId::uuid}} + and event_data.created_at between {{startDate}} and {{endDate}} ${filterQuery} group by website_event_id, data_key ) as t @@ -47,7 +51,7 @@ async function clickhouseQuery( filters: QueryFilters, ): Promise<{ events: number; properties: number; records: number }[]> { const { rawQuery, parseFilters } = clickhouse; - const { filterQuery, queryParams } = parseFilters({ ...filters, websiteId }); + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); return rawQuery( ` @@ -60,7 +64,8 @@ async function clickhouseQuery( event_id, data_key, count(*) as "total" - from event_data + from event_data website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} ${filterQuery} diff --git a/src/queries/sql/events/getEventDataValues.ts b/src/queries/sql/events/getEventDataValues.ts index 3ccf53a0..1c30617c 100644 --- a/src/queries/sql/events/getEventDataValues.ts +++ b/src/queries/sql/events/getEventDataValues.ts @@ -3,7 +3,7 @@ import clickhouse from '@/lib/clickhouse'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import { QueryFilters } from '@/lib/types'; -export interface WebsiteEventData { +interface WebsiteEventData { value: string; total: number; } @@ -25,7 +25,7 @@ async function relationalQuery( filters: QueryFilters & { eventName?: string; propertyName?: string }, ) { const { rawQuery, parseFilters, getDateSQL } = prisma; - const { filterQuery, queryParams } = parseFilters({ ...filters, websiteId }); + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); return rawQuery( ` @@ -38,6 +38,9 @@ async function relationalQuery( count(*) as "total" from event_data join website_event on website_event.event_id = event_data.website_event_id + and website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${cohortQuery} where event_data.website_id = {{websiteId::uuid}} and event_data.created_at between {{startDate}} and {{endDate}} and event_data.data_key = {{propertyName}} @@ -56,7 +59,7 @@ async function clickhouseQuery( filters: QueryFilters & { eventName?: string; propertyName?: string }, ): Promise<{ value: string; total: number }[]> { const { rawQuery, parseFilters } = clickhouse; - const { filterQuery, queryParams } = parseFilters({ ...filters, websiteId }); + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); return rawQuery( ` @@ -65,7 +68,8 @@ async function clickhouseQuery( data_type = 4, toString(date_trunc('hour', date_value)), string_value) as "value", count(*) as "total" - from event_data + from event_data website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and data_key = {propertyName:String} diff --git a/src/queries/sql/events/getEventMetrics.ts b/src/queries/sql/events/getEventMetrics.ts index 0f1d4a44..0e078ff1 100644 --- a/src/queries/sql/events/getEventMetrics.ts +++ b/src/queries/sql/events/getEventMetrics.ts @@ -22,9 +22,8 @@ export async function getEventMetrics( async function relationalQuery(websiteId: string, filters: QueryFilters) { const { timezone = 'utc', unit = 'day' } = filters; const { rawQuery, getDateSQL, parseFilters } = prisma; - const { filterQuery, joinSessionQuery, queryParams } = parseFilters({ + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ ...filters, - websiteId, eventType: EVENT_TYPE.customEvent, }); @@ -36,6 +35,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { count(*) y from website_event ${joinSessionQuery} + ${cohortQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and event_type = {{eventType}} @@ -53,7 +53,7 @@ async function clickhouseQuery( ): Promise { const { timezone = 'UTC', unit = 'day' } = filters; const { rawQuery, getDateSQL, parseFilters } = clickhouse; - const { filterQuery, queryParams } = parseFilters({ + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, eventType: EVENT_TYPE.customEvent, @@ -61,13 +61,14 @@ async function clickhouseQuery( let sql = ''; - if (filterQuery) { + if (filterQuery || cohortQuery) { sql = ` select event_name x, ${getDateSQL('created_at', unit, timezone)} t, count(*) y from website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and event_type = {eventType:UInt32} diff --git a/src/queries/sql/events/getWebsiteEvents.ts b/src/queries/sql/events/getWebsiteEvents.ts index 284f9335..c7e2af17 100644 --- a/src/queries/sql/events/getWebsiteEvents.ts +++ b/src/queries/sql/events/getWebsiteEvents.ts @@ -13,7 +13,7 @@ export function getWebsiteEvents(...args: [websiteId: string, filters: QueryFilt async function relationalQuery(websiteId: string, filters: QueryFilters) { const { pagedRawQuery, parseFilters } = prisma; const { search } = filters; - const { filterQuery, dateQuery, queryParams } = parseFilters({ + const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, }); @@ -39,6 +39,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { event_type as "eventType", event_name as "eventName" from website_event + ${cohortQuery} where website_id = {{websiteId::uuid}} ${dateQuery} ${filterQuery} @@ -52,7 +53,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { async function clickhouseQuery(websiteId: string, filters: QueryFilters) { const { pagedRawQuery, parseFilters } = clickhouse; - const { queryParams, dateQuery, filterQuery } = parseFilters({ + const { queryParams, dateQuery, cohortQuery, filterQuery } = parseFilters({ ...filters, websiteId, }); @@ -82,6 +83,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { event_type as eventType, event_name as eventName from website_event + ${cohortQuery} where website_id = {websiteId:UUID} ${dateQuery} ${filterQuery} diff --git a/src/queries/sql/getChannelMetrics.ts b/src/queries/sql/getChannelMetrics.ts index 57722bf3..051a2117 100644 --- a/src/queries/sql/getChannelMetrics.ts +++ b/src/queries/sql/getChannelMetrics.ts @@ -12,7 +12,7 @@ export async function getChannelMetrics(...args: [websiteId: string, filters?: Q async function relationalQuery(websiteId: string, filters: QueryFilters) { const { rawQuery, parseFilters } = prisma; - const { queryParams, filterQuery, dateQuery } = parseFilters(filters); + const { queryParams, filterQuery, cohortQuery, dateQuery } = parseFilters(filters); return rawQuery( ` @@ -21,6 +21,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { url_query as query, count(distinct session_id) as visitors from website_event + ${cohortQuery} where website_id = {{websiteId::uuid}} ${filterQuery} ${dateQuery} @@ -36,7 +37,7 @@ async function clickhouseQuery( filters: QueryFilters, ): Promise<{ x: string; y: number }[]> { const { rawQuery, parseFilters } = clickhouse; - const { queryParams, filterQuery, dateQuery } = parseFilters(filters); + const { queryParams, filterQuery, cohortQuery, dateQuery } = parseFilters(filters); const sql = ` select @@ -44,6 +45,7 @@ async function clickhouseQuery( url_query as query, uniq(session_id) as visitors from website_event + ${cohortQuery} where website_id = {websiteId:UUID} ${filterQuery} ${dateQuery} diff --git a/src/queries/sql/getRealtimeActivity.ts b/src/queries/sql/getRealtimeActivity.ts index 65192672..d53cab6b 100644 --- a/src/queries/sql/getRealtimeActivity.ts +++ b/src/queries/sql/getRealtimeActivity.ts @@ -12,7 +12,7 @@ export async function getRealtimeActivity(...args: [websiteId: string, filters: async function relationalQuery(websiteId: string, filters: QueryFilters) { const { rawQuery, parseFilters } = prisma; - const { queryParams, filterQuery, dateQuery } = parseFilters(filters); + const { queryParams, filterQuery, cohortQuery, dateQuery } = parseFilters(filters); return rawQuery( ` @@ -27,6 +27,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { website_event.url_path as "urlPath", website_event.referrer_domain as "referrerDomain" from website_event + ${cohortQuery} inner join session on session.session_id = website_event.session_id where website_event.website_id = {{websiteId::uuid}} @@ -41,7 +42,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { async function clickhouseQuery(websiteId: string, filters: QueryFilters): Promise<{ x: number }> { const { rawQuery, parseFilters } = clickhouse; - const { queryParams, filterQuery, dateQuery } = parseFilters(filters); + const { queryParams, filterQuery, cohortQuery, dateQuery } = parseFilters({ + ...filters, + websiteId, + }); return rawQuery( ` @@ -56,12 +60,13 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters): Promis url_path as urlPath, referrer_domain as referrerDomain from website_event + ${cohortQuery} where website_id = {websiteId:UUID} ${filterQuery} ${dateQuery} order by createdAt desc limit 100 `, - { ...filters, ...queryParams }, + queryParams, ); } diff --git a/src/queries/sql/getWebsiteStats.ts b/src/queries/sql/getWebsiteStats.ts index 77d88dbc..faad96f7 100644 --- a/src/queries/sql/getWebsiteStats.ts +++ b/src/queries/sql/getWebsiteStats.ts @@ -27,7 +27,7 @@ async function relationalQuery( filters: QueryFilters, ): Promise { const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma; - const { filterQuery, joinSessionQuery, queryParams } = parseFilters({ + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, eventType: EVENT_TYPE.pageView, @@ -50,6 +50,7 @@ async function relationalQuery( max(website_event.created_at) as "max_time" from website_event ${joinSessionQuery} + ${cohortQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and event_type = {{eventType}} @@ -66,7 +67,7 @@ async function clickhouseQuery( filters: QueryFilters, ): Promise { const { rawQuery, parseFilters } = clickhouse; - const { filterQuery, queryParams } = parseFilters({ + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, eventType: EVENT_TYPE.pageView, @@ -90,6 +91,7 @@ async function clickhouseQuery( min(created_at) min_time, max(created_at) max_time from website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and event_type = {eventType:UInt32} diff --git a/src/queries/sql/pageviews/getPageviewMetrics.ts b/src/queries/sql/pageviews/getPageviewMetrics.ts index 86f8bc0c..8134f006 100644 --- a/src/queries/sql/pageviews/getPageviewMetrics.ts +++ b/src/queries/sql/pageviews/getPageviewMetrics.ts @@ -32,7 +32,7 @@ async function relationalQuery( const { type, limit = 500, offset = 0 } = parameters; const column = FILTER_COLUMNS[type] || type; const { rawQuery, parseFilters } = prisma; - const { filterQuery, joinSessionQuery, queryParams } = parseFilters( + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters( { ...filters, websiteId, @@ -75,6 +75,7 @@ async function relationalQuery( ${column === 'referrer_domain' ? 'count(distinct website_event.session_id)' : 'count(*)'} as y from website_event ${joinSessionQuery} + ${cohortQuery} ${entryExitQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} @@ -94,11 +95,11 @@ async function clickhouseQuery( websiteId: string, parameters: PageviewMetricsParameters, filters: QueryFilters, -): Promise { +): Promise<{ x: string; y: number }[]> { const { type, limit = 500, offset = 0 } = parameters; const column = FILTER_COLUMNS[type] || type; const { rawQuery, parseFilters } = clickhouse; - const { filterQuery, queryParams } = parseFilters({ + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, eventType: column === 'event_name' ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView, @@ -171,6 +172,7 @@ async function clickhouseQuery( from ( select ${columnQuery} as t from website_event_stats_hourly as website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and event_type = {eventType:UInt32} diff --git a/src/queries/sql/pageviews/getPageviewStats.ts b/src/queries/sql/pageviews/getPageviewStats.ts index 953352d7..7ef51137 100644 --- a/src/queries/sql/pageviews/getPageviewStats.ts +++ b/src/queries/sql/pageviews/getPageviewStats.ts @@ -14,7 +14,7 @@ export async function getPageviewStats(...args: [websiteId: string, filters: Que async function relationalQuery(websiteId: string, filters: QueryFilters) { const { timezone = 'utc', unit = 'day' } = filters; const { getDateSQL, parseFilters, rawQuery } = prisma; - const { filterQuery, joinSessionQuery, queryParams } = parseFilters({ + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ ...filters, websiteId, eventType: EVENT_TYPE.pageView, @@ -27,6 +27,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { count(*) y from website_event ${joinSessionQuery} + ${cohortQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and event_type = {{eventType}} @@ -44,7 +45,7 @@ async function clickhouseQuery( ): Promise<{ x: string; y: number }[]> { const { timezone = 'utc', unit = 'day' } = filters; const { parseFilters, rawQuery, getDateSQL } = clickhouse; - const { filterQuery, queryParams } = parseFilters({ + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, eventType: EVENT_TYPE.pageView, @@ -62,6 +63,7 @@ async function clickhouseQuery( ${getDateSQL('website_event.created_at', unit, timezone)} as t, count(*) as y from website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and event_type = {eventType:UInt32} @@ -80,6 +82,7 @@ async function clickhouseQuery( ${getDateSQL('website_event.created_at', unit, timezone)} as t, sum(views) as y from website_event_stats_hourly website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and event_type = {eventType:UInt32} diff --git a/src/queries/sql/reports/getBreakdown.ts b/src/queries/sql/reports/getBreakdown.ts index 7785da44..a39c49ef 100644 --- a/src/queries/sql/reports/getBreakdown.ts +++ b/src/queries/sql/reports/getBreakdown.ts @@ -31,7 +31,7 @@ async function relationalQuery( ): Promise { const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma; const { startDate, endDate, fields } = parameters; - const { filterQuery, joinSessionQuery, queryParams } = parseFilters( + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters( { ...filters, websiteId, @@ -62,6 +62,7 @@ async function relationalQuery( min(website_event.created_at) as "min_time", max(website_event.created_at) as "max_time" from website_event + ${cohortQuery} ${joinSessionQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} @@ -85,7 +86,7 @@ async function clickhouseQuery( ): Promise { const { parseFilters, rawQuery } = clickhouse; const { startDate, endDate, fields } = parameters; - const { filterQuery, queryParams } = parseFilters({ + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, startDate, @@ -111,6 +112,7 @@ async function clickhouseQuery( min(created_at) min_time, max(created_at) max_time from website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and event_type = {eventType:UInt32} diff --git a/src/queries/sql/sessions/getSessionDataProperties.ts b/src/queries/sql/sessions/getSessionDataProperties.ts index 13a68fa4..c0f1a0da 100644 --- a/src/queries/sql/sessions/getSessionDataProperties.ts +++ b/src/queries/sql/sessions/getSessionDataProperties.ts @@ -17,7 +17,7 @@ async function relationalQuery( filters: QueryFilters & { propertyName?: string }, ) { const { rawQuery, parseFilters } = prisma; - const { filterQuery, queryParams } = parseFilters(filters, { + const { filterQuery, cohortQuery, queryParams } = parseFilters(filters, { columns: { propertyName: 'data_key' }, }); @@ -25,12 +25,13 @@ async function relationalQuery( ` select data_key as "propertyName", - count(distinct d.session_id) as "total" - from website_event e - join session_data d - on d.session_id = e.session_id - where e.website_id = {{websiteId::uuid}} - and e.created_at between {{startDate}} and {{endDate}} + count(distinct session_data.session_id) as "total" + from website_event + ${cohortQuery} + join session_data + on session_data.session_id = website_event.session_id + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} ${filterQuery} group by 1 order by 2 desc @@ -45,7 +46,7 @@ async function clickhouseQuery( filters: QueryFilters & { propertyName?: string }, ): Promise<{ propertyName: string; total: number }[]> { const { rawQuery, parseFilters } = clickhouse; - const { filterQuery, queryParams } = parseFilters(filters, { + const { filterQuery, cohortQuery, queryParams } = parseFilters(filters, { columns: { propertyName: 'data_key' }, }); @@ -53,13 +54,14 @@ async function clickhouseQuery( ` select data_key as propertyName, - count(distinct d.session_id) as total - from website_event e - join session_data d final - on d.session_id = e.session_id - where e.website_id = {websiteId:UUID} - and e.created_at between {startDate:DateTime64} and {endDate:DateTime64} - and d.data_key != '' + count(distinct session_data.session_id) as total + from website_event + ${cohortQuery} + join session_data final + on session_data.session_id = website_event.session_id + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and session_data.data_key != '' ${filterQuery} group by 1 order by 2 desc diff --git a/src/queries/sql/sessions/getSessionDataValues.ts b/src/queries/sql/sessions/getSessionDataValues.ts index 6d530b69..57792136 100644 --- a/src/queries/sql/sessions/getSessionDataValues.ts +++ b/src/queries/sql/sessions/getSessionDataValues.ts @@ -17,7 +17,7 @@ async function relationalQuery( filters: QueryFilters & { propertyName?: string }, ) { const { rawQuery, parseFilters, getDateSQL } = prisma; - const { filterQuery, queryParams } = parseFilters(filters); + const { filterQuery, cohortQuery, queryParams } = parseFilters(filters); return rawQuery( ` @@ -27,13 +27,14 @@ async function relationalQuery( when data_type = 4 then ${getDateSQL('date_value', 'hour')} else string_value end as "value", - count(distinct d.session_id) as "total" + count(distinct session_data.session_id) as "total" from website_event e + ${cohortQuery} join session_data d - on d.session_id = e.session_id - where e.website_id = {{websiteId::uuid}} - and e.created_at between {{startDate}} and {{endDate}} - and d.data_key = {{propertyName}} + on session_data.session_id = website_event.session_id + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and session_data.data_key = {{propertyName}} ${filterQuery} group by value order by 2 desc @@ -48,7 +49,7 @@ async function clickhouseQuery( filters: QueryFilters & { propertyName?: string }, ): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> { const { rawQuery, parseFilters } = clickhouse; - const { filterQuery, queryParams } = parseFilters(filters); + const { filterQuery, cohortQuery, queryParams } = parseFilters(filters); return rawQuery( ` @@ -56,13 +57,14 @@ async function clickhouseQuery( multiIf(data_type = 2, replaceAll(string_value, '.0000', ''), data_type = 4, toString(date_trunc('hour', date_value)), string_value) as "value", - uniq(d.session_id) as "total" + uniq(session_data.session_id) as "total" from website_event e + ${cohortQuery} join session_data d final - on d.session_id = e.session_id - where e.website_id = {websiteId:UUID} - and e.created_at between {startDate:DateTime64} and {endDate:DateTime64} - and d.data_key = {propertyName:String} + on session_data.session_id = website_event.session_id + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and session_data.data_key = {propertyName:String} ${filterQuery} group by value order by 2 desc diff --git a/src/queries/sql/sessions/getSessionMetrics.ts b/src/queries/sql/sessions/getSessionMetrics.ts index ca0a8c2f..285eaa7f 100644 --- a/src/queries/sql/sessions/getSessionMetrics.ts +++ b/src/queries/sql/sessions/getSessionMetrics.ts @@ -27,7 +27,7 @@ async function relationalQuery( const { type, limit = 500, offset = 0 } = parameters; const column = FILTER_COLUMNS[type] || type; const { parseFilters, rawQuery } = prisma; - const { filterQuery, joinSessionQuery, queryParams } = parseFilters( + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters( { ...filters, websiteId, @@ -46,6 +46,7 @@ async function relationalQuery( count(distinct website_event.session_id) y ${includeCountry ? ', country' : ''} from website_event + ${cohortQuery} ${joinSessionQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} @@ -69,7 +70,7 @@ async function clickhouseQuery( const { type, limit = 500, offset = 0 } = parameters; const column = FILTER_COLUMNS[type] || type; const { parseFilters, rawQuery } = clickhouse; - const { filterQuery, queryParams } = parseFilters({ + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, eventType: EVENT_TYPE.pageView, @@ -85,6 +86,7 @@ async function clickhouseQuery( count(distinct session_id) y ${includeCountry ? ', country' : ''} from website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and event_type = {eventType:UInt32} @@ -102,6 +104,7 @@ async function clickhouseQuery( uniq(session_id) y ${includeCountry ? ', country' : ''} from website_event_stats_hourly website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and event_type = {eventType:UInt32} diff --git a/src/queries/sql/sessions/getSessionStats.ts b/src/queries/sql/sessions/getSessionStats.ts index bb3ff03d..9911a235 100644 --- a/src/queries/sql/sessions/getSessionStats.ts +++ b/src/queries/sql/sessions/getSessionStats.ts @@ -14,7 +14,7 @@ export async function getSessionStats(...args: [websiteId: string, filters: Quer async function relationalQuery(websiteId: string, filters: QueryFilters) { const { timezone = 'utc', unit = 'day' } = filters; const { getDateSQL, parseFilters, rawQuery } = prisma; - const { filterQuery, joinSessionQuery, queryParams } = parseFilters({ + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, eventType: EVENT_TYPE.pageView, @@ -27,6 +27,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { count(distinct website_event.session_id) y from website_event ${joinSessionQuery} + ${cohortQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and event_type = {{eventType}} @@ -44,7 +45,7 @@ async function clickhouseQuery( ): Promise<{ x: string; y: number }[]> { const { timezone = 'utc', unit = 'day' } = filters; const { parseFilters, rawQuery, getDateSQL } = clickhouse; - const { filterQuery, queryParams } = parseFilters({ + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, eventType: EVENT_TYPE.pageView, @@ -62,6 +63,7 @@ async function clickhouseQuery( ${getDateSQL('website_event.created_at', unit, timezone)} as t, count(distinct session_id) as y from website_event + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and event_type = {eventType:UInt32} diff --git a/src/queries/sql/sessions/getWebsiteSessionStats.ts b/src/queries/sql/sessions/getWebsiteSessionStats.ts index 5e4d2721..0f4c1797 100644 --- a/src/queries/sql/sessions/getWebsiteSessionStats.ts +++ b/src/queries/sql/sessions/getWebsiteSessionStats.ts @@ -25,7 +25,7 @@ async function relationalQuery( filters: QueryFilters, ): Promise { const { parseFilters, rawQuery } = prisma; - const { filterQuery, queryParams } = parseFilters({ ...filters, websiteId }); + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); return rawQuery( ` @@ -36,6 +36,7 @@ async function relationalQuery( count(distinct session.country) as "countries", sum(case when website_event.event_type = 2 then 1 else 0 end) as "events" from website_event + ${cohortQuery} join session on website_event.session_id = session.session_id where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} @@ -50,7 +51,7 @@ async function clickhouseQuery( filters: QueryFilters, ): Promise { const { rawQuery, parseFilters } = clickhouse; - const { filterQuery, queryParams } = parseFilters({ ...filters, websiteId }); + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); return rawQuery( ` @@ -61,6 +62,7 @@ async function clickhouseQuery( uniq(country) as "countries", sum(length(event_name)) as "events" from umami.website_event_stats_hourly "website_event" + ${cohortQuery} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} ${filterQuery} diff --git a/src/queries/sql/sessions/getWebsiteSessions.ts b/src/queries/sql/sessions/getWebsiteSessions.ts index be118cc6..57dba14e 100644 --- a/src/queries/sql/sessions/getWebsiteSessions.ts +++ b/src/queries/sql/sessions/getWebsiteSessions.ts @@ -13,7 +13,7 @@ export async function getWebsiteSessions(...args: [websiteId: string, filters: Q async function relationalQuery(websiteId: string, filters: QueryFilters) { const { pagedRawQuery, parseFilters } = prisma; const { search } = filters; - const { filterQuery, dateQuery, queryParams } = parseFilters({ + const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, search: search ? `%${search}%` : undefined, @@ -41,6 +41,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { sum(case when website_event.event_type = 1 then 1 else 0 end) as "views", max(website_event.created_at) as "createdAt" from website_event + ${cohortQuery} join session on session.session_id = website_event.session_id where website_event.website_id = {{websiteId::uuid}} ${dateQuery} @@ -67,7 +68,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { async function clickhouseQuery(websiteId: string, filters: QueryFilters) { const { pagedRawQuery, parseFilters, getDateStringSQL } = clickhouse; const { search } = filters; - const { filterQuery, dateQuery, queryParams } = parseFilters({ + const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, }); @@ -93,7 +94,8 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { uniq(visit_id) as visits, sumIf(views, event_type = 1) as views, lastAt as createdAt - from website_event_stats_hourly + from website_event_stats_hourly website_event + ${cohortQuery} where website_id = {websiteId:UUID} ${dateQuery} ${filterQuery}