page analytics init

This commit is contained in:
Salihu
2026-08-13 22:25:08 +01:00
parent 89378ee766
commit 734f59967e
21 changed files with 604 additions and 2 deletions
+2
View File
@@ -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() {
<Route path={"ai"} element={<AiSettings />} />
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"analytics"} element={<PageViewAnalytics />} />
<Route path={"verifications"} element={<VerifiedPages />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
@@ -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 }],
@@ -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;
}
+1
View File
@@ -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',
@@ -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<RangePreset>("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 (
<>
<Helmet>
<title>
{t("Page analytics")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Page analytics")} />
<Group justify="space-between" mb="md">
<Text c="dimmed" size="sm">
{t("Unique visitors and view volume for your workspace pages.")}
</Text>
<Select
value={rangePreset}
onChange={handleRangeChange}
data={[
{ value: "7", label: t("Last 7 days") },
{ value: "30", label: t("Last 30 days") },
{ value: "90", label: t("Last 90 days") },
]}
w={160}
size="sm"
allowDeselect={false}
/>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} mb="md">
<Card withBorder radius="md" p="md">
<Text size="xs" c="dimmed">
{t("Total views")}
</Text>
<Text fw={700} fz="xl">
{formatNumber(totalsData?.totals.totalViews)}
</Text>
</Card>
<Card withBorder radius="md" p="md">
<Text size="xs" c="dimmed">
{t("Unique visitors")}
</Text>
<Text fw={700} fz="xl">
{formatNumber(totalsData?.totals.uniqueVisitors)}
</Text>
</Card>
<Card withBorder radius="md" p="md">
<Text size="xs" c="dimmed">
{t("Authenticated visitors")}
</Text>
<Text fw={700} fz="xl">
{formatNumber(totalsData?.totals.authenticatedVisitors)}
</Text>
</Card>
<Card withBorder radius="md" p="md">
<Text size="xs" c="dimmed">
{t("Shared-link views")}
</Text>
<Text fw={700} fz="xl">
{formatNumber(totalsData?.totals.sharedViews)}
</Text>
</Card>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Text fw={600}>{t("Top pages")}</Text>
<Group gap="xs">
{isTopPagesLoading && <Badge variant="light">{t("Loading")}</Badge>}
<Select
aria-label={t("Number of top pages")}
data={[
{ value: "10", label: t("Top 10") },
{ value: "20", label: t("Top 20") },
{ value: "50", label: t("Top 50") },
{ value: "100", label: t("Top 100") },
]}
value={topPagesLimit}
onChange={handleTopPagesLimitChange}
w={100}
size="xs"
allowDeselect={false}
/>
</Group>
</Group>
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Page")}</Table.Th>
<Table.Th>{t("Views")}</Table.Th>
<Table.Th>{t("Visitors")}</Table.Th>
<Table.Th>{t("Last viewed")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(topPagesData?.items ?? []).map((item) => (
<Table.Tr key={item.pageId}>
<Table.Td>
{item.pageSlugId ? (
<Link to={`/p/${item.pageSlugId}`}>
{item.pageTitle || t("Untitled")}
</Link>
) : (
item.pageTitle || t("Untitled")
)}
</Table.Td>
<Table.Td>{formatNumber(item.totalViews)}</Table.Td>
<Table.Td>{formatNumber(item.uniqueVisitors)}</Table.Td>
<Table.Td>{formatDate(item.lastViewedAt)}</Table.Td>
</Table.Tr>
))}
{!isTopPagesLoading && (topPagesData?.items.length ?? 0) === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Text c="dimmed" size="sm">
{t("No analytics data for this range.")}
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
{topPagesData?.items &&
topPagesData.items.length > 0 &&
(topPagesData.meta.hasPrevPage || topPagesData.meta.hasNextPage) && (
<Paginate
hasPrevPage={topPagesData.meta.hasPrevPage}
hasNextPage={topPagesData.meta.hasNextPage}
onPrev={goPrevTopPages}
onNext={() => goNextTopPages(topPagesData.meta.nextCursor)}
/>
)}
</Card>
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Text fw={600}>{t("Daily breakdown")}</Text>
{isDailyLoading && <Badge variant="light">{t("Loading")}</Badge>}
</Group>
<Stack gap="xs">
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Date")}</Table.Th>
<Table.Th>{t("Views")}</Table.Th>
<Table.Th>{t("Visitors")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(dailyData?.items ?? []).map((item) => (
<Table.Tr key={item.viewDate}>
<Table.Td>{item.viewDate}</Table.Td>
<Table.Td>{formatNumber(item.totalViews)}</Table.Td>
<Table.Td>{formatNumber(item.uniqueVisitors)}</Table.Td>
</Table.Tr>
))}
{!isDailyLoading && (dailyData?.items.length ?? 0) === 0 && (
<Table.Tr>
<Table.Td colSpan={3}>
<Text c="dimmed" size="sm">
{t("No analytics data for this range.")}
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
{dailyData?.items &&
dailyData.items.length > 0 &&
(dailyData.meta.hasPrevPage || dailyData.meta.hasNextPage) && (
<Paginate
hasPrevPage={dailyData.meta.hasPrevPage}
hasNextPage={dailyData.meta.hasNextPage}
onPrev={goPrevDaily}
onNext={() => goNextDaily(dailyData.meta.nextCursor)}
/>
)}
</Stack>
</Card>
</SimpleGrid>
</>
);
}
@@ -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<WorkspaceAnalyticsTotals, Error>({
queryKey: ["workspace-page-view-totals", params],
queryFn: () => getWorkspacePageViewTotals(params),
placeholderData: keepPreviousData,
});
}
export function useWorkspacePageViewDailyStatsQuery(
params?: WorkspaceAnalyticsListParams,
) {
return useQuery<IPagination<WorkspaceAnalyticsDailyStat>, Error>({
queryKey: ["workspace-page-view-daily-stats", params],
queryFn: () => getWorkspacePageViewDailyStats(params),
placeholderData: keepPreviousData,
});
}
export function useWorkspacePageViewTopPagesQuery(
params?: WorkspaceAnalyticsListParams,
) {
return useQuery<IPagination<WorkspaceAnalyticsTopPage>, Error>({
queryKey: ["workspace-page-view-top-pages", params],
queryFn: () => getWorkspacePageViewTopPages(params),
placeholderData: keepPreviousData,
});
}
@@ -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<WorkspaceAnalyticsTotals> {
const req = await api.post("/page-views/workspace-stats", params);
return req.data;
}
export async function getWorkspacePageViewDailyStats(
params?: WorkspaceAnalyticsListParams,
): Promise<IPagination<WorkspaceAnalyticsDailyStat>> {
const req = await api.post("/page-views/workspace-daily-stats", params);
return req.data;
}
export async function getWorkspacePageViewTopPages(
params?: WorkspaceAnalyticsListParams,
): Promise<IPagination<WorkspaceAnalyticsTopPage>> {
const req = await api.post("/page-views/workspace-top-pages", params);
return req.data;
}
@@ -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;
};
+2
View File
@@ -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,
+1
View File
@@ -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',
@@ -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();
}
@@ -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];
@@ -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'
@@ -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,
@@ -0,0 +1,50 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await db.schema.dropTable('page_views').ifExists().execute();
}
+15
View File
@@ -544,6 +544,20 @@ export interface PagePermissions {
updatedAt: Generated<Timestamp>;
}
export interface PageViews {
id: Generated<string>;
workspaceId: string;
pageId: string;
spaceId: string | null;
shareId: string | null;
userId: string | null;
visitorId: string;
viewDate: string;
hits: Generated<number>;
lastViewedAt: Generated<Timestamp>;
createdAt: Generated<Timestamp>;
}
export interface PageVerifications {
id: Generated<string>;
pageId: string;
@@ -662,6 +676,7 @@ export interface DB {
pagePermissions: PagePermissions;
pageHistory: PageHistory;
pageLabels: PageLabels;
pageViews: PageViews;
pageVerifications: PageVerifications;
pageVerifiers: PageVerifiers;
pages: Pages;
@@ -1,6 +1,7 @@
import { Insertable, Selectable, Updateable } from 'kysely';
import {
AiChats,
PageViews,
AiChatMessages,
Attachments,
BaseProperties,
@@ -107,6 +108,10 @@ export type PageHistory = Selectable<History>;
export type InsertablePageHistory = Insertable<History>;
export type UpdatablePageHistory = Updateable<Omit<History, 'id'>>;
export type PageView = Selectable<PageViews>;
export type InsertablePageView = Insertable<PageViews>;
export type UpdatablePageView = Updateable<Omit<PageViews, 'id'>>;
// Comment
export type Comment = Selectable<Comments>;
export type InsertableComment = Insertable<Comments>;
@@ -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 {}
@@ -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<void>;
}
@Injectable()
export class NoopPageViewService implements IPageViewService {
track(_payload: PageViewPayload): void {}
}
@@ -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',
@@ -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: {