From 734f59967e4dcd9485c0070a232c6f7feb133e97 Mon Sep 17 00:00:00 2001 From: Salihu Date: Thu, 13 Aug 2026 22:25:08 +0100 Subject: [PATCH] page analytics init --- apps/client/src/App.tsx | 2 + .../components/settings/settings-queries.tsx | 32 +- .../components/settings/settings-sidebar.tsx | 12 + apps/client/src/ee/features.ts | 1 + .../page-view/pages/page-view-analytics.tsx | 304 ++++++++++++++++++ .../queries/page-view-analytics-query.ts | 44 +++ .../services/page-view-analytics-service.ts | 30 ++ .../types/page-view-analytics.types.ts | 32 ++ apps/server/src/app.module.ts | 2 + apps/server/src/common/features.ts | 1 + .../abilities/workspace-ability.factory.ts | 1 + .../casl/interfaces/workspace-ability.type.ts | 4 +- apps/server/src/core/page/page.controller.ts | 12 + .../server/src/core/share/share.controller.ts | 13 + .../migrations/20260809T000000-page-views.ts | 50 +++ apps/server/src/database/types/db.d.ts | 15 + .../server/src/database/types/entity.types.ts | 5 + .../page-view/page-view.module.ts | 14 + .../page-view/page-view.service.ts | 21 ++ .../queue/constants/queue.constants.ts | 3 + .../src/integrations/queue/queue.module.ts | 8 + 21 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/ee/page-view/pages/page-view-analytics.tsx create mode 100644 apps/client/src/ee/page-view/queries/page-view-analytics-query.ts create mode 100644 apps/client/src/ee/page-view/services/page-view-analytics-service.ts create mode 100644 apps/client/src/ee/page-view/types/page-view-analytics.types.ts create mode 100644 apps/server/src/database/migrations/20260809T000000-page-views.ts create mode 100644 apps/server/src/integrations/page-view/page-view.module.ts create mode 100644 apps/server/src/integrations/page-view/page-view.service.ts diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index ab291ffea..5f0181f23 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -40,6 +40,7 @@ import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; import BasePage from "@/ee/base/pages/base-page.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; +import PageViewAnalytics from "@/ee/page-view/pages/page-view-analytics.tsx"; import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx"; import TemplateList from "@/ee/template/pages/template-list"; import TemplateEditor from "@/ee/template/pages/template-editor"; @@ -127,6 +128,7 @@ export default function App() { } /> } /> } /> + } /> } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-queries.tsx b/apps/client/src/components/settings/settings-queries.tsx index 892857f55..4dab56174 100644 --- a/apps/client/src/components/settings/settings-queries.tsx +++ b/apps/client/src/components/settings/settings-queries.tsx @@ -5,7 +5,7 @@ import { } from "@/ee/billing/services/billing-service.ts"; import { getSpaces } from "@/features/space/services/space-service.ts"; import { getGroups } from "@/features/group/services/group-service.ts"; -import { QueryParams } from "@/lib/types.ts"; +import type { QueryParams } from "@/lib/types.ts"; import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts"; import { getLicenseInfo } from "@/ee/licence/services/license-service.ts"; import { getSsoProviders } from "@/ee/security/services/security-service.ts"; @@ -14,6 +14,11 @@ import { getApiKeys } from "@/ee/api-key"; import { getAuditLogs } from "@/ee/audit/services/audit-service"; import { getVerificationList } from "@/ee/page-verification/services/page-verification-service"; import { getScimTokens } from "@/ee/scim/services/scim-token-service"; +import { + getWorkspacePageViewDailyStats, + getWorkspacePageViewTopPages, + getWorkspacePageViewTotals, +} from "@/ee/page-view/services/page-view-analytics-service"; export const prefetchWorkspaceMembers = () => { 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 542cad910..924b414f8 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 e50e8d70b..67718f91c 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 b8cfc5877..ebc3b1b00 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -26,6 +26,7 @@ import KeyvRedis from '@keyv/redis'; 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'; const enterpriseModules = []; @@ -50,6 +51,7 @@ try { }), LoggerModule, NoopAuditModule, + NoopPageViewModule, CoreModule, DatabaseModule, EnvironmentModule, diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index 2a889fd08..94bf33350 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 773774ea7..925d14fd6 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -51,6 +51,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) @@ -65,6 +69,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) @@ -88,6 +93,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 4756c2636..18bcef54a 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -544,6 +544,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; @@ -662,6 +676,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 e8aa0572c..a2755ab0a 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, @@ -107,6 +108,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 546553438..3667e76f0 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}', } @@ -81,6 +82,8 @@ export enum QueueJob { AUDIT_LOG = 'audit-log', AUDIT_CLEANUP = 'audit-cleanup', + PAGE_VIEW_TRACK = 'page-view-track', + PAGE_VIEW_CLEANUP = 'page-view-cleanup', PDF_EXPORT_TASK = 'pdf-export-task', PDF_EXPORT_CLEANUP = 'pdf-export-cleanup', diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index eeb74cbb3..6a76a585f 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -92,6 +92,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.BASE_QUEUE, defaultJobOptions: {