mirror of
https://github.com/docmost/docmost.git
synced 2026-08-20 19:31:36 +10:00
page analytics init
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user