diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index e4238a0a6..c8069e2d6 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -72,6 +72,7 @@ const WorkspaceApiKeys = lazy( const AiSettings = lazy(() => import("@/ee/ai/pages/ai-settings.tsx")); const BasePage = lazy(() => import("@/ee/base/pages/base-page.tsx")); const AuditLogs = lazy(() => import("@/ee/audit/pages/audit-logs.tsx")); +const PageViewAnalytics = lazy(() => import("@/ee/page-view/pages/page-view-analytics.tsx")) const VerifiedPages = lazy( () => import("@/ee/page-verification/pages/verified-pages.tsx"), ); @@ -184,6 +185,7 @@ export default function App() { } /> } /> } /> + } /> } /> { const params: QueryParams = { limit: 100, query: "" }; @@ -100,6 +105,31 @@ export const prefetchVerifiedPages = () => { }); }; +export const prefetchPageAnalytics = () => { + const dateRange = { + startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10), + endDate: new Date().toISOString().slice(0, 10), + }; + const listParams = { ...dateRange, cursor: undefined, limit: 10 }; + + queryClient.prefetchQuery({ + queryKey: ["workspace-page-view-totals", dateRange], + queryFn: () => getWorkspacePageViewTotals(dateRange), + }); + + queryClient.prefetchQuery({ + queryKey: ["workspace-page-view-top-pages", listParams], + queryFn: () => getWorkspacePageViewTopPages(listParams), + }); + + queryClient.prefetchQuery({ + queryKey: ["workspace-page-view-daily-stats", listParams], + queryFn: () => getWorkspacePageViewDailyStats(listParams), + }); +}; + export const prefetchScimTokens = () => { queryClient.prefetchQuery({ queryKey: ["scim-token-list", { cursor: undefined }], diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 1a1aec857..038b3ff4e 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -15,6 +15,7 @@ import { IconSparkles, IconHistory, IconShieldCheck, + IconChartBar, } from "@tabler/icons-react"; import { Link, useLocation } from "react-router-dom"; import classes from "./settings.module.css"; @@ -38,6 +39,7 @@ import { prefetchWorkspaceMembers, prefetchAuditLogs, prefetchVerifiedPages, + prefetchPageAnalytics, } from "@/components/settings/settings-queries.tsx"; import AppVersion from "@/components/settings/app-version.tsx"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; @@ -125,6 +127,13 @@ const groupedData: DataGroup[] = [ role: "owner", env: "selfhosted", }, + { + label: "Page analytics", + icon: IconChartBar, + path: "/settings/analytics", + feature: Feature.PAGE_ANALYTICS, + role: "owner", + }, ], }, { @@ -225,6 +234,9 @@ export default function SettingsSidebar() { case "Verified pages": prefetchHandler = prefetchVerifiedPages; break; + case "Page analytics": + prefetchHandler = prefetchPageAnalytics; + break; default: break; } diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts index 87bb559d5..eeced1329 100644 --- a/apps/client/src/ee/features.ts +++ b/apps/client/src/ee/features.ts @@ -15,6 +15,7 @@ export const Feature = { SCIM: 'scim', PAGE_VERIFICATION: 'page:verification', AUDIT_LOGS: 'audit:logs', + PAGE_ANALYTICS: 'analytics:page-views', RETENTION: 'retention', SHARING_CONTROLS: 'sharing:controls', TEMPLATES: 'templates', diff --git a/apps/client/src/ee/page-view/pages/page-view-analytics.tsx b/apps/client/src/ee/page-view/pages/page-view-analytics.tsx new file mode 100644 index 000000000..d0e1f798d --- /dev/null +++ b/apps/client/src/ee/page-view/pages/page-view-analytics.tsx @@ -0,0 +1,304 @@ +import { useCallback, useMemo, useState } from "react"; +import { + Badge, + Card, + Group, + Select, + SimpleGrid, + Stack, + Table, + Text, +} from "@mantine/core"; +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import SettingsTitle from "@/components/settings/settings-title"; +import { getAppName } from "@/lib/config"; +import Paginate from "@/components/common/paginate"; +import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; +import { + useWorkspacePageViewDailyStatsQuery, + useWorkspacePageViewTopPagesQuery, + useWorkspacePageViewTotalsQuery, +} from "@/ee/page-view/queries/page-view-analytics-query"; +import { Link } from "react-router-dom"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale"; + +type RangePreset = "7" | "30" | "90"; + +const DAILY_PAGE_SIZE = 10; + +function toISODate(daysAgo: number | string): string { + const daysNum = Number(daysAgo); + + return new Date(Date.now() - daysNum * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); +} + +function formatNumber(value: number | null | undefined): string { + return Number(value ?? 0).toLocaleString(); +} + +export default function PageViewAnalytics() { + const { t } = useTranslation(); + const locale = useDateFnsLocale(); + const [rangePreset, setRangePreset] = useState("30"); + const [topPagesLimit, setTopPagesLimit] = useState("10"); + + const { + cursor: topPagesCursor, + goNext: goNextTopPages, + goPrev: goPrevTopPages, + resetCursor: resetTopPagesCursor, + } = useCursorPaginate(); + + const { + cursor: dailyCursor, + goNext: goNextDaily, + goPrev: goPrevDaily, + resetCursor: resetDailyCursor, + } = useCursorPaginate(); + + const dateRange = useMemo( + () => ({ + startDate: toISODate(rangePreset), + endDate: new Date().toISOString().slice(0, 10), + }), + [rangePreset] + ); + + const topPagesParams = useMemo( + () => ({ + ...dateRange, + cursor: topPagesCursor, + limit: Number(topPagesLimit), + }), + [dateRange, topPagesCursor, topPagesLimit] + ); + + const formatDate = useCallback( + (value?: Date | string | null) => { + if (!value) return "-"; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + + return formatLocalized(date, "MMM dd, yyyy", "PP", locale); + }, + [locale] + ); + + const dailyParams = useMemo( + () => ({ + ...dateRange, + cursor: dailyCursor, + limit: DAILY_PAGE_SIZE, + }), + [dateRange, dailyCursor] + ); + + const { data: totalsData } = useWorkspacePageViewTotalsQuery(dateRange); + const { data: topPagesData, isLoading: isTopPagesLoading } = + useWorkspacePageViewTopPagesQuery(topPagesParams); + const { data: dailyData, isLoading: isDailyLoading } = + useWorkspacePageViewDailyStatsQuery(dailyParams); + + const handleRangeChange = (value: RangePreset) => { + if (value) { + setRangePreset(value); + resetTopPagesCursor(); + resetDailyCursor(); + } + }; + + const handleTopPagesLimitChange = (value: string | null) => { + if (value) { + setTopPagesLimit(value); + resetTopPagesCursor(); + } + }; + + return ( + <> + + + {t("Page analytics")} - {getAppName()} + + + + + + + + {t("Unique visitors and view volume for your workspace pages.")} + + + + + + + + {t("Page")} + {t("Views")} + {t("Visitors")} + {t("Last viewed")} + + + + {(topPagesData?.items ?? []).map((item) => ( + + + {item.pageSlugId ? ( + + {item.pageTitle || t("Untitled")} + + ) : ( + item.pageTitle || t("Untitled") + )} + + {formatNumber(item.totalViews)} + {formatNumber(item.uniqueVisitors)} + {formatDate(item.lastViewedAt)} + + ))} + {!isTopPagesLoading && (topPagesData?.items.length ?? 0) === 0 && ( + + + + {t("No analytics data for this range.")} + + + + )} + +
+ {topPagesData?.items && + topPagesData.items.length > 0 && + (topPagesData.meta.hasPrevPage || topPagesData.meta.hasNextPage) && ( + goNextTopPages(topPagesData.meta.nextCursor)} + /> + )} + + + + + {t("Daily breakdown")} + {isDailyLoading && {t("Loading")}} + + + + + + {t("Date")} + {t("Views")} + {t("Visitors")} + + + + {(dailyData?.items ?? []).map((item) => ( + + {item.viewDate} + {formatNumber(item.totalViews)} + {formatNumber(item.uniqueVisitors)} + + ))} + {!isDailyLoading && (dailyData?.items.length ?? 0) === 0 && ( + + + + {t("No analytics data for this range.")} + + + + )} + +
+ + {dailyData?.items && + dailyData.items.length > 0 && + (dailyData.meta.hasPrevPage || dailyData.meta.hasNextPage) && ( + goNextDaily(dailyData.meta.nextCursor)} + /> + )} +
+
+ + + ); +} diff --git a/apps/client/src/ee/page-view/queries/page-view-analytics-query.ts b/apps/client/src/ee/page-view/queries/page-view-analytics-query.ts new file mode 100644 index 000000000..7c1000c1c --- /dev/null +++ b/apps/client/src/ee/page-view/queries/page-view-analytics-query.ts @@ -0,0 +1,44 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + getWorkspacePageViewDailyStats, + getWorkspacePageViewTopPages, + getWorkspacePageViewTotals, +} from "@/ee/page-view/services/page-view-analytics-service"; +import type { IPagination } from "@/lib/types"; +import type { + WorkspaceAnalyticsDailyStat, + WorkspaceAnalyticsListParams, + WorkspaceAnalyticsParams, + WorkspaceAnalyticsTopPage, + WorkspaceAnalyticsTotals, +} from "@/ee/page-view/types/page-view-analytics.types"; + +export function useWorkspacePageViewTotalsQuery( + params?: WorkspaceAnalyticsParams, +) { + return useQuery({ + queryKey: ["workspace-page-view-totals", params], + queryFn: () => getWorkspacePageViewTotals(params), + placeholderData: keepPreviousData, + }); +} + +export function useWorkspacePageViewDailyStatsQuery( + params?: WorkspaceAnalyticsListParams, +) { + return useQuery, Error>({ + queryKey: ["workspace-page-view-daily-stats", params], + queryFn: () => getWorkspacePageViewDailyStats(params), + placeholderData: keepPreviousData, + }); +} + +export function useWorkspacePageViewTopPagesQuery( + params?: WorkspaceAnalyticsListParams, +) { + return useQuery, Error>({ + queryKey: ["workspace-page-view-top-pages", params], + queryFn: () => getWorkspacePageViewTopPages(params), + placeholderData: keepPreviousData, + }); +} diff --git a/apps/client/src/ee/page-view/services/page-view-analytics-service.ts b/apps/client/src/ee/page-view/services/page-view-analytics-service.ts new file mode 100644 index 000000000..e81d3fba7 --- /dev/null +++ b/apps/client/src/ee/page-view/services/page-view-analytics-service.ts @@ -0,0 +1,30 @@ +import api from "@/lib/api-client"; +import type { IPagination } from "@/lib/types"; +import type { + WorkspaceAnalyticsDailyStat, + WorkspaceAnalyticsListParams, + WorkspaceAnalyticsParams, + WorkspaceAnalyticsTopPage, + WorkspaceAnalyticsTotals, +} from "@/ee/page-view/types/page-view-analytics.types"; + +export async function getWorkspacePageViewTotals( + params?: WorkspaceAnalyticsParams, +): Promise { + const req = await api.post("/page-views/workspace-stats", params); + return req.data; +} + +export async function getWorkspacePageViewDailyStats( + params?: WorkspaceAnalyticsListParams, +): Promise> { + const req = await api.post("/page-views/workspace-daily-stats", params); + return req.data; +} + +export async function getWorkspacePageViewTopPages( + params?: WorkspaceAnalyticsListParams, +): Promise> { + const req = await api.post("/page-views/workspace-top-pages", params); + return req.data; +} diff --git a/apps/client/src/ee/page-view/types/page-view-analytics.types.ts b/apps/client/src/ee/page-view/types/page-view-analytics.types.ts new file mode 100644 index 000000000..6a65c6b00 --- /dev/null +++ b/apps/client/src/ee/page-view/types/page-view-analytics.types.ts @@ -0,0 +1,32 @@ +import { QueryParams } from "@/lib/types"; + +export type WorkspaceAnalyticsParams = { + startDate?: string; + endDate?: string; +}; + +export type WorkspaceAnalyticsListParams = WorkspaceAnalyticsParams & + QueryParams; + +export type BasicStats = { + totalViews: number; + uniqueVisitors: number; + authenticatedVisitors: number; + sharedViews: number; +}; + +export type WorkspaceAnalyticsDailyStat = BasicStats & { + viewDate: string; +}; + +export type WorkspaceAnalyticsTopPage = BasicStats & { + pageId: string; + pageTitle: string | null; + pageSlugId: string | null; + lastViewedAt: string | null; +}; + +export type WorkspaceAnalyticsTotals = { + range: { startDate: string; endDate: string }; + totals: BasicStats; +}; \ No newline at end of file diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 5002ca8a7..6af1c4daf 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -27,6 +27,7 @@ import { parseRedisUrl } from './common/helpers'; import { LoggerModule } from './common/logger/logger.module'; import { ClsModule } from 'nestjs-cls'; import { NoopAuditModule } from './integrations/audit/audit.module'; +import { NoopPageViewModule } from './integrations/page-view/page-view.module'; import { ThrottleModule } from './integrations/throttle/throttle.module'; import { OutboundModule } from './integrations/outbound/outbound.module'; import { EncryptionModule } from './integrations/encryption/encryption.module'; @@ -52,7 +53,7 @@ try { middleware: { mount: true }, }), LoggerModule, - ...(enterpriseModules.length > 0 ? [] : [NoopAuditModule]), + ...(enterpriseModules.length > 0 ? [] : [NoopAuditModule, NoopPageViewModule]), CoreModule, DatabaseModule, EnvironmentModule, diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index ac446d325..770223cb1 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -15,6 +15,7 @@ export const Feature = { SCIM: 'scim', PAGE_VERIFICATION: 'page:verification', AUDIT_LOGS: 'audit:logs', + PAGE_ANALYTICS: 'analytics:page-views', RETENTION: 'retention', SHARING_CONTROLS: 'sharing:controls', VIEWER_COMMENTS: 'comment:viewer', diff --git a/apps/server/src/core/casl/abilities/workspace-ability.factory.ts b/apps/server/src/core/casl/abilities/workspace-ability.factory.ts index 683cf4b63..00dcac562 100644 --- a/apps/server/src/core/casl/abilities/workspace-ability.factory.ts +++ b/apps/server/src/core/casl/abilities/workspace-ability.factory.ts @@ -42,6 +42,7 @@ function buildWorkspaceOwnerAbility() { can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Audit); + can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.PageAnalytics); return build(); } diff --git a/apps/server/src/core/casl/interfaces/workspace-ability.type.ts b/apps/server/src/core/casl/interfaces/workspace-ability.type.ts index 896b5338f..111a0604d 100644 --- a/apps/server/src/core/casl/interfaces/workspace-ability.type.ts +++ b/apps/server/src/core/casl/interfaces/workspace-ability.type.ts @@ -13,6 +13,7 @@ export enum WorkspaceCaslSubject { Attachment = 'attachment', API = 'api_key', Audit = 'audit', + PageAnalytics = "page_analytics" } export type IWorkspaceAbility = @@ -22,4 +23,5 @@ export type IWorkspaceAbility = | [WorkspaceCaslAction, WorkspaceCaslSubject.Group] | [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment] | [WorkspaceCaslAction, WorkspaceCaslSubject.API] - | [WorkspaceCaslAction, WorkspaceCaslSubject.Audit]; + | [WorkspaceCaslAction, WorkspaceCaslSubject.Audit] + | [WorkspaceCaslAction, WorkspaceCaslSubject.PageAnalytics]; diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 397eed259..1fe0ff4ba 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -52,6 +52,10 @@ import { AUDIT_SERVICE, IAuditService, } from '../../integrations/audit/audit.service'; +import { + PAGE_VIEW_SERVICE, + IPageViewService, +} from '../../integrations/page-view/page-view.service'; import { getPageTitle } from '../../common/helpers'; @UseGuards(JwtAuthGuard) @@ -66,6 +70,7 @@ export class PageController { private readonly backlinkService: BacklinkService, private readonly labelService: LabelService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + @Inject(PAGE_VIEW_SERVICE) private readonly pageViewService: IPageViewService, ) {} @HttpCode(HttpStatus.OK) @@ -90,6 +95,13 @@ export class PageController { const permissions = { canEdit, hasRestriction }; + void this.pageViewService.track({ + pageId: page.id, + workspaceId: page.workspaceId, + spaceId: page.spaceId, + userId: user.id, + }); + if (dto.format && dto.format !== 'json' && page.content) { const contentOutput = dto.format === 'markdown' diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts index 226273442..bbb6a701a 100644 --- a/apps/server/src/core/share/share.controller.ts +++ b/apps/server/src/core/share/share.controller.ts @@ -35,6 +35,10 @@ import { AUDIT_SERVICE, IAuditService, } from '../../integrations/audit/audit.service'; +import { + PAGE_VIEW_SERVICE, + IPageViewService, +} from '../../integrations/page-view/page-view.service'; @UseGuards(JwtAuthGuard) @Controller('shares') @@ -47,6 +51,7 @@ export class ShareController { private readonly pageAccessService: PageAccessService, private readonly licenseCheckService: LicenseCheckService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + @Inject(PAGE_VIEW_SERVICE) private readonly pageViewService: IPageViewService, ) {} @HttpCode(HttpStatus.OK) @@ -71,6 +76,14 @@ export class ShareController { const shareData = await this.shareService.getSharedPage(dto, workspace.id); + void this.pageViewService.track({ + pageId: shareData.page.id, + workspaceId: workspace.id, + spaceId: shareData.page.spaceId, + shareId: shareData.share.id, + userId: null, + }); + const sharingAllowed = await this.shareService.isSharingAllowed( workspace.id, shareData.share.spaceId, diff --git a/apps/server/src/database/migrations/20260809T000000-page-views.ts b/apps/server/src/database/migrations/20260809T000000-page-views.ts new file mode 100644 index 000000000..ee46e7362 --- /dev/null +++ b/apps/server/src/database/migrations/20260809T000000-page-views.ts @@ -0,0 +1,50 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('page_views') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('page_id', 'uuid', (col) => + col.references('pages.id').onDelete('cascade').notNull(), + ) + .addColumn('space_id', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade'), + ) + .addColumn('visitor_id', 'varchar', (col) => col.notNull()) + .addColumn('view_date', 'varchar', (col) => col.notNull()) + .addColumn('hits', 'int8', (col) => col.notNull().defaultTo(1)) + .addColumn('last_viewed_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createIndex('idx_page_views_workspace_page_date') + .ifNotExists() + .on('page_views') + .columns(['workspace_id', 'page_id', 'view_date']) + .execute(); + + await sql` + CREATE UNIQUE INDEX IF NOT EXISTS + uq_page_views_workspace_page_identity + ON page_views ( + workspace_id, + page_id, + COALESCE(user_id::text, visitor_id) + ) + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('page_views').ifExists().execute(); +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 0050e32d3..19ec4bba3 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -581,6 +581,20 @@ export interface PagePermissions { updatedAt: Generated; } +export interface PageViews { + id: Generated; + workspaceId: string; + pageId: string; + spaceId: string | null; + shareId: string | null; + userId: string | null; + visitorId: string; + viewDate: string; + hits: Generated; + lastViewedAt: Generated; + createdAt: Generated; +} + export interface PageVerifications { id: Generated; pageId: string; @@ -756,6 +770,7 @@ export interface DB { pagePermissions: PagePermissions; pageHistory: PageHistory; pageLabels: PageLabels; + pageViews: PageViews; pageVerifications: PageVerifications; pageVerifiers: PageVerifiers; pages: Pages; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 1458eef54..7c2bfff26 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -1,6 +1,7 @@ import { Insertable, Selectable, Updateable } from 'kysely'; import { AiChats, + PageViews, AiChatMessages, Attachments, BaseProperties, @@ -113,6 +114,10 @@ export type PageHistory = Selectable; export type InsertablePageHistory = Insertable; export type UpdatablePageHistory = Updateable>; +export type PageView = Selectable; +export type InsertablePageView = Insertable; +export type UpdatablePageView = Updateable>; + // Comment export type Comment = Selectable; export type InsertableComment = Insertable; diff --git a/apps/server/src/integrations/page-view/page-view.module.ts b/apps/server/src/integrations/page-view/page-view.module.ts new file mode 100644 index 000000000..a1ad32ebd --- /dev/null +++ b/apps/server/src/integrations/page-view/page-view.module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common'; +import { PAGE_VIEW_SERVICE, NoopPageViewService } from './page-view.service'; + +@Global() +@Module({ + providers: [ + { + provide: PAGE_VIEW_SERVICE, + useClass: NoopPageViewService, + }, + ], + exports: [PAGE_VIEW_SERVICE], +}) +export class NoopPageViewModule {} diff --git a/apps/server/src/integrations/page-view/page-view.service.ts b/apps/server/src/integrations/page-view/page-view.service.ts new file mode 100644 index 000000000..06e88f1aa --- /dev/null +++ b/apps/server/src/integrations/page-view/page-view.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; + +export type PageViewPayload = { + pageId: string; + workspaceId?: string; + spaceId?: string; + shareId?: string; + userId?: string | null; + visitorId?: string; +}; + +export const PAGE_VIEW_SERVICE = Symbol('PAGE_VIEW_SERVICE'); + +export interface IPageViewService { + track(payload: PageViewPayload): void | Promise; +} + +@Injectable() +export class NoopPageViewService implements IPageViewService { + track(_payload: PageViewPayload): void {} +} diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index a03c492d4..729050185 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -9,6 +9,7 @@ export enum QueueName { HISTORY_QUEUE = '{history-queue}', NOTIFICATION_QUEUE = '{notification-queue}', AUDIT_QUEUE = '{audit-queue}', + PAGE_VIEW_QUEUE = '{page-view-queue}', BASE_QUEUE = '{base-queue}', SIEM_QUEUE = '{siem-queue}', } @@ -83,6 +84,8 @@ export enum QueueJob { AUDIT_LOG = 'audit-log', AUDIT_CLEANUP = 'audit-cleanup', + PAGE_VIEW_TRACK = 'page-view-track', + PAGE_VIEW_CLEANUP = 'page-view-cleanup', SIEM_SWEEP = 'siem-sweep', SIEM_DELIVER = 'siem-deliver', diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index 77bdf2b41..d3d590a0f 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -94,6 +94,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor'; attempts: 3, }, }), + BullModule.registerQueue({ + name: QueueName.PAGE_VIEW_QUEUE, + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: true, + attempts: 3, + }, + }), BullModule.registerQueue({ name: QueueName.SIEM_QUEUE, defaultJobOptions: {