mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-22 08:47:10 +08:00
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:
@@ -0,0 +1,610 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { app, e2e, Mockers } from '../test';
|
||||
|
||||
async function gql(query: string, variables?: Record<string, unknown>) {
|
||||
const res = await app.POST('/graphql').send({ query, variables }).expect(200);
|
||||
return res.body as {
|
||||
data?: Record<string, any>;
|
||||
errors?: Array<{ message: string; extensions: Record<string, any> }>;
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureAnalyticsTables(db: PrismaClient) {
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS workspace_admin_stats_daily (
|
||||
workspace_id VARCHAR NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
snapshot_size BIGINT NOT NULL DEFAULT 0,
|
||||
blob_size BIGINT NOT NULL DEFAULT 0,
|
||||
member_count BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (workspace_id, date)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
|
||||
minute_ts TIMESTAMPTZ(3) NOT NULL PRIMARY KEY,
|
||||
active_users INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS workspace_doc_view_daily (
|
||||
workspace_id VARCHAR NOT NULL,
|
||||
doc_id VARCHAR NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
total_views BIGINT NOT NULL DEFAULT 0,
|
||||
unique_views BIGINT NOT NULL DEFAULT 0,
|
||||
guest_views BIGINT NOT NULL DEFAULT 0,
|
||||
last_accessed_at TIMESTAMPTZ(3),
|
||||
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (workspace_id, doc_id, date)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS workspace_member_last_access (
|
||||
workspace_id VARCHAR NOT NULL,
|
||||
user_id VARCHAR NOT NULL,
|
||||
last_accessed_at TIMESTAMPTZ(3) NOT NULL,
|
||||
last_doc_id VARCHAR,
|
||||
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (workspace_id, user_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
async function createPublicDoc(input: {
|
||||
workspaceId: string;
|
||||
ownerId: string;
|
||||
title: string;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date;
|
||||
}) {
|
||||
const snapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: input.workspaceId,
|
||||
user: { id: input.ownerId },
|
||||
});
|
||||
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId: input.workspaceId,
|
||||
docId: snapshot.id,
|
||||
title: input.title,
|
||||
public: true,
|
||||
publishedAt: input.publishedAt,
|
||||
});
|
||||
|
||||
const db = app.get(PrismaClient);
|
||||
await db.snapshot.update({
|
||||
where: {
|
||||
workspaceId_id: {
|
||||
workspaceId: input.workspaceId,
|
||||
id: snapshot.id,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
updatedAt: input.updatedAt,
|
||||
updatedBy: input.ownerId,
|
||||
},
|
||||
});
|
||||
|
||||
return snapshot.id;
|
||||
}
|
||||
|
||||
e2e(
|
||||
'adminAllSharedLinks should support stable pagination and includeTotal',
|
||||
async t => {
|
||||
const admin = await app.create(Mockers.User, {
|
||||
feature: 'administrator',
|
||||
});
|
||||
await app.login(admin);
|
||||
|
||||
const owner = await app.create(Mockers.User);
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
|
||||
const newerDocId = await createPublicDoc({
|
||||
workspaceId: workspace.id,
|
||||
ownerId: owner.id,
|
||||
title: 'newer-doc',
|
||||
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
|
||||
publishedAt: new Date('2026-02-11T10:00:00.000Z'),
|
||||
});
|
||||
const olderDocId = await createPublicDoc({
|
||||
workspaceId: workspace.id,
|
||||
ownerId: owner.id,
|
||||
title: 'older-doc',
|
||||
updatedAt: new Date('2026-02-10T10:00:00.000Z'),
|
||||
publishedAt: new Date('2026-02-10T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
const db = app.get(PrismaClient);
|
||||
await ensureAnalyticsTables(db);
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_doc_view_daily (
|
||||
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
|
||||
)
|
||||
VALUES
|
||||
(${workspace.id}, ${newerDocId}, CURRENT_DATE, 10, 8, 2, NOW(), NOW()),
|
||||
(${workspace.id}, ${olderDocId}, CURRENT_DATE, 5, 4, 1, NOW(), NOW())
|
||||
ON CONFLICT (workspace_id, doc_id, date)
|
||||
DO UPDATE SET
|
||||
total_views = EXCLUDED.total_views,
|
||||
unique_views = EXCLUDED.unique_views,
|
||||
guest_views = EXCLUDED.guest_views,
|
||||
last_accessed_at = EXCLUDED.last_accessed_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
const query = `
|
||||
query AdminAllSharedLinks($pagination: PaginationInput!, $filter: AdminAllSharedLinksFilterInput) {
|
||||
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
|
||||
totalCount
|
||||
analyticsWindow {
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
workspaceId
|
||||
docId
|
||||
title
|
||||
shareUrl
|
||||
views
|
||||
uniqueViews
|
||||
guestViews
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const firstPage = await gql(query, {
|
||||
pagination: { first: 1, offset: 0 },
|
||||
filter: {
|
||||
includeTotal: false,
|
||||
orderBy: 'UpdatedAtDesc',
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.falsy(firstPage.errors);
|
||||
const first = firstPage.data!.adminAllSharedLinks;
|
||||
t.is(first.totalCount, null);
|
||||
t.true(first.pageInfo.hasNextPage);
|
||||
t.is(first.edges.length, 1);
|
||||
t.true([newerDocId, olderDocId].includes(first.edges[0].node.docId));
|
||||
t.true(
|
||||
first.edges[0].node.shareUrl.includes(`/workspace/${workspace.id}/`)
|
||||
);
|
||||
|
||||
const secondPage = await gql(query, {
|
||||
pagination: { first: 1, offset: 0, after: first.pageInfo.endCursor },
|
||||
filter: {
|
||||
includeTotal: true,
|
||||
orderBy: 'UpdatedAtDesc',
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.falsy(secondPage.errors);
|
||||
const second = secondPage.data!.adminAllSharedLinks;
|
||||
t.is(second.totalCount, 2);
|
||||
t.is(second.edges.length, 1);
|
||||
t.not(second.edges[0].node.docId, first.edges[0].node.docId);
|
||||
|
||||
const conflict = await gql(query, {
|
||||
pagination: {
|
||||
first: 1,
|
||||
offset: 1,
|
||||
after: first.pageInfo.endCursor,
|
||||
},
|
||||
filter: {
|
||||
includeTotal: false,
|
||||
orderBy: 'UpdatedAtDesc',
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(conflict.errors?.length);
|
||||
t.is(conflict.errors![0].extensions.name, 'BAD_REQUEST');
|
||||
|
||||
const malformedDateCursor = await gql(query, {
|
||||
pagination: {
|
||||
first: 1,
|
||||
offset: 0,
|
||||
after: JSON.stringify({
|
||||
orderBy: 'UpdatedAtDesc',
|
||||
sortValue: 'not-a-date',
|
||||
workspaceId: workspace.id,
|
||||
docId: newerDocId,
|
||||
}),
|
||||
},
|
||||
filter: {
|
||||
includeTotal: false,
|
||||
orderBy: 'UpdatedAtDesc',
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(malformedDateCursor.errors?.length);
|
||||
t.is(malformedDateCursor.errors![0].extensions.name, 'BAD_REQUEST');
|
||||
|
||||
const malformedViewsCursor = await gql(query, {
|
||||
pagination: {
|
||||
first: 1,
|
||||
offset: 0,
|
||||
after: JSON.stringify({
|
||||
orderBy: 'ViewsDesc',
|
||||
sortValue: 'NaN',
|
||||
workspaceId: workspace.id,
|
||||
docId: newerDocId,
|
||||
}),
|
||||
},
|
||||
filter: {
|
||||
includeTotal: false,
|
||||
orderBy: 'ViewsDesc',
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(malformedViewsCursor.errors?.length);
|
||||
t.is(malformedViewsCursor.errors![0].extensions.name, 'BAD_REQUEST');
|
||||
}
|
||||
);
|
||||
|
||||
e2e(
|
||||
'adminDashboard should clamp window inputs and return expected buckets',
|
||||
async t => {
|
||||
const admin = await app.create(Mockers.User, {
|
||||
feature: 'administrator',
|
||||
});
|
||||
await app.login(admin);
|
||||
|
||||
const owner = await app.create(Mockers.User);
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
|
||||
const docId = await createPublicDoc({
|
||||
workspaceId: workspace.id,
|
||||
ownerId: owner.id,
|
||||
title: 'dashboard-doc',
|
||||
updatedAt: new Date(),
|
||||
publishedAt: new Date(),
|
||||
});
|
||||
|
||||
const db = app.get(PrismaClient);
|
||||
await ensureAnalyticsTables(db);
|
||||
const minute = new Date();
|
||||
minute.setSeconds(0, 0);
|
||||
|
||||
await db.$executeRaw`
|
||||
INSERT INTO sync_active_users_minutely (minute_ts, active_users, updated_at)
|
||||
VALUES (${minute}, 7, NOW())
|
||||
ON CONFLICT (minute_ts)
|
||||
DO UPDATE SET active_users = EXCLUDED.active_users, updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_admin_stats (
|
||||
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, features, updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, 1, 100, 1, 50, 1, 1, ARRAY[]::text[], NOW())
|
||||
ON CONFLICT (workspace_id)
|
||||
DO UPDATE SET
|
||||
snapshot_count = EXCLUDED.snapshot_count,
|
||||
snapshot_size = EXCLUDED.snapshot_size,
|
||||
blob_count = EXCLUDED.blob_count,
|
||||
blob_size = EXCLUDED.blob_size,
|
||||
member_count = EXCLUDED.member_count,
|
||||
public_page_count = EXCLUDED.public_page_count,
|
||||
features = EXCLUDED.features,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_admin_stats_daily (
|
||||
workspace_id, date, snapshot_size, blob_size, member_count, updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, CURRENT_DATE, 100, 50, 1, NOW())
|
||||
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
|
||||
`;
|
||||
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_doc_view_daily (
|
||||
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, ${docId}, CURRENT_DATE, 3, 2, 1, NOW(), NOW())
|
||||
ON CONFLICT (workspace_id, doc_id, date)
|
||||
DO UPDATE SET
|
||||
total_views = EXCLUDED.total_views,
|
||||
unique_views = EXCLUDED.unique_views,
|
||||
guest_views = EXCLUDED.guest_views,
|
||||
last_accessed_at = EXCLUDED.last_accessed_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
const dashboardQuery = `
|
||||
query AdminDashboard($input: AdminDashboardInput) {
|
||||
adminDashboard(input: $input) {
|
||||
syncWindow {
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
storageWindow {
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
topSharedLinksWindow {
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
syncActiveUsersTimeline {
|
||||
minute
|
||||
activeUsers
|
||||
}
|
||||
workspaceStorageHistory {
|
||||
date
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const result = await gql(dashboardQuery, {
|
||||
input: {
|
||||
storageHistoryDays: -10,
|
||||
syncHistoryHours: -10,
|
||||
sharedLinkWindowDays: -10,
|
||||
},
|
||||
});
|
||||
|
||||
t.falsy(result.errors);
|
||||
const dashboard = result.data!.adminDashboard;
|
||||
t.is(dashboard.syncWindow.bucket, 'Minute');
|
||||
t.is(dashboard.syncWindow.effectiveSize, 1);
|
||||
t.is(dashboard.storageWindow.bucket, 'Day');
|
||||
t.is(dashboard.storageWindow.effectiveSize, 1);
|
||||
t.is(dashboard.topSharedLinksWindow.effectiveSize, 1);
|
||||
t.is(dashboard.syncActiveUsersTimeline.length, 1);
|
||||
t.is(dashboard.workspaceStorageHistory.length, 1);
|
||||
}
|
||||
);
|
||||
|
||||
e2e(
|
||||
'Doc analytics and lastAccessedMembers should enforce permissions and privacy',
|
||||
async t => {
|
||||
const owner = await app.signup();
|
||||
const member = await app.create(Mockers.User);
|
||||
const staleMember = await app.create(Mockers.User);
|
||||
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: staleMember.id,
|
||||
});
|
||||
|
||||
const docId = await createPublicDoc({
|
||||
workspaceId: workspace.id,
|
||||
ownerId: owner.id,
|
||||
title: 'page-analytics-doc',
|
||||
updatedAt: new Date(),
|
||||
publishedAt: new Date(),
|
||||
});
|
||||
|
||||
const db = app.get(PrismaClient);
|
||||
await ensureAnalyticsTables(db);
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_doc_view_daily (
|
||||
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, ${docId}, CURRENT_DATE, 9, 5, 2, NOW(), NOW())
|
||||
ON CONFLICT (workspace_id, doc_id, date)
|
||||
DO UPDATE SET
|
||||
total_views = EXCLUDED.total_views,
|
||||
unique_views = EXCLUDED.unique_views,
|
||||
guest_views = EXCLUDED.guest_views,
|
||||
last_accessed_at = EXCLUDED.last_accessed_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_member_last_access (
|
||||
workspace_id, user_id, last_accessed_at, last_doc_id, updated_at
|
||||
)
|
||||
VALUES
|
||||
(${workspace.id}, ${owner.id}, NOW(), ${docId}, NOW()),
|
||||
(${workspace.id}, ${member.id}, NOW() - interval '1 minute', ${docId}, NOW()),
|
||||
(${workspace.id}, ${staleMember.id}, NOW() - interval '8 day', ${docId}, NOW())
|
||||
ON CONFLICT (workspace_id, user_id)
|
||||
DO UPDATE SET
|
||||
last_accessed_at = EXCLUDED.last_accessed_at,
|
||||
last_doc_id = EXCLUDED.last_doc_id,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
const analyticsQuery = `
|
||||
query DocAnalytics($workspaceId: String!, $docId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
analytics(input: { windowDays: 999 }) {
|
||||
window {
|
||||
effectiveSize
|
||||
}
|
||||
series {
|
||||
date
|
||||
totalViews
|
||||
}
|
||||
summary {
|
||||
totalViews
|
||||
uniqueViews
|
||||
guestViews
|
||||
}
|
||||
}
|
||||
lastAccessedMembers(
|
||||
pagination: { first: 100, offset: 0 }
|
||||
includeTotal: true
|
||||
) {
|
||||
totalCount
|
||||
edges {
|
||||
node {
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
lastAccessedAt
|
||||
lastDocId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await app.login(owner);
|
||||
const ownerResult = await gql(analyticsQuery, {
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
});
|
||||
|
||||
t.falsy(ownerResult.errors);
|
||||
t.is(ownerResult.data!.workspace.doc.analytics.window.effectiveSize, 7);
|
||||
t.true(ownerResult.data!.workspace.doc.analytics.series.length > 0);
|
||||
t.is(ownerResult.data!.workspace.doc.lastAccessedMembers.totalCount, 2);
|
||||
t.is(ownerResult.data!.workspace.doc.lastAccessedMembers.edges.length, 2);
|
||||
t.false(
|
||||
ownerResult.data!.workspace.doc.lastAccessedMembers.edges.some(
|
||||
(edge: { node: { user: { id: string } } }) =>
|
||||
edge.node.user.id === staleMember.id
|
||||
)
|
||||
);
|
||||
|
||||
const malformedMembersCursor = await gql(
|
||||
`
|
||||
query DocMembersCursor($workspaceId: String!, $docId: String!, $after: String) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
lastAccessedMembers(
|
||||
pagination: { first: 10, offset: 0, after: $after }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
after: JSON.stringify({
|
||||
lastAccessedAt: 'not-a-date',
|
||||
userId: owner.id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
t.truthy(malformedMembersCursor.errors?.length);
|
||||
t.is(malformedMembersCursor.errors![0].extensions.name, 'BAD_REQUEST');
|
||||
|
||||
const privacyQuery = `
|
||||
query DocMembersPrivacy($workspaceId: String!, $docId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
lastAccessedMembers(pagination: { first: 10, offset: 0 }) {
|
||||
edges {
|
||||
node {
|
||||
user {
|
||||
id
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const privacyRes = await app
|
||||
.POST('/graphql')
|
||||
.send({
|
||||
query: privacyQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
const privacyResult = privacyRes.body as {
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
t.truthy(privacyResult.errors?.length);
|
||||
t.true(
|
||||
privacyResult.errors![0].message.includes(
|
||||
'Cannot query field "email" on type "PublicUserType"'
|
||||
)
|
||||
);
|
||||
|
||||
await app.login(member);
|
||||
const memberDeniedRes = await app
|
||||
.POST('/graphql')
|
||||
.send({
|
||||
query: `
|
||||
query DocMembersDenied($workspaceId: String!, $docId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
lastAccessedMembers(pagination: { first: 10, offset: 0 }) {
|
||||
edges {
|
||||
node {
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { workspaceId: workspace.id, docId },
|
||||
})
|
||||
.expect(200);
|
||||
const memberDenied = memberDeniedRes.body as {
|
||||
errors?: Array<{ extensions: Record<string, unknown> }>;
|
||||
};
|
||||
t.truthy(memberDenied.errors?.length);
|
||||
t.is(memberDenied.errors![0].extensions.name, 'SPACE_ACCESS_DENIED');
|
||||
}
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import test, { type ExecutionContext } from 'ava';
|
||||
import { io, type Socket as SocketIOClient } from 'socket.io-client';
|
||||
import { Doc, encodeStateAsUpdate } from 'yjs';
|
||||
@@ -146,6 +147,44 @@ function createYjsUpdateBase64() {
|
||||
return Buffer.from(update).toString('base64');
|
||||
}
|
||||
|
||||
async function ensureSyncActiveUsersTable(db: PrismaClient) {
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
|
||||
minute_ts TIMESTAMPTZ(3) NOT NULL PRIMARY KEY,
|
||||
active_users INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async function latestActiveUsers(db: PrismaClient) {
|
||||
const rows = await db.$queryRaw<{ activeUsers: number }[]>`
|
||||
SELECT active_users::integer AS "activeUsers"
|
||||
FROM sync_active_users_minutely
|
||||
ORDER BY minute_ts DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (!rows[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(rows[0].activeUsers);
|
||||
}
|
||||
|
||||
async function waitForActiveUsers(db: PrismaClient, expected: number) {
|
||||
const deadline = Date.now() + WS_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const current = await latestActiveUsers(db);
|
||||
if (current === expected) {
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting active users=${expected}`);
|
||||
}
|
||||
|
||||
let app: TestingApp;
|
||||
let url: string;
|
||||
|
||||
@@ -461,3 +500,22 @@ test('space:join-awareness should reject clientVersion<0.25.0', async t => {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('active users metric should dedupe multiple sockets for one user', async t => {
|
||||
const db = app.get(PrismaClient);
|
||||
await ensureSyncActiveUsersTable(db);
|
||||
|
||||
const { cookieHeader } = await login(app);
|
||||
const first = createClient(url, cookieHeader);
|
||||
const second = createClient(url, cookieHeader);
|
||||
|
||||
try {
|
||||
await Promise.all([waitForConnect(first), waitForConnect(second)]);
|
||||
await waitForActiveUsers(db, 1);
|
||||
t.pass();
|
||||
} finally {
|
||||
first.disconnect();
|
||||
second.disconnect();
|
||||
await Promise.all([waitForDisconnect(first), waitForDisconnect(second)]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -217,6 +217,35 @@ test('should be able to get doc', async t => {
|
||||
t.deepEqual(res.body, Buffer.from([0, 0]));
|
||||
});
|
||||
|
||||
test('should record doc view when reading doc', async t => {
|
||||
const { app, workspace: doc, models } = t.context;
|
||||
|
||||
doc.getDoc.resolves({
|
||||
spaceId: '',
|
||||
docId: '',
|
||||
bin: Buffer.from([0, 0]),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const record = Sinon.stub(
|
||||
models.workspaceAnalytics,
|
||||
'recordDocView'
|
||||
).resolves();
|
||||
await app.login(t.context.u1);
|
||||
|
||||
const res = await app.GET('/api/workspaces/private/docs/public');
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.true(record.calledOnce);
|
||||
t.like(record.firstCall.args[0], {
|
||||
workspaceId: 'private',
|
||||
docId: 'public',
|
||||
userId: t.context.u1.id,
|
||||
isGuest: false,
|
||||
});
|
||||
|
||||
record.restore();
|
||||
});
|
||||
|
||||
test('should be able to change page publish mode', async t => {
|
||||
const { app, workspace: doc, models } = t.context;
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ test('should decode pagination input', async t => {
|
||||
await app.gql(query, {
|
||||
input: {
|
||||
first: 5,
|
||||
offset: 1,
|
||||
offset: 0,
|
||||
after: Buffer.from('4').toString('base64'),
|
||||
},
|
||||
});
|
||||
@@ -90,12 +90,34 @@ test('should decode pagination input', async t => {
|
||||
t.true(
|
||||
paginationStub.calledOnceWithExactly({
|
||||
first: 5,
|
||||
offset: 1,
|
||||
offset: 0,
|
||||
after: '4',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should reject mixed pagination cursor and offset', async t => {
|
||||
const res = await app.POST('/graphql').send({
|
||||
query,
|
||||
variables: {
|
||||
input: {
|
||||
first: 5,
|
||||
offset: 1,
|
||||
after: Buffer.from('4').toString('base64'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(res.status, 200);
|
||||
t.truthy(res.body.errors?.length);
|
||||
t.is(
|
||||
res.body.errors[0].message,
|
||||
'pagination.after and pagination.offset cannot be used together'
|
||||
);
|
||||
t.is(res.body.errors[0].extensions.status, 400);
|
||||
t.is(res.body.errors[0].extensions.name, 'BAD_REQUEST');
|
||||
});
|
||||
|
||||
test('should return encode pageInfo', async t => {
|
||||
const result = paginate(
|
||||
ITEMS.slice(10, 20),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { PipeTransform, Type } from '@nestjs/common';
|
||||
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BadRequest } from '../error';
|
||||
|
||||
@InputType()
|
||||
export class PaginationInput {
|
||||
/**
|
||||
@@ -13,11 +15,15 @@ export class PaginationInput {
|
||||
*/
|
||||
static decode: PipeTransform<PaginationInput, PaginationInput> = {
|
||||
transform: value => {
|
||||
return {
|
||||
const input = {
|
||||
...value,
|
||||
first: Math.min(Math.max(value?.first ?? 10, 1), 100),
|
||||
offset: Math.max(value?.offset ?? 0, 0),
|
||||
after: decode(value?.after),
|
||||
// before: decode(value.before),
|
||||
};
|
||||
assertPaginationInput(input);
|
||||
return input;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -51,6 +57,18 @@ export class PaginationInput {
|
||||
// before?: string | null;
|
||||
}
|
||||
|
||||
export function assertPaginationInput(paginationInput?: PaginationInput) {
|
||||
if (!paginationInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (paginationInput.after && paginationInput.offset > 0) {
|
||||
throw new BadRequest(
|
||||
'pagination.after and pagination.offset cannot be used together'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const encode = (input: unknown) => {
|
||||
let inputStr: string;
|
||||
if (input instanceof Date) {
|
||||
@@ -65,7 +83,7 @@ const encode = (input: unknown) => {
|
||||
const decode = (base64String?: string | null) =>
|
||||
base64String ? Buffer.from(base64String, 'base64').toString('utf-8') : null;
|
||||
|
||||
function encodeWithJson(input: unknown) {
|
||||
export function encodeWithJson(input: unknown) {
|
||||
return encode(JSON.stringify(input ?? null));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { UserFeatureModel } from './user-feature';
|
||||
import { UserSettingsModel } from './user-settings';
|
||||
import { VerificationTokenModel } from './verification-token';
|
||||
import { WorkspaceModel } from './workspace';
|
||||
import { WorkspaceAnalyticsModel } from './workspace-analytics';
|
||||
import { WorkspaceCalendarModel } from './workspace-calendar';
|
||||
import { WorkspaceFeatureModel } from './workspace-feature';
|
||||
import { WorkspaceUserModel } from './workspace-user';
|
||||
@@ -68,6 +69,7 @@ const MODELS = {
|
||||
calendarEvent: CalendarEventModel,
|
||||
calendarEventInstance: CalendarEventInstanceModel,
|
||||
workspaceCalendar: WorkspaceCalendarModel,
|
||||
workspaceAnalytics: WorkspaceAnalyticsModel,
|
||||
};
|
||||
|
||||
type ModelsType = {
|
||||
@@ -144,6 +146,7 @@ export * from './user-feature';
|
||||
export * from './user-settings';
|
||||
export * from './verification-token';
|
||||
export * from './workspace';
|
||||
export * from './workspace-analytics';
|
||||
export * from './workspace-calendar';
|
||||
export * from './workspace-feature';
|
||||
export * from './workspace-user';
|
||||
|
||||
1138
packages/backend/server/src/models/workspace-analytics.ts
Normal file
1138
packages/backend/server/src/models/workspace-analytics.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -59,11 +59,13 @@ export const CheckoutParams = z.object({
|
||||
});
|
||||
|
||||
export abstract class SubscriptionManager {
|
||||
protected readonly scheduleManager = new ScheduleManager(this.stripeProvider);
|
||||
protected readonly scheduleManager: ScheduleManager;
|
||||
constructor(
|
||||
protected readonly stripeProvider: StripeFactory,
|
||||
protected readonly db: PrismaClient
|
||||
) {}
|
||||
) {
|
||||
this.scheduleManager = new ScheduleManager(this.stripeProvider);
|
||||
}
|
||||
|
||||
get stripe() {
|
||||
return this.stripeProvider.stripe;
|
||||
|
||||
@@ -75,7 +75,7 @@ export { CheckoutParams };
|
||||
@Injectable()
|
||||
export class SubscriptionService {
|
||||
private readonly logger = new Logger(SubscriptionService.name);
|
||||
private readonly scheduleManager = new ScheduleManager(this.stripeProvider);
|
||||
private readonly scheduleManager: ScheduleManager;
|
||||
|
||||
constructor(
|
||||
private readonly stripeProvider: StripeFactory,
|
||||
@@ -85,7 +85,9 @@ export class SubscriptionService {
|
||||
private readonly userManager: UserSubscriptionManager,
|
||||
private readonly workspaceManager: WorkspaceSubscriptionManager,
|
||||
private readonly selfhostManager: SelfhostTeamSubscriptionManager
|
||||
) {}
|
||||
) {
|
||||
this.scheduleManager = new ScheduleManager(this.stripeProvider);
|
||||
}
|
||||
|
||||
get stripe() {
|
||||
return this.stripeProvider.stripe;
|
||||
|
||||
@@ -5,12 +5,14 @@ import { fixUrl, OriginRules } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class WorkerService {
|
||||
allowedOrigins: OriginRules = [...this.url.allowedOrigins];
|
||||
allowedOrigins: OriginRules;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly url: URLHelper
|
||||
) {}
|
||||
) {
|
||||
this.allowedOrigins = [...this.url.allowedOrigins];
|
||||
}
|
||||
|
||||
@OnEvent('config.init')
|
||||
onConfigInit() {
|
||||
|
||||
@@ -30,6 +30,85 @@ input AddContextFileInput {
|
||||
contextId: String!
|
||||
}
|
||||
|
||||
type AdminAllSharedLink {
|
||||
docId: String!
|
||||
docUpdatedAt: DateTime
|
||||
guestViews: SafeInt
|
||||
lastAccessedAt: DateTime
|
||||
lastUpdaterId: String
|
||||
publishedAt: DateTime
|
||||
shareUrl: String!
|
||||
title: String
|
||||
uniqueViews: SafeInt
|
||||
views: SafeInt
|
||||
workspaceId: String!
|
||||
workspaceOwnerId: String
|
||||
}
|
||||
|
||||
type AdminAllSharedLinkEdge {
|
||||
cursor: String!
|
||||
node: AdminAllSharedLink!
|
||||
}
|
||||
|
||||
input AdminAllSharedLinksFilterInput {
|
||||
analyticsWindowDays: Int = 28
|
||||
includeTotal: Boolean = false
|
||||
keyword: String
|
||||
orderBy: AdminSharedLinksOrder = UpdatedAtDesc
|
||||
updatedAfter: DateTime
|
||||
workspaceId: String
|
||||
}
|
||||
|
||||
type AdminDashboard {
|
||||
blobStorageBytes: SafeInt!
|
||||
blobStorageHistory: [AdminDashboardValueDayPoint!]!
|
||||
copilotConversations: SafeInt!
|
||||
generatedAt: DateTime!
|
||||
storageWindow: TimeWindow!
|
||||
syncActiveUsers: Int!
|
||||
syncActiveUsersTimeline: [AdminDashboardMinutePoint!]!
|
||||
syncWindow: TimeWindow!
|
||||
topSharedLinks: [AdminSharedLinkTopItem!]!
|
||||
topSharedLinksWindow: TimeWindow!
|
||||
workspaceStorageBytes: SafeInt!
|
||||
workspaceStorageHistory: [AdminDashboardValueDayPoint!]!
|
||||
}
|
||||
|
||||
input AdminDashboardInput {
|
||||
sharedLinkWindowDays: Int = 28
|
||||
storageHistoryDays: Int = 30
|
||||
syncHistoryHours: Int = 48
|
||||
timezone: String = "UTC"
|
||||
}
|
||||
|
||||
type AdminDashboardMinutePoint {
|
||||
activeUsers: Int!
|
||||
minute: DateTime!
|
||||
}
|
||||
|
||||
type AdminDashboardValueDayPoint {
|
||||
date: DateTime!
|
||||
value: SafeInt!
|
||||
}
|
||||
|
||||
type AdminSharedLinkTopItem {
|
||||
docId: String!
|
||||
guestViews: SafeInt!
|
||||
lastAccessedAt: DateTime
|
||||
publishedAt: DateTime
|
||||
shareUrl: String!
|
||||
title: String
|
||||
uniqueViews: SafeInt!
|
||||
views: SafeInt!
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
enum AdminSharedLinksOrder {
|
||||
PublishedAtDesc
|
||||
UpdatedAtDesc
|
||||
ViewsDesc
|
||||
}
|
||||
|
||||
input AdminUpdateWorkspaceInput {
|
||||
avatarKey: String
|
||||
enableAi: Boolean
|
||||
@@ -720,6 +799,17 @@ type DocHistoryType {
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
type DocMemberLastAccess {
|
||||
lastAccessedAt: DateTime!
|
||||
lastDocId: String
|
||||
user: PublicUserType!
|
||||
}
|
||||
|
||||
type DocMemberLastAccessEdge {
|
||||
cursor: String!
|
||||
node: DocMemberLastAccess!
|
||||
}
|
||||
|
||||
"""Doc mode"""
|
||||
enum DocMode {
|
||||
edgeless
|
||||
@@ -731,6 +821,32 @@ type DocNotFoundDataType {
|
||||
spaceId: String!
|
||||
}
|
||||
|
||||
type DocPageAnalytics {
|
||||
generatedAt: DateTime!
|
||||
series: [DocPageAnalyticsPoint!]!
|
||||
summary: DocPageAnalyticsSummary!
|
||||
window: TimeWindow!
|
||||
}
|
||||
|
||||
input DocPageAnalyticsInput {
|
||||
timezone: String = "UTC"
|
||||
windowDays: Int = 28
|
||||
}
|
||||
|
||||
type DocPageAnalyticsPoint {
|
||||
date: DateTime!
|
||||
guestViews: SafeInt!
|
||||
totalViews: SafeInt!
|
||||
uniqueViews: SafeInt!
|
||||
}
|
||||
|
||||
type DocPageAnalyticsSummary {
|
||||
guestViews: SafeInt!
|
||||
lastAccessedAt: DateTime
|
||||
totalViews: SafeInt!
|
||||
uniqueViews: SafeInt!
|
||||
}
|
||||
|
||||
type DocPermissions {
|
||||
Doc_Comments_Create: Boolean!
|
||||
Doc_Comments_Delete: Boolean!
|
||||
@@ -763,6 +879,8 @@ enum DocRole {
|
||||
}
|
||||
|
||||
type DocType {
|
||||
"""Doc page analytics in a time window"""
|
||||
analytics(input: DocPageAnalyticsInput): DocPageAnalytics!
|
||||
createdAt: DateTime
|
||||
|
||||
"""Doc create user"""
|
||||
@@ -774,6 +892,9 @@ type DocType {
|
||||
grantedUsersList(pagination: PaginationInput!): PaginatedGrantedDocUserType!
|
||||
id: String!
|
||||
|
||||
"""Paginated last accessed members of the current doc"""
|
||||
lastAccessedMembers(includeTotal: Boolean = false, pagination: PaginationInput!, query: String): PaginatedDocMemberLastAccess!
|
||||
|
||||
"""Doc last updated user"""
|
||||
lastUpdatedBy: PublicUserType
|
||||
lastUpdaterId: String
|
||||
@@ -1677,6 +1798,13 @@ type PageInfo {
|
||||
startCursor: String
|
||||
}
|
||||
|
||||
type PaginatedAdminAllSharedLink {
|
||||
analyticsWindow: TimeWindow!
|
||||
edges: [AdminAllSharedLinkEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int
|
||||
}
|
||||
|
||||
type PaginatedCommentChangeObjectType {
|
||||
edges: [CommentChangeObjectTypeEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
@@ -1701,6 +1829,12 @@ type PaginatedCopilotWorkspaceFileType {
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type PaginatedDocMemberLastAccess {
|
||||
edges: [DocMemberLastAccessEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int
|
||||
}
|
||||
|
||||
type PaginatedDocType {
|
||||
edges: [DocTypeEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
@@ -1762,6 +1896,12 @@ type PublicUserType {
|
||||
}
|
||||
|
||||
type Query {
|
||||
"""List all shared links across workspaces for admin panel"""
|
||||
adminAllSharedLinks(filter: AdminAllSharedLinksFilterInput, pagination: PaginationInput!): PaginatedAdminAllSharedLink!
|
||||
|
||||
"""Get aggregated dashboard metrics for admin panel"""
|
||||
adminDashboard(input: AdminDashboardInput): AdminDashboard!
|
||||
|
||||
"""Get workspace detail for admin"""
|
||||
adminWorkspace(id: String!): AdminWorkspace
|
||||
|
||||
@@ -2207,6 +2347,20 @@ enum SubscriptionVariant {
|
||||
Onetime
|
||||
}
|
||||
|
||||
enum TimeBucket {
|
||||
Day
|
||||
Minute
|
||||
}
|
||||
|
||||
type TimeWindow {
|
||||
bucket: TimeBucket!
|
||||
effectiveSize: Int!
|
||||
from: DateTime!
|
||||
requestedSize: Int!
|
||||
timezone: String!
|
||||
to: DateTime!
|
||||
}
|
||||
|
||||
type TranscriptionItemType {
|
||||
end: String!
|
||||
speaker: String!
|
||||
|
||||
Reference in New Issue
Block a user