feat: doc status & share status (#14426)

#### PR Dependency Tree


* **PR #14426** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Admin dashboard: view workspace analytics (storage, sync activity, top
shared links) with charts and configurable windows.
* Document analytics tab: see total/unique/guest views and trends over
selectable time windows.
* Last-accessed members: view who last accessed a document, with
pagination.
* Shared links analytics: browse and paginate all shared links with
view/unique/guest metrics and share URLs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-13 01:01:29 +08:00
committed by GitHub
parent b46bf91575
commit b4be9118ad
44 changed files with 5701 additions and 86 deletions
@@ -2,18 +2,20 @@ import { randomUUID } from 'node:crypto';
import { User, Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { Doc as YDoc } from 'yjs';
import { createTestingApp, type TestingApp } from '../../../__tests__/utils';
import { ConfigFactory } from '../../../base';
import { Flavor } from '../../../env';
import { Models } from '../../../models';
import { PgWorkspaceDocStorageAdapter } from '../../doc';
import { DocReader, PgWorkspaceDocStorageAdapter } from '../../doc';
const test = ava as TestFn<{
models: Models;
app: TestingApp;
adapter: PgWorkspaceDocStorageAdapter;
docReader: DocReader;
}>;
test.before(async t => {
@@ -23,6 +25,7 @@ test.before(async t => {
t.context.models = app.get(Models);
t.context.adapter = app.get(PgWorkspaceDocStorageAdapter);
t.context.docReader = app.get(DocReader);
t.context.app = app;
});
@@ -68,3 +71,41 @@ test('should render page success', async t => {
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
t.pass();
});
test('should record page view when rendering shared page', async t => {
const docId = randomUUID();
const { app, adapter, models, docReader } = t.context;
const doc = new YDoc();
const text = doc.getText('content');
const updates: Buffer[] = [];
doc.on('update', update => {
updates.push(Buffer.from(update));
});
text.insert(0, 'analytics');
await adapter.pushDocUpdates(workspace.id, docId, updates, user.id);
await models.doc.publish(workspace.id, docId);
const docContent = Sinon.stub(docReader, 'getDocContent').resolves({
title: 'analytics-doc',
summary: 'summary',
});
const record = Sinon.stub(
models.workspaceAnalytics,
'recordDocView'
).resolves();
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
t.true(record.calledOnce);
t.like(record.firstCall.args[0], {
workspaceId: workspace.id,
docId,
isGuest: true,
});
docContent.restore();
record.restore();
});
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
@@ -5,7 +6,7 @@ import { Controller, Get, Logger, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import isMobile from 'is-mobile';
import { Config, metrics } from '../../base';
import { Config, getRequestTrackerId, metrics } from '../../base';
import { Models } from '../../models';
import { htmlSanitize } from '../../native';
import { Public } from '../auth';
@@ -60,6 +61,13 @@ export class DocRendererController {
);
}
private buildVisitorId(req: Request, workspaceId: string, docId: string) {
const tracker = getRequestTrackerId(req);
return createHash('sha256')
.update(`${workspaceId}:${docId}:${tracker}`)
.digest('hex');
}
@Public()
@Get('/*path')
async render(@Req() req: Request, @Res() res: Response) {
@@ -83,6 +91,22 @@ export class DocRendererController {
? await this.getWorkspaceContent(workspaceId)
: await this.getPageContent(workspaceId, subPath);
metrics.doc.counter('render').add(1);
if (opts && workspaceId !== subPath) {
void this.models.workspaceAnalytics
.recordDocView({
workspaceId,
docId: subPath,
visitorId: this.buildVisitorId(req, workspaceId, subPath),
isGuest: true,
})
.catch(error => {
this.logger.warn(
`Failed to record shared page view: ${workspaceId}/${subPath}`,
error as Error
);
});
}
} catch (e) {
this.logger.error('failed to render page', e);
}
@@ -1,4 +1,10 @@
import { applyDecorators, Logger, UseInterceptors } from '@nestjs/common';
import {
applyDecorators,
Logger,
OnModuleDestroy,
OnModuleInit,
UseInterceptors,
} from '@nestjs/common';
import {
ConnectedSocket,
MessageBody,
@@ -8,6 +14,7 @@ import {
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import type { Request } from 'express';
import { ClsInterceptor } from 'nestjs-cls';
import semver from 'semver';
import { type Server, Socket } from 'socket.io';
@@ -71,6 +78,7 @@ const DOC_UPDATES_PROTOCOL_026 = new semver.Range('>=0.26.0-0', {
});
type SyncProtocolRoomType = Extract<RoomType, 'sync-025' | 'sync-026'>;
const SOCKET_PRESENCE_USER_ID_KEY = 'affinePresenceUserId';
function normalizeWsClientVersion(clientVersion: string): string | null {
if (env.namespaces.canary) {
@@ -190,7 +198,11 @@ interface UpdateAwarenessMessage {
@WebSocketGateway()
@UseInterceptors(ClsInterceptor)
export class SpaceSyncGateway
implements OnGatewayConnection, OnGatewayDisconnect
implements
OnGatewayConnection,
OnGatewayDisconnect,
OnModuleInit,
OnModuleDestroy
{
protected logger = new Logger(SpaceSyncGateway.name);
@@ -198,6 +210,7 @@ export class SpaceSyncGateway
private readonly server!: Server;
private connectionCount = 0;
private flushTimer?: NodeJS.Timeout;
constructor(
private readonly ac: AccessController,
@@ -208,6 +221,22 @@ export class SpaceSyncGateway
private readonly models: Models
) {}
onModuleInit() {
this.flushTimer = setInterval(() => {
this.flushActiveUsersMinute().catch(error => {
this.logger.warn('Failed to flush active users minute', error as Error);
});
}, 60_000);
this.flushTimer.unref?.();
}
onModuleDestroy() {
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.flushTimer = undefined;
}
}
private encodeUpdates(updates: Uint8Array[]) {
return updates.map(update => Buffer.from(update).toString('base64'));
}
@@ -269,18 +298,95 @@ export class SpaceSyncGateway
setImmediate(() => client.disconnect());
}
handleConnection() {
handleConnection(client: Socket) {
this.connectionCount++;
this.logger.debug(`New connection, total: ${this.connectionCount}`);
metrics.socketio.gauge('connections').record(this.connectionCount);
this.attachPresenceUserId(client);
this.flushActiveUsersMinute().catch(error => {
this.logger.warn('Failed to flush active users minute', error as Error);
});
}
handleDisconnect() {
this.connectionCount--;
handleDisconnect(_client: Socket) {
this.connectionCount = Math.max(0, this.connectionCount - 1);
this.logger.debug(
`Connection disconnected, total: ${this.connectionCount}`
);
metrics.socketio.gauge('connections').record(this.connectionCount);
void this.flushActiveUsersMinute({
aggregateAcrossCluster: false,
}).catch(error => {
this.logger.warn('Failed to flush active users minute', error as Error);
});
}
private attachPresenceUserId(client: Socket) {
const request = client.request as Request;
const userId = request.session?.user.id ?? request.token?.user.id;
if (typeof userId !== 'string' || !userId) {
this.logger.warn(
`Unable to resolve authenticated user id for socket ${client.id}`
);
return;
}
client.data[SOCKET_PRESENCE_USER_ID_KEY] = userId;
}
private resolvePresenceUserId(socket: { data?: unknown }) {
if (!socket.data || typeof socket.data !== 'object') {
return null;
}
const userId = (socket.data as Record<string, unknown>)[
SOCKET_PRESENCE_USER_ID_KEY
];
return typeof userId === 'string' && userId ? userId : null;
}
private async flushActiveUsersMinute(options?: {
aggregateAcrossCluster?: boolean;
}) {
const minute = new Date();
minute.setSeconds(0, 0);
const aggregateAcrossCluster = options?.aggregateAcrossCluster ?? true;
let activeUsers = Math.max(0, this.connectionCount);
if (aggregateAcrossCluster) {
try {
const sockets = await this.server.fetchSockets();
const uniqueUsers = new Set<string>();
let missingUserCount = 0;
for (const socket of sockets) {
const userId = this.resolvePresenceUserId(socket);
if (userId) {
uniqueUsers.add(userId);
} else {
missingUserCount++;
}
}
if (missingUserCount > 0) {
activeUsers = sockets.length;
this.logger.warn(
`Unable to resolve user id for ${missingUserCount} active sockets, fallback to connection count`
);
} else {
activeUsers = uniqueUsers.size;
}
} catch (error) {
this.logger.warn(
'Failed to aggregate active users from sockets, using local value',
error as Error
);
}
}
await this.models.workspaceAnalytics.upsertSyncActiveUsersMinute(
minute,
activeUsers
);
}
@OnEvent('doc.updates.pushed')
@@ -1,5 +1,15 @@
import { Controller, Get, Logger, Param, Query, Res } from '@nestjs/common';
import type { Response } from 'express';
import { createHash } from 'node:crypto';
import {
Controller,
Get,
Logger,
Param,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
applyAttachHeaders,
@@ -8,6 +18,7 @@ import {
CommentAttachmentNotFound,
DocHistoryNotFound,
DocNotFound,
getRequestTrackerId,
InvalidHistoryTimestamp,
} from '../../base';
import { DocMode, Models, PublicDocMode } from '../../models';
@@ -30,6 +41,13 @@ export class WorkspacesController {
private readonly models: Models
) {}
private buildVisitorId(req: Request, workspaceId: string, docId: string) {
const tracker = getRequestTrackerId(req);
return createHash('sha256')
.update(`${workspaceId}:${docId}:${tracker}`)
.digest('hex');
}
// get workspace blob
//
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
@@ -99,6 +117,7 @@ export class WorkspacesController {
@CallMetric('controllers', 'workspace_get_doc')
async doc(
@CurrentUser() user: CurrentUser | undefined,
@Req() req: Request,
@Param('id') ws: string,
@Param('guid') guid: string,
@Res() res: Response
@@ -127,6 +146,23 @@ export class WorkspacesController {
});
}
if (!docId.isWorkspace) {
void this.models.workspaceAnalytics
.recordDocView({
workspaceId: docId.workspace,
docId: docId.guid,
userId: user?.id,
visitorId: this.buildVisitorId(req, docId.workspace, docId.guid),
isGuest: !user,
})
.catch(error => {
this.logger.warn(
`Failed to record doc view: ${docId.workspace}/${docId.guid}`,
error as Error
);
});
}
if (!docId.isWorkspace) {
// fetch the publish page mode for publish page
const docMeta = await this.models.doc.getMeta(
@@ -16,6 +16,8 @@ import {
} from '@nestjs/graphql';
import { SafeIntResolver } from 'graphql-scalars';
import { PaginationInput, URLHelper } from '../../../base';
import { PageInfo } from '../../../base/graphql/pagination';
import {
Feature,
Models,
@@ -25,6 +27,7 @@ import {
} from '../../../models';
import { Admin } from '../../common';
import { WorkspaceUserType } from '../../user';
import { TimeWindow } from './analytics-types';
enum AdminWorkspaceSort {
CreatedAt = 'CreatedAt',
@@ -40,6 +43,16 @@ registerEnumType(AdminWorkspaceSort, {
name: 'AdminWorkspaceSort',
});
enum AdminSharedLinksOrder {
UpdatedAtDesc = 'UpdatedAtDesc',
PublishedAtDesc = 'PublishedAtDesc',
ViewsDesc = 'ViewsDesc',
}
registerEnumType(AdminSharedLinksOrder, {
name: 'AdminSharedLinksOrder',
});
@InputType()
class ListWorkspaceInput {
@Field(() => Int, { defaultValue: 20 })
@@ -106,6 +119,195 @@ class AdminWorkspaceSharedLink {
publishedAt?: Date | null;
}
@InputType()
class AdminDashboardInput {
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
timezone?: string;
@Field(() => Int, { nullable: true, defaultValue: 30 })
storageHistoryDays?: number;
@Field(() => Int, { nullable: true, defaultValue: 48 })
syncHistoryHours?: number;
@Field(() => Int, { nullable: true, defaultValue: 28 })
sharedLinkWindowDays?: number;
}
@ObjectType()
class AdminDashboardMinutePoint {
@Field(() => Date)
minute!: Date;
@Field(() => Int)
activeUsers!: number;
}
@ObjectType()
class AdminDashboardValueDayPoint {
@Field(() => Date)
date!: Date;
@Field(() => SafeIntResolver)
value!: number;
}
@ObjectType()
class AdminSharedLinkTopItem {
@Field(() => String)
workspaceId!: string;
@Field(() => String)
docId!: string;
@Field(() => String, { nullable: true })
title?: string | null;
@Field(() => String)
shareUrl!: string;
@Field(() => Date, { nullable: true })
publishedAt?: Date | null;
@Field(() => SafeIntResolver)
views!: number;
@Field(() => SafeIntResolver)
uniqueViews!: number;
@Field(() => SafeIntResolver)
guestViews!: number;
@Field(() => Date, { nullable: true })
lastAccessedAt?: Date | null;
}
@ObjectType()
class AdminDashboard {
@Field(() => Int)
syncActiveUsers!: number;
@Field(() => [AdminDashboardMinutePoint])
syncActiveUsersTimeline!: AdminDashboardMinutePoint[];
@Field(() => TimeWindow)
syncWindow!: TimeWindow;
@Field(() => SafeIntResolver)
copilotConversations!: number;
@Field(() => SafeIntResolver)
workspaceStorageBytes!: number;
@Field(() => SafeIntResolver)
blobStorageBytes!: number;
@Field(() => [AdminDashboardValueDayPoint])
workspaceStorageHistory!: AdminDashboardValueDayPoint[];
@Field(() => [AdminDashboardValueDayPoint])
blobStorageHistory!: AdminDashboardValueDayPoint[];
@Field(() => TimeWindow)
storageWindow!: TimeWindow;
@Field(() => [AdminSharedLinkTopItem])
topSharedLinks!: AdminSharedLinkTopItem[];
@Field(() => TimeWindow)
topSharedLinksWindow!: TimeWindow;
@Field(() => Date)
generatedAt!: Date;
}
@InputType()
class AdminAllSharedLinksFilterInput {
@Field(() => String, { nullable: true })
keyword?: string;
@Field(() => String, { nullable: true })
workspaceId?: string;
@Field(() => Date, { nullable: true })
updatedAfter?: Date;
@Field(() => AdminSharedLinksOrder, {
nullable: true,
defaultValue: AdminSharedLinksOrder.UpdatedAtDesc,
})
orderBy?: AdminSharedLinksOrder;
@Field(() => Int, { nullable: true, defaultValue: 28 })
analyticsWindowDays?: number;
@Field(() => Boolean, { nullable: true, defaultValue: false })
includeTotal?: boolean;
}
@ObjectType()
class AdminAllSharedLink {
@Field(() => String)
workspaceId!: string;
@Field(() => String)
docId!: string;
@Field(() => String, { nullable: true })
title?: string | null;
@Field(() => Date, { nullable: true })
publishedAt?: Date | null;
@Field(() => Date, { nullable: true })
docUpdatedAt?: Date | null;
@Field(() => String, { nullable: true })
workspaceOwnerId?: string | null;
@Field(() => String, { nullable: true })
lastUpdaterId?: string | null;
@Field(() => String)
shareUrl!: string;
@Field(() => SafeIntResolver, { nullable: true })
views?: number | null;
@Field(() => SafeIntResolver, { nullable: true })
uniqueViews?: number | null;
@Field(() => SafeIntResolver, { nullable: true })
guestViews?: number | null;
@Field(() => Date, { nullable: true })
lastAccessedAt?: Date | null;
}
@ObjectType()
class AdminAllSharedLinkEdge {
@Field(() => String)
cursor!: string;
@Field(() => AdminAllSharedLink)
node!: AdminAllSharedLink;
}
@ObjectType()
class PaginatedAdminAllSharedLink {
@Field(() => [AdminAllSharedLinkEdge])
edges!: AdminAllSharedLinkEdge[];
@Field(() => PageInfo)
pageInfo!: PageInfo;
@Field(() => Int, { nullable: true })
totalCount?: number;
@Field(() => TimeWindow)
analyticsWindow!: TimeWindow;
}
@ObjectType()
export class AdminWorkspace {
@Field()
@@ -187,7 +389,10 @@ class AdminUpdateWorkspaceInput extends PartialType(
@Admin()
@Resolver(() => AdminWorkspace)
export class AdminWorkspaceResolver {
constructor(private readonly models: Models) {}
constructor(
private readonly models: Models,
private readonly url: URLHelper
) {}
private assertCloudOnly() {
if (env.selfhosted) {
@@ -261,6 +466,72 @@ export class AdminWorkspaceResolver {
return row;
}
@Query(() => AdminDashboard, {
description: 'Get aggregated dashboard metrics for admin panel',
})
async adminDashboard(
@Args('input', { nullable: true, type: () => AdminDashboardInput })
input?: AdminDashboardInput
) {
this.assertCloudOnly();
const dashboard = await this.models.workspaceAnalytics.adminGetDashboard({
timezone: input?.timezone,
storageHistoryDays: input?.storageHistoryDays,
syncHistoryHours: input?.syncHistoryHours,
sharedLinkWindowDays: input?.sharedLinkWindowDays,
});
return {
...dashboard,
topSharedLinks: dashboard.topSharedLinks.map(link => ({
...link,
shareUrl: this.url.link(`/workspace/${link.workspaceId}/${link.docId}`),
})),
};
}
@Query(() => PaginatedAdminAllSharedLink, {
description: 'List all shared links across workspaces for admin panel',
})
async adminAllSharedLinks(
@Args('pagination', PaginationInput.decode) pagination: PaginationInput,
@Args('filter', {
nullable: true,
type: () => AdminAllSharedLinksFilterInput,
})
filter?: AdminAllSharedLinksFilterInput
) {
this.assertCloudOnly();
const result =
await this.models.workspaceAnalytics.adminPaginateAllSharedLinks({
keyword: filter?.keyword,
workspaceId: filter?.workspaceId,
updatedAfter: filter?.updatedAfter,
orderBy:
filter?.orderBy === AdminSharedLinksOrder.PublishedAtDesc
? 'PublishedAtDesc'
: filter?.orderBy === AdminSharedLinksOrder.ViewsDesc
? 'ViewsDesc'
: 'UpdatedAtDesc',
analyticsWindowDays: filter?.analyticsWindowDays,
includeTotal: filter?.includeTotal,
pagination,
});
return {
...result,
edges: result.edges.map(edge => ({
...edge,
node: {
...edge.node,
shareUrl: this.url.link(
`/workspace/${edge.node.workspaceId}/${edge.node.docId}`
),
},
})),
};
}
@ResolveField(() => [AdminWorkspaceMember], {
description: 'Members of workspace',
})
@@ -0,0 +1,31 @@
import { Field, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
export enum TimeBucket {
Minute = 'Minute',
Day = 'Day',
}
registerEnumType(TimeBucket, {
name: 'TimeBucket',
});
@ObjectType()
export class TimeWindow {
@Field(() => Date)
from!: Date;
@Field(() => Date)
to!: Date;
@Field(() => String)
timezone!: string;
@Field(() => TimeBucket)
bucket!: TimeBucket;
@Field(() => Int)
requestedSize!: number;
@Field(() => Int)
effectiveSize!: number;
}
@@ -3,6 +3,7 @@ import {
Args,
Field,
InputType,
Int,
Mutation,
ObjectType,
Parent,
@@ -11,6 +12,7 @@ import {
Resolver,
} from '@nestjs/graphql';
import { PrismaClient } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars';
import {
Cache,
@@ -27,6 +29,7 @@ import {
PaginationInput,
registerObjectType,
} from '../../../base';
import { PageInfo } from '../../../base/graphql/pagination';
import { Models, PublicDocMode } from '../../../models';
import { CurrentUser } from '../../auth';
import { Editor } from '../../doc';
@@ -38,6 +41,7 @@ import {
} from '../../permission';
import { PublicUserType, WorkspaceUserType } from '../../user';
import { WorkspaceType } from '../types';
import { TimeBucket, TimeWindow } from './analytics-types';
import {
DotToUnderline,
mapPermissionsToGraphqlPermissions,
@@ -194,6 +198,93 @@ class WorkspaceDocMeta {
updatedBy!: EditorType | null;
}
@InputType()
class DocPageAnalyticsInput {
@Field(() => Int, { nullable: true, defaultValue: 28 })
windowDays?: number;
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
timezone?: string;
}
@ObjectType()
class DocPageAnalyticsPoint {
@Field(() => Date)
date!: Date;
@Field(() => SafeIntResolver)
totalViews!: number;
@Field(() => SafeIntResolver)
uniqueViews!: number;
@Field(() => SafeIntResolver)
guestViews!: number;
}
@ObjectType()
class DocPageAnalyticsSummary {
@Field(() => SafeIntResolver)
totalViews!: number;
@Field(() => SafeIntResolver)
uniqueViews!: number;
@Field(() => SafeIntResolver)
guestViews!: number;
@Field(() => Date, { nullable: true })
lastAccessedAt!: Date | null;
}
@ObjectType()
class DocPageAnalytics {
@Field(() => TimeWindow)
window!: TimeWindow;
@Field(() => [DocPageAnalyticsPoint])
series!: DocPageAnalyticsPoint[];
@Field(() => DocPageAnalyticsSummary)
summary!: DocPageAnalyticsSummary;
@Field(() => Date)
generatedAt!: Date;
}
@ObjectType()
class DocMemberLastAccess {
@Field(() => PublicUserType)
user!: PublicUserType;
@Field(() => Date)
lastAccessedAt!: Date;
@Field(() => String, { nullable: true })
lastDocId!: string | null;
}
@ObjectType()
class DocMemberLastAccessEdge {
@Field(() => String)
cursor!: string;
@Field(() => DocMemberLastAccess)
node!: DocMemberLastAccess;
}
@ObjectType()
class PaginatedDocMemberLastAccess {
@Field(() => [DocMemberLastAccessEdge])
edges!: DocMemberLastAccessEdge[];
@Field(() => PageInfo)
pageInfo!: PageInfo;
@Field(() => Int, { nullable: true })
totalCount?: number;
}
@Resolver(() => WorkspaceType)
export class WorkspaceDocResolver {
private readonly logger = new Logger(WorkspaceDocResolver.name);
@@ -464,6 +555,64 @@ export class DocResolver {
updatedBy: metadata.updatedByUser || null,
};
}
@ResolveField(() => DocPageAnalytics, {
description: 'Doc page analytics in a time window',
complexity: 5,
})
async analytics(
@CurrentUser() me: CurrentUser,
@Parent() doc: DocType,
@Args('input', { nullable: true, type: () => DocPageAnalyticsInput })
input?: DocPageAnalyticsInput
): Promise<DocPageAnalytics> {
await this.ac.user(me.id).doc(doc).assert('Doc.Read');
const analytics = await this.models.workspaceAnalytics.getDocPageAnalytics({
workspaceId: doc.workspaceId,
docId: doc.docId,
windowDays: input?.windowDays,
timezone: input?.timezone,
});
return {
...analytics,
window: {
...analytics.window,
bucket:
analytics.window.bucket === 'Minute'
? TimeBucket.Minute
: TimeBucket.Day,
},
};
}
@ResolveField(() => PaginatedDocMemberLastAccess, {
description: 'Paginated last accessed members of the current doc',
complexity: 5,
})
async lastAccessedMembers(
@CurrentUser() me: CurrentUser,
@Parent() doc: DocType,
@Args('pagination', PaginationInput.decode) pagination: PaginationInput,
@Args('query', { nullable: true }) query?: string,
@Args('includeTotal', { nullable: true, defaultValue: false })
includeTotal?: boolean
): Promise<PaginatedDocMemberLastAccess> {
await this.ac
.user(me.id)
.workspace(doc.workspaceId)
.assert('Workspace.Users.Manage');
return this.models.workspaceAnalytics.paginateDocLastAccessedMembers({
workspaceId: doc.workspaceId,
docId: doc.docId,
pagination,
query,
includeTotal: includeTotal ?? false,
});
}
@ResolveField(() => DocPermissions)
async permissions(
@CurrentUser() user: CurrentUser,
@@ -124,6 +124,21 @@ export class WorkspaceStatsJob {
`Recalibrate admin stats for ${processed} workspace(s) (last sid ${lastSid})`
);
}
try {
const snapshotted = await this.withAdvisoryLock(async tx => {
await this.writeDailySnapshot(tx);
return true;
});
if (snapshotted) {
this.logger.debug('Wrote daily workspace admin stats snapshot');
}
} catch (error) {
this.logger.error(
'Failed to write daily workspace admin stats snapshot',
error as Error
);
}
}
private async withAdvisoryLock<T>(
@@ -304,4 +319,31 @@ export class WorkspaceStatsJob {
LIMIT ${limit}
`;
}
private async writeDailySnapshot(tx: Prisma.TransactionClient) {
await tx.$executeRaw`
INSERT INTO workspace_admin_stats_daily (
workspace_id,
date,
snapshot_size,
blob_size,
member_count,
updated_at
)
SELECT
workspace_id,
CURRENT_DATE,
snapshot_size,
blob_size,
member_count,
NOW()
FROM workspace_admin_stats
ON CONFLICT (workspace_id, date)
DO UPDATE SET
snapshot_size = EXCLUDED.snapshot_size,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
updated_at = EXCLUDED.updated_at
`;
}
}