Compare commits

...
Author SHA1 Message Date
Salihu 4a412899e8 scheduled cleanup 2026-08-14 23:14:02 +01:00
Salihu fd81cd281f page analytics retention 2026-08-14 23:12:36 +01:00
Salihu e204184ad8 fix naming conflicts 2026-08-14 16:20:18 +01:00
Salihu 19e2e8ba1c fix naming conflicts 2026-08-14 16:19:12 +01:00
Salihu 79bf2c3cd7 fix issues 2026-08-13 23:43:09 +01:00
Salihu 76ca723d90 chore: update EE submodule 2026-08-13 22:32:21 +01:00
Salihu 734f59967e page analytics init 2026-08-13 22:25:08 +01:00
24 changed files with 811 additions and 20 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 PageAnalytics from "@/ee/page-analytics/pages/page-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={<PageAnalytics />} />
<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 {
getWorkspacePageAnalyticsDailyStats,
getWorkspacePageAnalyticsTopPages,
getWorkspacePageAnalyticsTotals,
} from "@/ee/page-analytics/services/page-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-analytics-totals", dateRange],
queryFn: () => getWorkspacePageAnalyticsTotals(dateRange),
});
queryClient.prefetchQuery({
queryKey: ["workspace-page-analytics-top-pages", listParams],
queryFn: () => getWorkspacePageAnalyticsTopPages(listParams),
});
queryClient.prefetchQuery({
queryKey: ["workspace-page-analytics-daily-stats", listParams],
queryFn: () => getWorkspacePageAnalyticsDailyStats(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 -17
View File
@@ -26,24 +26,8 @@ import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role";
import { daysToRetention, retentionToDays, RetentionUnit } from "@/ee/utils";
type RetentionUnit = "days" | "months" | "years";
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" };
}
if (days >= 30 && days % 30 === 0) {
return { amount: days / 30, unit: "months" };
}
return { amount: days, unit: "days" };
}
function retentionToDays(amount: number, unit: RetentionUnit): number {
if (unit === "years") return amount * 365;
if (unit === "months") return amount * 30;
return amount;
}
export default function AuditLogs() {
const { t } = useTranslation();
+1
View File
@@ -15,6 +15,7 @@ export const Feature = {
SCIM: 'scim',
PAGE_VERIFICATION: 'page:verification',
AUDIT_LOGS: 'audit:logs',
PAGE_ANALYTICS: 'analytics:page-analytics',
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
@@ -0,0 +1,421 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Button,
Card,
Group,
NumberInput,
Popover,
Select,
SimpleGrid,
Stack,
Table,
Text,
Tooltip,
} 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 {
usePageAnalyticsRetentionQuery,
useUpdatePageAnalyticsRetentionMutation,
useWorkspacePageAnalyticsDailyStatsQuery,
useWorkspacePageAnalyticsTopPagesQuery,
useWorkspacePageAnalyticsTotalsQuery,
} from "@/ee/page-analytics/queries/page-analytics-query";
import { Link } from "react-router-dom";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
import { IconSettings } from "@tabler/icons-react";
import {
daysToRetention,
formatNumber,
retentionToDays,
RetentionUnit,
toISODate,
} from "@/ee/utils";
type RangePreset = "7" | "30" | "90";
const DAILY_PAGE_SIZE = 10;
export default function PageAnalytics() {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const [rangePreset, setRangePreset] = useState<RangePreset>("30");
const [topPagesLimit, setTopPagesLimit] = useState("10");
const [settingsOpen, setSettingsOpen] = useState(false);
const { data: retentionData } = usePageAnalyticsRetentionQuery();
const updateRetention = useUpdatePageAnalyticsRetentionMutation();
const parsed = useMemo(
() => daysToRetention(retentionData?.retentionDays ?? 365),
[retentionData]
);
useEffect(() => {
if (settingsOpen) return;
setRetentionAmount(parsed.amount);
setRetentionUnit(parsed.unit);
}, [parsed]);
const [retentionAmount, setRetentionAmount] = useState<number | string>(
parsed.amount
);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
const resetRetentionForm = useCallback(() => {
const { amount, unit } = daysToRetention(retentionData?.retentionDays ?? 365);
setRetentionAmount(amount);
setRetentionUnit(unit);
}, [setRetentionAmount, setRetentionUnit, retentionData]);
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 } = useWorkspacePageAnalyticsTotalsQuery(dateRange);
const { data: topPagesData, isLoading: isTopPagesLoading } =
useWorkspacePageAnalyticsTopPagesQuery(topPagesParams);
const { data: dailyData, isLoading: isDailyLoading } =
useWorkspacePageAnalyticsDailyStatsQuery(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">
<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}
/>
<Popover
position="bottom-end"
shadow="md"
width={260}
withArrow
opened={settingsOpen}
onChange={(opened) => {
if (!opened) resetRetentionForm();
setSettingsOpen(opened);
}}
>
<Popover.Target>
<Tooltip label={t("Audit settings")}>
<ActionIcon
variant="default"
size="input-sm"
ml="auto"
onClick={() => setSettingsOpen((o) => !o)}
>
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Text fz="sm" fw={500} mb={4}>
{t("Retention")}
</Text>
<Text fz="xs" c="dimmed" mb="sm">
{t("Logs older than this period are automatically deleted.")}
</Text>
<Group gap="xs" wrap="nowrap" mb="sm">
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
comboboxProps={{ withinPortal: false }}
/>
</Group>
<Group gap="xs" grow>
<Button
size="xs"
variant="default"
onClick={() => {
resetRetentionForm();
setSettingsOpen(false);
}}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const num =
typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days !== (retentionData?.retentionDays ?? 365)) {
updateRetention.mutate({
pageAnalyticsRetentionDays: Number(days),
});
}
setSettingsOpen(false);
}}
loading={updateRetention.isPending}
>
{t("Save")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</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,73 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getPageAnalyticsRetention,
getWorkspacePageAnalyticsDailyStats,
getWorkspacePageAnalyticsTopPages,
getWorkspacePageAnalyticsTotals,
updatePageAnalyticsRetention,
} from "@/ee/page-analytics/services/page-analytics-service";
import type { IPagination } from "@/lib/types";
import type {
WorkspaceAnalyticsDailyStat,
WorkspaceAnalyticsListParams,
WorkspaceAnalyticsParams,
WorkspaceAnalyticsTopPage,
WorkspaceAnalyticsTotals,
} from "@/ee/page-analytics/types/page-analytics.types";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
export function useWorkspacePageAnalyticsTotalsQuery(
params?: WorkspaceAnalyticsParams,
) {
return useQuery<WorkspaceAnalyticsTotals, Error>({
queryKey: ["workspace-page-analytics-totals", params],
queryFn: () => getWorkspacePageAnalyticsTotals(params),
placeholderData: keepPreviousData,
});
}
export function useWorkspacePageAnalyticsDailyStatsQuery(
params?: WorkspaceAnalyticsListParams,
) {
return useQuery<IPagination<WorkspaceAnalyticsDailyStat>, Error>({
queryKey: ["workspace-page-analytics-daily-stats", params],
queryFn: () => getWorkspacePageAnalyticsDailyStats(params),
placeholderData: keepPreviousData,
});
}
export function useWorkspacePageAnalyticsTopPagesQuery(
params?: WorkspaceAnalyticsListParams,
) {
return useQuery<IPagination<WorkspaceAnalyticsTopPage>, Error>({
queryKey: ["workspace-page-analytics-top-pages", params],
queryFn: () => getWorkspacePageAnalyticsTopPages(params),
placeholderData: keepPreviousData,
});
}
export function usePageAnalyticsRetentionQuery() {
return useQuery({
queryKey: ["page-analytics-retention"],
queryFn: () => getPageAnalyticsRetention(),
});
}
export function useUpdatePageAnalyticsRetentionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (data: { pageAnalyticsRetentionDays: number }) =>
updatePageAnalyticsRetention(data),
onSuccess: () => {
notifications.show({ message: t("Page analytics retention updated") });
queryClient.invalidateQueries({ queryKey: ["page-analytics-retention"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -0,0 +1,43 @@
import api from "@/lib/api-client";
import type { IPagination } from "@/lib/types";
import type {
WorkspaceAnalyticsDailyStat,
WorkspaceAnalyticsListParams,
WorkspaceAnalyticsParams,
WorkspaceAnalyticsTopPage,
WorkspaceAnalyticsTotals,
} from "@/ee/page-analytics/types/page-analytics.types";
export async function getWorkspacePageAnalyticsTotals(
params?: WorkspaceAnalyticsParams,
): Promise<WorkspaceAnalyticsTotals> {
const req = await api.post("/page-analytics/workspace-stats", params);
return req.data;
}
export async function getWorkspacePageAnalyticsDailyStats(
params?: WorkspaceAnalyticsListParams,
): Promise<IPagination<WorkspaceAnalyticsDailyStat>> {
const req = await api.post("/page-analytics/workspace-daily-stats", params);
return req.data;
}
export async function getWorkspacePageAnalyticsTopPages(
params?: WorkspaceAnalyticsListParams,
): Promise<IPagination<WorkspaceAnalyticsTopPage>> {
const req = await api.post("/page-analytics/workspace-top-pages", params);
return req.data;
}
export async function getPageAnalyticsRetention(): Promise<{ retentionDays: number }> {
const req = await api.post("/page-analytics/retention");
return req.data;
}
export async function updatePageAnalyticsRetention(data: {
pageAnalyticsRetentionDays: number;
}): Promise<{ retentionDays: number }> {
const req = await api.post("/page-analytics/retention/update", data);
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;
};
+30
View File
@@ -14,3 +14,33 @@ export function exchangeTokenRedirectUrl(
) {
return getHostnameUrl(hostname) + "/api/auth/exchange?token=" + exchangeToken;
}
export type RetentionUnit = "days" | "months" | "years";
export function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" };
}
if (days >= 30 && days % 30 === 0) {
return { amount: days / 30, unit: "months" };
}
return { amount: days, unit: "days" };
}
export function retentionToDays(amount: number, unit: RetentionUnit): number {
if (unit === "years") return amount * 365;
if (unit === "months") return amount * 30;
return amount;
}
export function toISODate(daysAgo: number | string): string {
const daysNum = Number(daysAgo);
return new Date(Date.now() - daysNum * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
}
export function formatNumber(value: number | null | undefined): string {
return Number(value ?? 0).toLocaleString();
}
+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 { NoopPageAnalyticsModule } from './integrations/page-analytics/page-analytics.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
const enterpriseModules = [];
@@ -50,6 +51,7 @@ try {
}),
LoggerModule,
NoopAuditModule,
NoopPageAnalyticsModule,
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-analytics',
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_ANALYTICS_SERVICE,
IPageAnalyticsService,
} from '../../integrations/page-analytics/page-analytics.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_ANALYTICS_SERVICE) private readonly pageAnalyticsService: IPageAnalyticsService,
) {}
@HttpCode(HttpStatus.OK)
@@ -88,6 +93,13 @@ export class PageController {
const permissions = { canEdit, hasRestriction };
void this.pageAnalyticsService.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_ANALYTICS_SERVICE,
IPageAnalyticsService,
} from '../../integrations/page-analytics/page-analytics.service';
@UseGuards(JwtAuthGuard)
@Controller('shares')
@@ -47,6 +51,8 @@ export class ShareController {
private readonly pageAccessService: PageAccessService,
private readonly licenseCheckService: LicenseCheckService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
@Inject(PAGE_ANALYTICS_SERVICE)
private readonly pageAnalyticsService: IPageAnalyticsService,
) {}
@HttpCode(HttpStatus.OK)
@@ -79,6 +85,14 @@ export class ShareController {
throw new NotFoundException('Shared page not found');
}
void this.pageAnalyticsService.track({
pageId: shareData.page.id,
workspaceId: workspace.id,
spaceId: shareData.page.spaceId,
shareId: shareData.share.id,
userId: null,
});
return {
...shareData,
features: this.licenseCheckService.resolveFeatures(
@@ -0,0 +1,64 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_analytics')
.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('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('share_id', 'uuid')
.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_analytics_workspace_page_date')
.ifNotExists()
.on('page_analytics')
.columns(['workspace_id', 'page_id', 'view_date'])
.execute();
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS
uq_page_analytics_workspace_page_identity
ON page_analytics (
workspace_id,
page_id,
COALESCE(user_id::text, visitor_id)
)
`.execute(db);
await db.schema
.alterTable('workspaces')
.addColumn('page_analytics_retention_days', 'int8', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspaces')
.dropColumn('page_analytics_retention_days')
.execute();
await db.schema.dropTable('page_analytics').ifExists().execute();
}
+16
View File
@@ -437,6 +437,7 @@ export interface WorkspaceInvitations {
}
export interface Workspaces {
pageAnalyticsRetentionDays: Generated<number>;
auditRetentionDays: Generated<number>;
trashRetentionDays: Generated<number>;
billingEmail: string | null;
@@ -544,6 +545,20 @@ export interface PagePermissions {
updatedAt: Generated<Timestamp>;
}
export interface PageAnalytics {
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 +677,7 @@ export interface DB {
pagePermissions: PagePermissions;
pageHistory: PageHistory;
pageLabels: PageLabels;
pageAnalytics: PageAnalytics;
pageVerifications: PageVerifications;
pageVerifiers: PageVerifiers;
pages: Pages;
@@ -1,6 +1,7 @@
import { Insertable, Selectable, Updateable } from 'kysely';
import {
AiChats,
PageAnalytics as _PageAnalytics,
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 PageAnalytics = Selectable<_PageAnalytics>;
export type InsertablePageAnalytics = Insertable<_PageAnalytics>;
export type UpdatablePageAnalytics = Updateable<Omit<_PageAnalytics, 'id'>>;
// Comment
export type Comment = Selectable<Comments>;
export type InsertableComment = Insertable<Comments>;
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { PAGE_ANALYTICS_SERVICE, NoopPageAnalyticsService } from './page-analytics.service';
@Global()
@Module({
providers: [
{
provide: PAGE_ANALYTICS_SERVICE,
useClass: NoopPageAnalyticsService,
},
],
exports: [PAGE_ANALYTICS_SERVICE],
})
export class NoopPageAnalyticsModule {}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
export type PageAnalyticsPayload = {
pageId: string;
workspaceId?: string;
spaceId?: string;
shareId?: string;
userId?: string | null;
visitorId?: string;
};
export const PAGE_ANALYTICS_SERVICE = Symbol('PAGE_ANALYTICS_SERVICE');
export interface IPageAnalyticsService {
track(payload: PageAnalyticsPayload): void | Promise<void>;
}
@Injectable()
export class NoopPageAnalyticsService implements IPageAnalyticsService {
track(_payload: PageAnalyticsPayload): void {}
}
@@ -9,6 +9,7 @@ export enum QueueName {
HISTORY_QUEUE = '{history-queue}',
NOTIFICATION_QUEUE = '{notification-queue}',
AUDIT_QUEUE = '{audit-queue}',
PAGE_ANALYTICS_QUEUE = '{page-analytics-queue}',
BASE_QUEUE = '{base-queue}',
}
@@ -81,6 +82,8 @@ export enum QueueJob {
AUDIT_LOG = 'audit-log',
AUDIT_CLEANUP = 'audit-cleanup',
PAGE_ANALYTICS_TRACK = 'page-analytics-track',
PAGE_ANALYTICS_CLEANUP = 'page-analytics-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_ANALYTICS_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 3,
},
}),
BullModule.registerQueue({
name: QueueName.BASE_QUEUE,
defaultJobOptions: {