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("Total views")}
+
+
+ {formatNumber(totalsData?.totals.totalViews)}
+
+
+
+
+ {t("Unique visitors")}
+
+
+ {formatNumber(totalsData?.totals.uniqueVisitors)}
+
+
+
+
+ {t("Authenticated visitors")}
+
+
+ {formatNumber(totalsData?.totals.authenticatedVisitors)}
+
+
+
+
+ {t("Shared-link views")}
+
+
+ {formatNumber(totalsData?.totals.sharedViews)}
+
+
+
+
+
+
+
+ {t("Top pages")}
+
+ {isTopPagesLoading && {t("Loading")}}
+
+
+
+
+
+
+ {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: {