From b4be9118ad2045dabb2c98de8174f6d127d8c959 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Fri, 13 Feb 2026 01:01:29 +0800 Subject: [PATCH] feat: doc status & share status (#14426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #14426** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## 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. --- .../migration.sql | 81 ++ packages/backend/server/schema.prisma | 108 +- .../e2e/workspace/admin-analytics.spec.ts | 610 +++++++++ .../server/src/__tests__/sync/gateway.spec.ts | 58 + .../__tests__/workspace/controller.spec.ts | 29 + .../base/graphql/__tests__/pagination.spec.ts | 26 +- .../server/src/base/graphql/pagination.ts | 22 +- .../doc-renderer/__tests__/controller.spec.ts | 43 +- .../src/core/doc-renderer/controller.ts | 26 +- .../backend/server/src/core/sync/gateway.ts | 116 +- .../server/src/core/workspaces/controller.ts | 40 +- .../src/core/workspaces/resolvers/admin.ts | 273 +++- .../workspaces/resolvers/analytics-types.ts | 31 + .../src/core/workspaces/resolvers/doc.ts | 149 +++ .../server/src/core/workspaces/stats.job.ts | 42 + packages/backend/server/src/models/index.ts | 3 + .../server/src/models/workspace-analytics.ts | 1138 +++++++++++++++++ .../src/plugins/payment/manager/common.ts | 6 +- .../server/src/plugins/payment/service.ts | 6 +- .../server/src/plugins/worker/service.ts | 6 +- packages/backend/server/src/schema.gql | 154 +++ .../graphql/admin/admin-all-shared-links.gql | 39 + .../src/graphql/admin/admin-dashboard.gql | 56 + .../graphql/get-doc-last-accessed-members.gql | 37 + .../src/graphql/get-doc-page-analytics.gql | 33 + packages/common/graphql/src/graphql/index.ts | 172 +++ packages/common/graphql/src/schema.ts | 409 ++++++ packages/frontend/admin/package.json | 1 + packages/frontend/admin/src/app.tsx | 23 +- .../admin/src/components/ui/chart.tsx | 173 +++ .../admin/src/modules/dashboard/index.tsx | 645 ++++++++++ .../frontend/admin/src/modules/nav/nav.tsx | 23 +- packages/frontend/core/package.json | 1 + .../workspace/detail-page/detail-page.tsx | 13 + .../detail-page/tabs/analytics.css.ts | 229 ++++ .../workspace/detail-page/tabs/analytics.tsx | 511 ++++++++ .../detail-page/tabs/analytics.utils.spec.ts | 107 ++ .../detail-page/tabs/analytics.utils.ts | 72 ++ .../i18n/src/i18n-completenesses.json | 26 +- packages/frontend/i18n/src/i18n.gen.ts | 68 + packages/frontend/i18n/src/resources/en.json | 16 + packages/frontend/routes/routes.json | 3 + packages/frontend/routes/src/routes.ts | 3 + yarn.lock | 160 ++- 44 files changed, 5701 insertions(+), 86 deletions(-) create mode 100644 packages/backend/server/migrations/20260212053401_workspace_analytics/migration.sql create mode 100644 packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts create mode 100644 packages/backend/server/src/core/workspaces/resolvers/analytics-types.ts create mode 100644 packages/backend/server/src/models/workspace-analytics.ts create mode 100644 packages/common/graphql/src/graphql/admin/admin-all-shared-links.gql create mode 100644 packages/common/graphql/src/graphql/admin/admin-dashboard.gql create mode 100644 packages/common/graphql/src/graphql/get-doc-last-accessed-members.gql create mode 100644 packages/common/graphql/src/graphql/get-doc-page-analytics.gql create mode 100644 packages/frontend/admin/src/components/ui/chart.tsx create mode 100644 packages/frontend/admin/src/modules/dashboard/index.tsx create mode 100644 packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/analytics.css.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/analytics.tsx create mode 100644 packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/analytics.utils.spec.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/analytics.utils.ts diff --git a/packages/backend/server/migrations/20260212053401_workspace_analytics/migration.sql b/packages/backend/server/migrations/20260212053401_workspace_analytics/migration.sql new file mode 100644 index 0000000000..2c5ee3d7bd --- /dev/null +++ b/packages/backend/server/migrations/20260212053401_workspace_analytics/migration.sql @@ -0,0 +1,81 @@ +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(), + CONSTRAINT "workspace_admin_stats_daily_pkey" PRIMARY KEY ("workspace_id", "date"), + CONSTRAINT "workspace_admin_stats_daily_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX IF NOT EXISTS "workspace_admin_stats_daily_date_idx" ON "workspace_admin_stats_daily" ("date"); + +CREATE TABLE IF NOT EXISTS "sync_active_users_minutely" ( + "minute_ts" TIMESTAMPTZ(3) NOT NULL, + "active_users" INTEGER NOT NULL DEFAULT 0, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(), + CONSTRAINT "sync_active_users_minutely_pkey" PRIMARY KEY ("minute_ts") +); + +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(), + CONSTRAINT "workspace_doc_view_daily_pkey" PRIMARY KEY ("workspace_id", "doc_id", "date"), + CONSTRAINT "workspace_doc_view_daily_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX IF NOT EXISTS "workspace_doc_view_daily_workspace_id_date_idx" ON "workspace_doc_view_daily" ("workspace_id", "date"); + +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(), + CONSTRAINT "workspace_member_last_access_pkey" PRIMARY KEY ("workspace_id", "user_id"), + CONSTRAINT "workspace_member_last_access_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "workspace_member_last_access_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX IF NOT EXISTS "workspace_member_last_access_workspace_id_last_accessed_at_idx" ON "workspace_member_last_access" ("workspace_id", "last_accessed_at" DESC); + +CREATE INDEX IF NOT EXISTS "workspace_member_last_access_workspace_id_last_doc_id_idx" ON "workspace_member_last_access" ("workspace_id", "last_doc_id"); + +CREATE INDEX IF NOT EXISTS "workspace_pages_public_published_at_idx" ON "workspace_pages" ("public", "published_at"); + +CREATE INDEX IF NOT EXISTS "ai_sessions_messages_created_at_role_idx" ON "ai_sessions_messages" ("created_at", "role"); + +DROP TRIGGER IF EXISTS user_features_set_feature_id ON "user_features"; + +DROP TRIGGER IF EXISTS workspace_features_set_feature_id ON "workspace_features"; + +DROP FUNCTION IF EXISTS set_user_feature_id_from_name(); + +DROP FUNCTION IF EXISTS set_workspace_feature_id_from_name(); + +DROP FUNCTION IF EXISTS ensure_feature_exists(TEXT); + +ALTER TABLE + "user_features" DROP CONSTRAINT "user_features_feature_id_fkey"; + +ALTER TABLE + "workspace_features" DROP CONSTRAINT "workspace_features_feature_id_fkey"; + +DROP INDEX "user_features_feature_id_idx"; + +DROP INDEX "workspace_features_feature_id_idx"; + +ALTER TABLE + "user_features" DROP COLUMN "feature_id"; + +ALTER TABLE + "workspace_features" DROP COLUMN "feature_id"; + +DROP TABLE "features"; \ No newline at end of file diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index dfdd49bfb9..c8f82c77fb 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -25,31 +25,32 @@ model User { registered Boolean @default(true) disabled Boolean @default(false) - features UserFeature[] - userStripeCustomer UserStripeCustomer? - workspaces WorkspaceUserRole[] + features UserFeature[] + userStripeCustomer UserStripeCustomer? + workspaces WorkspaceUserRole[] // Invite others to join the workspace - WorkspaceInvitations WorkspaceUserRole[] @relation("inviter") - docPermissions WorkspaceDocUserRole[] - connectedAccounts ConnectedAccount[] - calendarAccounts CalendarAccount[] - sessions UserSession[] - aiSessions AiSession[] - appConfigs AppConfig[] - userSnapshots UserSnapshot[] - createdSnapshot Snapshot[] @relation("createdSnapshot") - updatedSnapshot Snapshot[] @relation("updatedSnapshot") - createdUpdate Update[] @relation("createdUpdate") - createdHistory SnapshotHistory[] @relation("createdHistory") - createdAiJobs AiJobs[] @relation("createdAiJobs") + WorkspaceInvitations WorkspaceUserRole[] @relation("inviter") + docPermissions WorkspaceDocUserRole[] + connectedAccounts ConnectedAccount[] + calendarAccounts CalendarAccount[] + sessions UserSession[] + aiSessions AiSession[] + appConfigs AppConfig[] + userSnapshots UserSnapshot[] + createdSnapshot Snapshot[] @relation("createdSnapshot") + updatedSnapshot Snapshot[] @relation("updatedSnapshot") + createdUpdate Update[] @relation("createdUpdate") + createdHistory SnapshotHistory[] @relation("createdHistory") + createdAiJobs AiJobs[] @relation("createdAiJobs") // receive notifications - notifications Notification[] @relation("user_notifications") - settings UserSettings? - comments Comment[] - replies Reply[] - commentAttachments CommentAttachment[] @relation("createdCommentAttachments") - AccessToken AccessToken[] - workspaceCalendars WorkspaceCalendar[] + notifications Notification[] @relation("user_notifications") + settings UserSettings? + comments Comment[] + replies Reply[] + commentAttachments CommentAttachment[] @relation("createdCommentAttachments") + AccessToken AccessToken[] + workspaceCalendars WorkspaceCalendar[] + workspaceMemberLastAccesses WorkspaceMemberLastAccess[] @@index([email]) @@map("users") @@ -151,6 +152,9 @@ model Workspace { workspaceCalendars WorkspaceCalendar[] workspaceAdminStats WorkspaceAdminStats[] workspaceAdminStatsDirties WorkspaceAdminStatsDirty[] + workspaceAdminStatsDaily WorkspaceAdminStatsDaily[] + workspaceDocViewDaily WorkspaceDocViewDaily[] + workspaceMemberLastAccess WorkspaceMemberLastAccess[] @@index([lastCheckEmbeddings]) @@index([createdAt]) @@ -180,6 +184,7 @@ model WorkspaceDoc { @@id([workspaceId, docId]) @@index([workspaceId, public]) + @@index([public, publishedAt]) @@map("workspace_pages") } @@ -320,6 +325,62 @@ model WorkspaceAdminStatsDirty { @@map("workspace_admin_stats_dirty") } +model WorkspaceAdminStatsDaily { + workspaceId String @map("workspace_id") @db.VarChar + date DateTime @db.Date + snapshotSize BigInt @default(0) @map("snapshot_size") @db.BigInt + blobSize BigInt @default(0) @map("blob_size") @db.BigInt + memberCount BigInt @default(0) @map("member_count") @db.BigInt + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3) + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + @@id([workspaceId, date]) + @@index([date]) + @@map("workspace_admin_stats_daily") +} + +model SyncActiveUsersMinutely { + minuteTs DateTime @id @map("minute_ts") @db.Timestamptz(3) + activeUsers Int @default(0) @map("active_users") @db.Integer + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3) + + @@map("sync_active_users_minutely") +} + +model WorkspaceDocViewDaily { + workspaceId String @map("workspace_id") @db.VarChar + docId String @map("doc_id") @db.VarChar + date DateTime @db.Date + totalViews BigInt @default(0) @map("total_views") @db.BigInt + uniqueViews BigInt @default(0) @map("unique_views") @db.BigInt + guestViews BigInt @default(0) @map("guest_views") @db.BigInt + lastAccessedAt DateTime? @map("last_accessed_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3) + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + @@id([workspaceId, docId, date]) + @@index([workspaceId, date]) + @@map("workspace_doc_view_daily") +} + +model WorkspaceMemberLastAccess { + workspaceId String @map("workspace_id") @db.VarChar + userId String @map("user_id") @db.VarChar + lastAccessedAt DateTime @map("last_accessed_at") @db.Timestamptz(3) + lastDocId String? @map("last_doc_id") @db.VarChar + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3) + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([workspaceId, userId]) + @@index([workspaceId, lastAccessedAt(sort: Desc)]) + @@index([workspaceId, lastDocId]) + @@map("workspace_member_last_access") +} + // the latest snapshot of each doc that we've seen // Snapshot + Updates are the latest state of the doc model Snapshot { @@ -456,6 +517,7 @@ model AiSessionMessage { session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) @@index([sessionId]) + @@index([createdAt, role]) @@map("ai_sessions_messages") } diff --git a/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts new file mode 100644 index 0000000000..11a628bd78 --- /dev/null +++ b/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts @@ -0,0 +1,610 @@ +import { PrismaClient } from '@prisma/client'; + +import { app, e2e, Mockers } from '../test'; + +async function gql(query: string, variables?: Record) { + const res = await app.POST('/graphql').send({ query, variables }).expect(200); + return res.body as { + data?: Record; + errors?: Array<{ message: string; extensions: Record }>; + }; +} + +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 }>; + }; + t.truthy(memberDenied.errors?.length); + t.is(memberDenied.errors![0].extensions.name, 'SPACE_ACCESS_DENIED'); + } +); diff --git a/packages/backend/server/src/__tests__/sync/gateway.spec.ts b/packages/backend/server/src/__tests__/sync/gateway.spec.ts index be2c76cf1e..b67a974518 100644 --- a/packages/backend/server/src/__tests__/sync/gateway.spec.ts +++ b/packages/backend/server/src/__tests__/sync/gateway.spec.ts @@ -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)]); + } +}); diff --git a/packages/backend/server/src/__tests__/workspace/controller.spec.ts b/packages/backend/server/src/__tests__/workspace/controller.spec.ts index fa48d6b4a9..a68d44bd23 100644 --- a/packages/backend/server/src/__tests__/workspace/controller.spec.ts +++ b/packages/backend/server/src/__tests__/workspace/controller.spec.ts @@ -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; diff --git a/packages/backend/server/src/base/graphql/__tests__/pagination.spec.ts b/packages/backend/server/src/base/graphql/__tests__/pagination.spec.ts index 7771c7dcbc..cd4f0d969b 100644 --- a/packages/backend/server/src/base/graphql/__tests__/pagination.spec.ts +++ b/packages/backend/server/src/base/graphql/__tests__/pagination.spec.ts @@ -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), diff --git a/packages/backend/server/src/base/graphql/pagination.ts b/packages/backend/server/src/base/graphql/pagination.ts index e16dd0bf88..c24287c6d8 100644 --- a/packages/backend/server/src/base/graphql/pagination.ts +++ b/packages/backend/server/src/base/graphql/pagination.ts @@ -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 = { 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)); } diff --git a/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts b/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts index a88dd3bccf..8dfcda3b5f 100644 --- a/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts +++ b/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts @@ -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(); +}); diff --git a/packages/backend/server/src/core/doc-renderer/controller.ts b/packages/backend/server/src/core/doc-renderer/controller.ts index 6c1fd2101d..3246daad2c 100644 --- a/packages/backend/server/src/core/doc-renderer/controller.ts +++ b/packages/backend/server/src/core/doc-renderer/controller.ts @@ -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); } diff --git a/packages/backend/server/src/core/sync/gateway.ts b/packages/backend/server/src/core/sync/gateway.ts index ff4484fa90..e31f8075b2 100644 --- a/packages/backend/server/src/core/sync/gateway.ts +++ b/packages/backend/server/src/core/sync/gateway.ts @@ -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; +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)[ + 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(); + 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') diff --git a/packages/backend/server/src/core/workspaces/controller.ts b/packages/backend/server/src/core/workspaces/controller.ts index 319f6bdcf8..78c92bb045 100644 --- a/packages/backend/server/src/core/workspaces/controller.ts +++ b/packages/backend/server/src/core/workspaces/controller.ts @@ -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( diff --git a/packages/backend/server/src/core/workspaces/resolvers/admin.ts b/packages/backend/server/src/core/workspaces/resolvers/admin.ts index 338e6d322f..d5b76d2df9 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/admin.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/admin.ts @@ -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', }) diff --git a/packages/backend/server/src/core/workspaces/resolvers/analytics-types.ts b/packages/backend/server/src/core/workspaces/resolvers/analytics-types.ts new file mode 100644 index 0000000000..b1142afa4b --- /dev/null +++ b/packages/backend/server/src/core/workspaces/resolvers/analytics-types.ts @@ -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; +} diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index 657c146ad5..026e727652 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -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 { + 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 { + 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, diff --git a/packages/backend/server/src/core/workspaces/stats.job.ts b/packages/backend/server/src/core/workspaces/stats.job.ts index 24a015c40a..d2fc7f2358 100644 --- a/packages/backend/server/src/core/workspaces/stats.job.ts +++ b/packages/backend/server/src/core/workspaces/stats.job.ts @@ -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( @@ -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 + `; + } } diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 3fb36d8ccf..a362e106dd 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -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'; diff --git a/packages/backend/server/src/models/workspace-analytics.ts b/packages/backend/server/src/models/workspace-analytics.ts new file mode 100644 index 0000000000..253d165344 --- /dev/null +++ b/packages/backend/server/src/models/workspace-analytics.ts @@ -0,0 +1,1138 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { BadRequest, QueryTooLong } from '../base'; +import { + decodeWithJson, + encodeWithJson, + PaginationInput, +} from '../base/graphql/pagination'; +import { CacheRedis } from '../base/redis'; +import { BaseModel } from './base'; +import { WorkspaceRole } from './common'; + +const DEFAULT_STORAGE_HISTORY_DAYS = 30; +const DEFAULT_SYNC_HISTORY_HOURS = 48; +const DEFAULT_SHARED_LINK_WINDOW_DAYS = 28; +const DEFAULT_ANALYTICS_WINDOW_DAYS = 28; +const NON_TEAM_ANALYTICS_WINDOW_DAYS = 7; +const DEFAULT_TIMEZONE = 'UTC'; +const DOC_MEMBER_QUERY_MAX_LENGTH = 255; +const MEMBER_PAGINATION_MAX = 50; +const UNIQUE_VISITOR_KEY_TTL_SECONDS = 90 * 24 * 60 * 60; + +type SharedLinksOrder = 'UpdatedAtDesc' | 'PublishedAtDesc' | 'ViewsDesc'; + +type TimeBucket = 'Minute' | 'Day'; + +type SharedLinkCursor = { + orderBy: SharedLinksOrder; + sortValue: string | number; + workspaceId: string; + docId: string; +}; + +type MemberCursor = { + lastAccessedAt: string; + userId: string; +}; + +export type TimeWindowDto = { + from: Date; + to: Date; + timezone: string; + bucket: TimeBucket; + requestedSize: number; + effectiveSize: number; +}; + +export type AdminDashboardOptions = { + timezone?: string; + storageHistoryDays?: number; + syncHistoryHours?: number; + sharedLinkWindowDays?: number; +}; + +export type AdminAllSharedLinksOptions = { + keyword?: string; + workspaceId?: string; + updatedAfter?: Date; + orderBy?: SharedLinksOrder; + analyticsWindowDays?: number; + includeTotal?: boolean; + pagination: PaginationInput; +}; + +export type OptionalTotalPaginated = { + edges: Array<{ + cursor: string; + node: T; + }>; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; + endCursor: string | null; + }; + totalCount?: number; +}; + +export type AdminSharedLinkNode = { + workspaceId: string; + docId: string; + title: string | null; + publishedAt: Date | null; + docUpdatedAt: Date | null; + workspaceOwnerId: string | null; + lastUpdaterId: string | null; + views: number; + uniqueViews: number; + guestViews: number; + lastAccessedAt: Date | null; +}; + +export type AdminDashboardDto = { + syncActiveUsers: number; + syncActiveUsersTimeline: Array<{ + minute: Date; + activeUsers: number; + }>; + syncWindow: TimeWindowDto; + copilotConversations: number; + workspaceStorageBytes: number; + blobStorageBytes: number; + workspaceStorageHistory: Array<{ + date: Date; + value: number; + }>; + blobStorageHistory: Array<{ + date: Date; + value: number; + }>; + storageWindow: TimeWindowDto; + topSharedLinks: AdminSharedLinkNode[]; + topSharedLinksWindow: TimeWindowDto; + generatedAt: Date; +}; + +export type DocPageAnalyticsPoint = { + date: Date; + totalViews: number; + uniqueViews: number; + guestViews: number; +}; + +export type DocPageAnalyticsDto = { + window: TimeWindowDto; + series: DocPageAnalyticsPoint[]; + summary: { + totalViews: number; + uniqueViews: number; + guestViews: number; + lastAccessedAt: Date | null; + }; + generatedAt: Date; +}; + +export type DocMemberLastAccessNode = { + user: { + id: string; + name: string; + avatarUrl: string | null; + }; + lastAccessedAt: Date; + lastDocId: string | null; +}; + +function clampInt( + value: number | undefined, + min: number, + max: number, + def: number +) { + if (!Number.isFinite(value)) { + return def; + } + + return Math.min(max, Math.max(min, Math.trunc(value as number))); +} + +function floorMinute(date: Date) { + const result = new Date(date); + result.setSeconds(0, 0); + return result; +} + +function startOfUtcDay(date: Date) { + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) + ); +} + +function addUtcDays(date: Date, days: number) { + return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); +} + +function asDateOnlyString(date: Date) { + return date.toISOString().slice(0, 10); +} + +function normalizeTimezone(timezone?: string) { + const trimmed = timezone?.trim(); + return trimmed ? trimmed : DEFAULT_TIMEZONE; +} + +function parseJsonCursor(cursor?: string | null): T | null { + if (!cursor) { + return null; + } + + const raw = cursor.trim(); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as T; + } catch { + try { + return decodeWithJson(raw); + } catch { + throw new BadRequest('Invalid pagination cursor'); + } + } +} + +function parseCursorDate(value: unknown): Date { + if ( + typeof value !== 'string' && + typeof value !== 'number' && + !(value instanceof Date) + ) { + throw new BadRequest('Invalid pagination cursor'); + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new BadRequest('Invalid pagination cursor'); + } + return parsed; +} + +function parseCursorNumber(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new BadRequest('Invalid pagination cursor'); + } + return parsed; +} + +function parseCursorString(value: unknown): string { + if (typeof value !== 'string' || !value) { + throw new BadRequest('Invalid pagination cursor'); + } + return value; +} + +@Injectable() +export class WorkspaceAnalyticsModel extends BaseModel { + constructor(private readonly redis: CacheRedis) { + super(); + } + + async adminGetDashboard( + options: AdminDashboardOptions + ): Promise { + const timezone = normalizeTimezone(options.timezone); + const storageHistoryDays = clampInt( + options.storageHistoryDays, + 1, + 90, + DEFAULT_STORAGE_HISTORY_DAYS + ); + const syncHistoryHours = clampInt( + options.syncHistoryHours, + 1, + 72, + DEFAULT_SYNC_HISTORY_HOURS + ); + const sharedLinkWindowDays = clampInt( + options.sharedLinkWindowDays, + 1, + 90, + DEFAULT_SHARED_LINK_WINDOW_DAYS + ); + + const now = new Date(); + + const syncTo = floorMinute(now); + const syncFrom = new Date( + syncTo.getTime() - (syncHistoryHours - 1) * 60 * 60 * 1000 + ); + + const currentDay = startOfUtcDay(now); + const storageFrom = addUtcDays(currentDay, -(storageHistoryDays - 1)); + const sharedFrom = addUtcDays(currentDay, -(sharedLinkWindowDays - 1)); + + const [ + syncCurrent, + syncTimeline, + storageCurrent, + storageHistory, + copilotCount, + topSharedLinks, + ] = await Promise.all([ + this.db.$queryRaw<{ activeUsers: number }[]>` + SELECT COALESCE( + ( + SELECT active_users + FROM sync_active_users_minutely + WHERE minute_ts <= ${syncTo} + ORDER BY minute_ts DESC + LIMIT 1 + ), + 0 + )::integer AS "activeUsers" + `, + this.db.$queryRaw<{ minute: Date; activeUsers: number }[]>` + WITH minutes AS ( + SELECT generate_series(${syncFrom}, ${syncTo}, interval '1 minute') AS minute_ts + ) + SELECT + minutes.minute_ts AS minute, + COALESCE(s.active_users, 0)::integer AS "activeUsers" + FROM minutes + LEFT JOIN sync_active_users_minutely s ON s.minute_ts = minutes.minute_ts + ORDER BY minute ASC + `, + this.db.$queryRaw< + { + workspaceStorageBytes: bigint | number; + blobStorageBytes: bigint | number; + }[] + >` + SELECT + COALESCE(SUM(snapshot_size), 0) AS "workspaceStorageBytes", + COALESCE(SUM(blob_size), 0) AS "blobStorageBytes" + FROM workspace_admin_stats + `, + this.db.$queryRaw< + { + date: Date; + workspaceStorageBytes: bigint | number; + blobStorageBytes: bigint | number; + }[] + >` + WITH days AS ( + SELECT generate_series(${storageFrom}::date, ${currentDay}::date, interval '1 day')::date AS day + ), + grouped AS ( + SELECT + date, + COALESCE(SUM(snapshot_size), 0) AS workspace_storage_bytes, + COALESCE(SUM(blob_size), 0) AS blob_storage_bytes + FROM workspace_admin_stats_daily + WHERE date BETWEEN ${storageFrom}::date AND ${currentDay}::date + GROUP BY date + ) + SELECT + days.day AS date, + COALESCE(grouped.workspace_storage_bytes, 0) AS "workspaceStorageBytes", + COALESCE(grouped.blob_storage_bytes, 0) AS "blobStorageBytes" + FROM days + LEFT JOIN grouped ON grouped.date = days.day + ORDER BY date ASC + `, + this.db.$queryRaw<{ conversations: bigint | number }[]>` + SELECT COUNT(*) AS conversations + FROM ai_sessions_messages + WHERE role = 'user' + AND created_at >= ${sharedFrom} + AND created_at <= ${now} + `, + this.db.$queryRaw< + { + workspaceId: string; + docId: string; + title: string | null; + publishedAt: Date | null; + docUpdatedAt: Date | null; + workspaceOwnerId: string | null; + lastUpdaterId: string | null; + views: bigint | number; + uniqueViews: bigint | number; + guestViews: bigint | number; + lastAccessedAt: Date | null; + }[] + >` + WITH view_agg AS ( + SELECT + workspace_id, + doc_id, + COALESCE(SUM(total_views), 0) AS views, + COALESCE(SUM(unique_views), 0) AS unique_views, + COALESCE(SUM(guest_views), 0) AS guest_views, + MAX(last_accessed_at) AS last_accessed_at + FROM workspace_doc_view_daily + WHERE date BETWEEN ${sharedFrom}::date AND ${currentDay}::date + GROUP BY workspace_id, doc_id + ) + SELECT + wp.workspace_id AS "workspaceId", + wp.page_id AS "docId", + wp.title AS title, + wp.published_at AS "publishedAt", + sn.updated_at AS "docUpdatedAt", + owner.user_id AS "workspaceOwnerId", + sn.updated_by AS "lastUpdaterId", + COALESCE(v.views, 0) AS views, + COALESCE(v.unique_views, 0) AS "uniqueViews", + COALESCE(v.guest_views, 0) AS "guestViews", + v.last_accessed_at AS "lastAccessedAt" + FROM workspace_pages wp + LEFT JOIN snapshots sn + ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id + LEFT JOIN view_agg v + ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id + LEFT JOIN LATERAL ( + SELECT user_id + FROM workspace_user_permissions + WHERE workspace_id = wp.workspace_id + AND type = ${WorkspaceRole.Owner} + AND status = 'Accepted'::"WorkspaceMemberStatus" + ORDER BY created_at ASC + LIMIT 1 + ) owner ON TRUE + WHERE wp.public = TRUE + ORDER BY views DESC, "uniqueViews" DESC, "workspaceId" ASC, "docId" ASC + LIMIT 10 + `, + ]); + + const storageHistorySeries = storageHistory.map(row => ({ + date: row.date, + workspaceStorageBytes: Number(row.workspaceStorageBytes ?? 0), + blobStorageBytes: Number(row.blobStorageBytes ?? 0), + })); + + return { + syncActiveUsers: Number(syncCurrent[0]?.activeUsers ?? 0), + syncActiveUsersTimeline: syncTimeline.map(row => ({ + minute: row.minute, + activeUsers: Number(row.activeUsers ?? 0), + })), + syncWindow: { + from: syncFrom, + to: syncTo, + timezone, + bucket: 'Minute', + requestedSize: options.syncHistoryHours ?? DEFAULT_SYNC_HISTORY_HOURS, + effectiveSize: syncHistoryHours, + }, + copilotConversations: Number(copilotCount[0]?.conversations ?? 0), + workspaceStorageBytes: Number( + storageCurrent[0]?.workspaceStorageBytes ?? 0 + ), + blobStorageBytes: Number(storageCurrent[0]?.blobStorageBytes ?? 0), + workspaceStorageHistory: storageHistorySeries.map(row => ({ + date: row.date, + value: row.workspaceStorageBytes, + })), + blobStorageHistory: storageHistorySeries.map(row => ({ + date: row.date, + value: row.blobStorageBytes, + })), + storageWindow: { + from: storageFrom, + to: currentDay, + timezone, + bucket: 'Day', + requestedSize: + options.storageHistoryDays ?? DEFAULT_STORAGE_HISTORY_DAYS, + effectiveSize: storageHistoryDays, + }, + topSharedLinks: topSharedLinks.map(row => ({ + ...row, + views: Number(row.views ?? 0), + uniqueViews: Number(row.uniqueViews ?? 0), + guestViews: Number(row.guestViews ?? 0), + })), + topSharedLinksWindow: { + from: sharedFrom, + to: currentDay, + timezone, + bucket: 'Day', + requestedSize: + options.sharedLinkWindowDays ?? DEFAULT_SHARED_LINK_WINDOW_DAYS, + effectiveSize: sharedLinkWindowDays, + }, + generatedAt: now, + }; + } + + async adminPaginateAllSharedLinks( + options: AdminAllSharedLinksOptions + ): Promise< + OptionalTotalPaginated & { + analyticsWindow: TimeWindowDto; + } + > { + const pagination: PaginationInput = { + ...options.pagination, + first: Math.min(Math.max(options.pagination.first ?? 10, 1), 100), + offset: Math.max(options.pagination.offset ?? 0, 0), + }; + const keyword = options.keyword?.trim(); + if (keyword && keyword.length > DOC_MEMBER_QUERY_MAX_LENGTH) { + throw new QueryTooLong({ max: DOC_MEMBER_QUERY_MAX_LENGTH }); + } + + const includeTotal = options.includeTotal ?? false; + const orderBy = options.orderBy ?? 'UpdatedAtDesc'; + const analyticsWindowDays = clampInt( + options.analyticsWindowDays, + 1, + 90, + DEFAULT_ANALYTICS_WINDOW_DAYS + ); + const now = new Date(); + const currentDay = startOfUtcDay(now); + const analyticsFrom = addUtcDays(currentDay, -(analyticsWindowDays - 1)); + + const cursor = parseJsonCursor(pagination.after ?? null); + const cursorCondition = this.buildSharedLinkCursorCondition( + orderBy, + cursor + ); + const orderClause = this.buildSharedLinkOrderClause(orderBy); + + const keywordCondition = keyword + ? Prisma.sql`AND ( + wp.title ILIKE ${`%${keyword}%`} + OR wp.page_id ILIKE ${`%${keyword}%`} + OR wp.workspace_id ILIKE ${`%${keyword}%`} + )` + : Prisma.empty; + + const workspaceCondition = options.workspaceId + ? Prisma.sql`AND wp.workspace_id = ${options.workspaceId}` + : Prisma.empty; + + const updatedAfterCondition = options.updatedAfter + ? Prisma.sql`AND sn.updated_at >= ${options.updatedAfter}` + : Prisma.empty; + + const rows = await this.db.$queryRaw< + Array< + AdminSharedLinkNode & { + sortValueDate: Date; + sortValueNumber: number; + } + > + >` + WITH view_agg AS ( + SELECT + workspace_id, + doc_id, + COALESCE(SUM(total_views), 0) AS views, + COALESCE(SUM(unique_views), 0) AS unique_views, + COALESCE(SUM(guest_views), 0) AS guest_views, + MAX(last_accessed_at) AS last_accessed_at + FROM workspace_doc_view_daily + WHERE date BETWEEN ${analyticsFrom}::date AND ${currentDay}::date + GROUP BY workspace_id, doc_id + ), + base AS ( + SELECT + wp.workspace_id AS "workspaceId", + wp.page_id AS "docId", + wp.title AS title, + wp.published_at AS "publishedAt", + sn.updated_at AS "docUpdatedAt", + owner.user_id AS "workspaceOwnerId", + sn.updated_by AS "lastUpdaterId", + COALESCE(v.views, 0) AS views, + COALESCE(v.unique_views, 0) AS "uniqueViews", + COALESCE(v.guest_views, 0) AS "guestViews", + v.last_accessed_at AS "lastAccessedAt", + COALESCE(sn.updated_at, to_timestamp(0)) AS "sortValueDateUpdatedAt", + COALESCE(wp.published_at, to_timestamp(0)) AS "sortValueDatePublishedAt", + COALESCE(v.views, 0) AS "sortValueViews" + FROM workspace_pages wp + LEFT JOIN snapshots sn + ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id + LEFT JOIN view_agg v + ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id + LEFT JOIN LATERAL ( + SELECT user_id + FROM workspace_user_permissions + WHERE workspace_id = wp.workspace_id + AND type = ${WorkspaceRole.Owner} + AND status = 'Accepted'::"WorkspaceMemberStatus" + ORDER BY created_at ASC + LIMIT 1 + ) owner ON TRUE + WHERE wp.public = TRUE + ${keywordCondition} + ${workspaceCondition} + ${updatedAfterCondition} + ) + SELECT + "workspaceId", + "docId", + title, + "publishedAt", + "docUpdatedAt", + "workspaceOwnerId", + "lastUpdaterId", + views, + "uniqueViews", + "guestViews", + "lastAccessedAt", + CASE + WHEN ${orderBy} = 'UpdatedAtDesc' THEN "sortValueDateUpdatedAt" + WHEN ${orderBy} = 'PublishedAtDesc' THEN "sortValueDatePublishedAt" + ELSE to_timestamp(0) + END AS "sortValueDate", + CASE + WHEN ${orderBy} = 'ViewsDesc' THEN "sortValueViews" + ELSE 0 + END AS "sortValueNumber" + FROM base + WHERE 1 = 1 + ${cursorCondition} + ORDER BY ${orderClause} + LIMIT ${pagination.first + 1} + OFFSET ${pagination.offset} + `; + + const hasNextPage = rows.length > pagination.first; + const pageRows = hasNextPage ? rows.slice(0, pagination.first) : rows; + + const edges = pageRows.map(row => { + const sortValue = + orderBy === 'ViewsDesc' + ? Number(row.sortValueNumber ?? 0) + : row.sortValueDate.toISOString(); + const cursorValue: SharedLinkCursor = { + orderBy, + sortValue, + workspaceId: row.workspaceId, + docId: row.docId, + }; + + return { + cursor: encodeWithJson(cursorValue), + node: { + ...row, + views: Number(row.views ?? 0), + uniqueViews: Number(row.uniqueViews ?? 0), + guestViews: Number(row.guestViews ?? 0), + }, + }; + }); + + const totalCount = includeTotal + ? await this.countAdminSharedLinks({ + keyword, + workspaceId: options.workspaceId, + updatedAfter: options.updatedAfter, + }) + : undefined; + + return { + edges, + pageInfo: { + hasNextPage, + hasPreviousPage: Boolean(pagination.after) || pagination.offset > 0, + startCursor: edges[0]?.cursor ?? null, + endCursor: edges[edges.length - 1]?.cursor ?? null, + }, + totalCount, + analyticsWindow: { + from: analyticsFrom, + to: currentDay, + timezone: DEFAULT_TIMEZONE, + bucket: 'Day', + requestedSize: + options.analyticsWindowDays ?? DEFAULT_ANALYTICS_WINDOW_DAYS, + effectiveSize: analyticsWindowDays, + }, + }; + } + + async getDocPageAnalytics(input: { + workspaceId: string; + docId: string; + windowDays?: number; + timezone?: string; + }): Promise { + const isTeamWorkspace = await this.models.workspace.isTeamWorkspace( + input.workspaceId + ); + const defaultWindowDays = isTeamWorkspace + ? DEFAULT_ANALYTICS_WINDOW_DAYS + : NON_TEAM_ANALYTICS_WINDOW_DAYS; + const requestedWindowDays = input.windowDays ?? defaultWindowDays; + const windowDays = clampInt( + requestedWindowDays, + 1, + isTeamWorkspace ? 90 : NON_TEAM_ANALYTICS_WINDOW_DAYS, + defaultWindowDays + ); + const timezone = normalizeTimezone(input.timezone); + const now = new Date(); + const currentDay = startOfUtcDay(now); + const from = addUtcDays(currentDay, -(windowDays - 1)); + + const rows = await this.db.$queryRaw< + { + date: Date; + totalViews: bigint | number; + uniqueViews: bigint | number; + guestViews: bigint | number; + lastAccessedAt: Date | null; + }[] + >` + WITH days AS ( + SELECT generate_series(${from}::date, ${currentDay}::date, interval '1 day')::date AS day + ) + SELECT + days.day AS date, + COALESCE(v.total_views, 0) AS "totalViews", + COALESCE(v.unique_views, 0) AS "uniqueViews", + COALESCE(v.guest_views, 0) AS "guestViews", + v.last_accessed_at AS "lastAccessedAt" + FROM days + LEFT JOIN workspace_doc_view_daily v + ON v.workspace_id = ${input.workspaceId} + AND v.doc_id = ${input.docId} + AND v.date = days.day + ORDER BY date ASC + `; + + const series = rows.map(row => ({ + date: row.date, + totalViews: Number(row.totalViews ?? 0), + uniqueViews: Number(row.uniqueViews ?? 0), + guestViews: Number(row.guestViews ?? 0), + lastAccessedAt: row.lastAccessedAt, + })); + + const summary = series.reduce( + (acc, row) => { + acc.totalViews += row.totalViews; + acc.uniqueViews += row.uniqueViews; + acc.guestViews += row.guestViews; + if ( + row.lastAccessedAt && + (!acc.lastAccessedAt || row.lastAccessedAt > acc.lastAccessedAt) + ) { + acc.lastAccessedAt = row.lastAccessedAt; + } + return acc; + }, + { + totalViews: 0, + uniqueViews: 0, + guestViews: 0, + lastAccessedAt: null as Date | null, + } + ); + + return { + window: { + from, + to: currentDay, + timezone, + bucket: 'Day', + requestedSize: requestedWindowDays, + effectiveSize: windowDays, + }, + series: series.map(row => ({ + date: row.date, + totalViews: row.totalViews, + uniqueViews: row.uniqueViews, + guestViews: row.guestViews, + })), + summary, + generatedAt: now, + }; + } + + async paginateDocLastAccessedMembers(input: { + workspaceId: string; + docId: string; + pagination: PaginationInput; + query?: string; + includeTotal?: boolean; + }): Promise> { + const isTeamWorkspace = await this.models.workspace.isTeamWorkspace( + input.workspaceId + ); + const nonTeamAccessFrom = isTeamWorkspace + ? null + : addUtcDays( + startOfUtcDay(new Date()), + -(NON_TEAM_ANALYTICS_WINDOW_DAYS - 1) + ); + + const pagination: PaginationInput = { + ...input.pagination, + first: Math.min( + MEMBER_PAGINATION_MAX, + Math.max(input.pagination.first ?? 10, 1) + ), + offset: Math.max(input.pagination.offset ?? 0, 0), + }; + const keyword = input.query?.trim(); + if (keyword && keyword.length > DOC_MEMBER_QUERY_MAX_LENGTH) { + throw new QueryTooLong({ max: DOC_MEMBER_QUERY_MAX_LENGTH }); + } + + const cursor = parseJsonCursor(pagination.after ?? null); + const keywordCondition = keyword + ? Prisma.sql`AND (u.name ILIKE ${`%${keyword}%`} OR u.email ILIKE ${`%${keyword}%`})` + : Prisma.empty; + const windowCondition = nonTeamAccessFrom + ? Prisma.sql`AND mla.last_accessed_at >= ${nonTeamAccessFrom}` + : Prisma.empty; + const cursorCondition = cursor + ? (() => { + const cursorLastAccessedAt = parseCursorDate(cursor.lastAccessedAt); + const cursorUserId = parseCursorString(cursor.userId); + return Prisma.sql` + AND ( + mla.last_accessed_at < ${cursorLastAccessedAt} + OR ( + mla.last_accessed_at = ${cursorLastAccessedAt} + AND mla.user_id > ${cursorUserId} + ) + ) + `; + })() + : Prisma.empty; + + const rows = await this.db.$queryRaw< + { + userId: string; + name: string; + avatarUrl: string | null; + lastAccessedAt: Date; + lastDocId: string | null; + }[] + >` + SELECT + mla.user_id AS "userId", + u.name AS name, + u.avatar_url AS "avatarUrl", + mla.last_accessed_at AS "lastAccessedAt", + mla.last_doc_id AS "lastDocId" + FROM workspace_member_last_access mla + INNER JOIN users u ON u.id = mla.user_id + INNER JOIN workspace_user_permissions wur + ON wur.workspace_id = mla.workspace_id + AND wur.user_id = mla.user_id + AND wur.status = 'Accepted'::"WorkspaceMemberStatus" + WHERE mla.workspace_id = ${input.workspaceId} + AND mla.last_doc_id = ${input.docId} + ${windowCondition} + ${keywordCondition} + ${cursorCondition} + ORDER BY mla.last_accessed_at DESC, mla.user_id ASC + LIMIT ${pagination.first + 1} + OFFSET ${pagination.offset} + `; + + const hasNextPage = rows.length > pagination.first; + const pageRows = hasNextPage ? rows.slice(0, pagination.first) : rows; + + const edges = pageRows.map(row => { + const cursorValue: MemberCursor = { + lastAccessedAt: row.lastAccessedAt.toISOString(), + userId: row.userId, + }; + return { + cursor: encodeWithJson(cursorValue), + node: { + user: { + id: row.userId, + name: row.name, + avatarUrl: row.avatarUrl, + }, + lastAccessedAt: row.lastAccessedAt, + lastDocId: row.lastDocId, + }, + }; + }); + + const totalCount = input.includeTotal + ? await this.countDocLastAccessedMembers( + input.workspaceId, + input.docId, + keyword, + nonTeamAccessFrom + ) + : undefined; + + return { + edges, + pageInfo: { + hasNextPage, + hasPreviousPage: Boolean(pagination.after) || pagination.offset > 0, + startCursor: edges[0]?.cursor ?? null, + endCursor: edges[edges.length - 1]?.cursor ?? null, + }, + totalCount, + }; + } + + async recordDocView(input: { + workspaceId: string; + docId: string; + viewedAt?: Date; + visitorId: string; + isGuest: boolean; + userId?: string; + }) { + const viewedAt = input.viewedAt ?? new Date(); + const viewedDate = asDateOnlyString(startOfUtcDay(viewedAt)); + const unique = await this.markDailyUniqueVisitor( + input.workspaceId, + input.docId, + viewedDate, + input.visitorId + ); + + await this.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 ( + ${input.workspaceId}, + ${input.docId}, + ${viewedDate}::date, + 1, + ${unique ? 1 : 0}, + ${input.isGuest ? 1 : 0}, + ${viewedAt}, + NOW() + ) + ON CONFLICT (workspace_id, doc_id, date) + DO UPDATE SET + total_views = workspace_doc_view_daily.total_views + 1, + unique_views = workspace_doc_view_daily.unique_views + ${unique ? 1 : 0}, + guest_views = workspace_doc_view_daily.guest_views + ${input.isGuest ? 1 : 0}, + last_accessed_at = COALESCE( + GREATEST(workspace_doc_view_daily.last_accessed_at, EXCLUDED.last_accessed_at), + EXCLUDED.last_accessed_at + ), + updated_at = NOW() + `; + + if (input.userId) { + await this.db.$executeRaw` + INSERT INTO workspace_member_last_access ( + workspace_id, + user_id, + last_accessed_at, + last_doc_id, + updated_at + ) + VALUES ( + ${input.workspaceId}, + ${input.userId}, + ${viewedAt}, + ${input.docId}, + NOW() + ) + ON CONFLICT (workspace_id, user_id) + DO UPDATE SET + last_accessed_at = GREATEST( + workspace_member_last_access.last_accessed_at, + EXCLUDED.last_accessed_at + ), + last_doc_id = CASE + WHEN EXCLUDED.last_accessed_at >= workspace_member_last_access.last_accessed_at + THEN EXCLUDED.last_doc_id + ELSE workspace_member_last_access.last_doc_id + END, + updated_at = NOW() + `; + } + } + + async upsertSyncActiveUsersMinute(minuteTs: Date, activeUsers: number) { + await this.db.$executeRaw` + INSERT INTO sync_active_users_minutely ( + minute_ts, + active_users, + updated_at + ) + VALUES ( + ${minuteTs}, + ${Math.max(0, Math.trunc(activeUsers))}, + NOW() + ) + ON CONFLICT (minute_ts) + DO UPDATE SET + active_users = EXCLUDED.active_users, + updated_at = NOW() + `; + } + + private async countAdminSharedLinks(options: { + keyword?: string; + workspaceId?: string; + updatedAfter?: Date; + }) { + const keywordCondition = options.keyword + ? Prisma.sql`AND ( + wp.title ILIKE ${`%${options.keyword}%`} + OR wp.page_id ILIKE ${`%${options.keyword}%`} + OR wp.workspace_id ILIKE ${`%${options.keyword}%`} + )` + : Prisma.empty; + const workspaceCondition = options.workspaceId + ? Prisma.sql`AND wp.workspace_id = ${options.workspaceId}` + : Prisma.empty; + const updatedAfterCondition = options.updatedAfter + ? Prisma.sql` + AND EXISTS ( + SELECT 1 + FROM snapshots sn + WHERE sn.workspace_id = wp.workspace_id + AND sn.guid = wp.page_id + AND sn.updated_at >= ${options.updatedAfter} + ) + ` + : Prisma.empty; + + const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>` + SELECT COUNT(*) AS total + FROM workspace_pages wp + WHERE wp.public = TRUE + ${keywordCondition} + ${workspaceCondition} + ${updatedAfterCondition} + `; + + return Number(row?.total ?? 0); + } + + private async countDocLastAccessedMembers( + workspaceId: string, + docId: string, + keyword?: string, + accessedFrom?: Date | null + ) { + const keywordCondition = keyword + ? Prisma.sql`AND (u.name ILIKE ${`%${keyword}%`} OR u.email ILIKE ${`%${keyword}%`})` + : Prisma.empty; + const windowCondition = accessedFrom + ? Prisma.sql`AND mla.last_accessed_at >= ${accessedFrom}` + : Prisma.empty; + + const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>` + SELECT COUNT(*) AS total + FROM workspace_member_last_access mla + INNER JOIN users u ON u.id = mla.user_id + INNER JOIN workspace_user_permissions wur + ON wur.workspace_id = mla.workspace_id + AND wur.user_id = mla.user_id + AND wur.status = 'Accepted'::"WorkspaceMemberStatus" + WHERE mla.workspace_id = ${workspaceId} + AND mla.last_doc_id = ${docId} + ${windowCondition} + ${keywordCondition} + `; + + return Number(row?.total ?? 0); + } + + private buildSharedLinkOrderClause(orderBy: SharedLinksOrder): Prisma.Sql { + switch (orderBy) { + case 'PublishedAtDesc': + return Prisma.sql`"sortValueDatePublishedAt" DESC, "workspaceId" ASC, "docId" ASC`; + case 'ViewsDesc': + return Prisma.sql`"sortValueViews" DESC, "workspaceId" ASC, "docId" ASC`; + case 'UpdatedAtDesc': + default: + return Prisma.sql`"sortValueDateUpdatedAt" DESC, "workspaceId" ASC, "docId" ASC`; + } + } + + private buildSharedLinkCursorCondition( + orderBy: SharedLinksOrder, + cursor: SharedLinkCursor | null + ) { + if (!cursor) { + return Prisma.empty; + } + + if (cursor.orderBy !== orderBy) { + return Prisma.empty; + } + + const workspaceId = parseCursorString(cursor.workspaceId); + const docId = parseCursorString(cursor.docId); + + if (orderBy === 'ViewsDesc') { + const sortValue = parseCursorNumber(cursor.sortValue); + return Prisma.sql` + AND ( + "sortValueViews" < ${sortValue} + OR ("sortValueViews" = ${sortValue} AND "workspaceId" > ${workspaceId}) + OR ( + "sortValueViews" = ${sortValue} + AND "workspaceId" = ${workspaceId} + AND "docId" > ${docId} + ) + ) + `; + } + + const sortValue = parseCursorDate(cursor.sortValue); + const sortField = + orderBy === 'PublishedAtDesc' + ? Prisma.raw('"sortValueDatePublishedAt"') + : Prisma.raw('"sortValueDateUpdatedAt"'); + return Prisma.sql` + AND ( + ${sortField} < ${sortValue} + OR (${sortField} = ${sortValue} AND "workspaceId" > ${workspaceId}) + OR ( + ${sortField} = ${sortValue} + AND "workspaceId" = ${workspaceId} + AND "docId" > ${docId} + ) + ) + `; + } + + private async markDailyUniqueVisitor( + workspaceId: string, + docId: string, + date: string, + visitorId: string + ) { + const key = `analytics:doc_uv:${workspaceId}:${docId}:${date}`; + try { + const added = await this.redis.sadd(key, visitorId); + if (added > 0) { + await this.redis.expire(key, UNIQUE_VISITOR_KEY_TTL_SECONDS); + return true; + } + return false; + } catch { + return true; + } + } +} diff --git a/packages/backend/server/src/plugins/payment/manager/common.ts b/packages/backend/server/src/plugins/payment/manager/common.ts index c5a5324e75..4683a2e64f 100644 --- a/packages/backend/server/src/plugins/payment/manager/common.ts +++ b/packages/backend/server/src/plugins/payment/manager/common.ts @@ -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; diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index d24f3ad278..3206a88f42 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -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; diff --git a/packages/backend/server/src/plugins/worker/service.ts b/packages/backend/server/src/plugins/worker/service.ts index 99027d6826..010272a141 100644 --- a/packages/backend/server/src/plugins/worker/service.ts +++ b/packages/backend/server/src/plugins/worker/service.ts @@ -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() { diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 883d3eb9d1..b1874bb303 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -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! diff --git a/packages/common/graphql/src/graphql/admin/admin-all-shared-links.gql b/packages/common/graphql/src/graphql/admin/admin-all-shared-links.gql new file mode 100644 index 0000000000..cf32ab399a --- /dev/null +++ b/packages/common/graphql/src/graphql/admin/admin-all-shared-links.gql @@ -0,0 +1,39 @@ +query adminAllSharedLinks( + $pagination: PaginationInput! + $filter: AdminAllSharedLinksFilterInput +) { + adminAllSharedLinks(pagination: $pagination, filter: $filter) { + totalCount + analyticsWindow { + from + to + timezone + bucket + requestedSize + effectiveSize + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + workspaceId + docId + title + publishedAt + docUpdatedAt + workspaceOwnerId + lastUpdaterId + shareUrl + views + uniqueViews + guestViews + lastAccessedAt + } + } + } +} diff --git a/packages/common/graphql/src/graphql/admin/admin-dashboard.gql b/packages/common/graphql/src/graphql/admin/admin-dashboard.gql new file mode 100644 index 0000000000..a305360138 --- /dev/null +++ b/packages/common/graphql/src/graphql/admin/admin-dashboard.gql @@ -0,0 +1,56 @@ +query adminDashboard($input: AdminDashboardInput) { + adminDashboard(input: $input) { + syncActiveUsers + syncActiveUsersTimeline { + minute + activeUsers + } + syncWindow { + from + to + timezone + bucket + requestedSize + effectiveSize + } + copilotConversations + workspaceStorageBytes + blobStorageBytes + workspaceStorageHistory { + date + value + } + blobStorageHistory { + date + value + } + storageWindow { + from + to + timezone + bucket + requestedSize + effectiveSize + } + topSharedLinks { + workspaceId + docId + title + shareUrl + publishedAt + views + uniqueViews + guestViews + lastAccessedAt + } + topSharedLinksWindow { + from + to + timezone + bucket + requestedSize + effectiveSize + } + generatedAt + } +} diff --git a/packages/common/graphql/src/graphql/get-doc-last-accessed-members.gql b/packages/common/graphql/src/graphql/get-doc-last-accessed-members.gql new file mode 100644 index 0000000000..e26cbf4867 --- /dev/null +++ b/packages/common/graphql/src/graphql/get-doc-last-accessed-members.gql @@ -0,0 +1,37 @@ +query getDocLastAccessedMembers( + $workspaceId: String! + $docId: String! + $pagination: PaginationInput! + $query: String + $includeTotal: Boolean +) { + workspace(id: $workspaceId) { + doc(docId: $docId) { + lastAccessedMembers( + pagination: $pagination + query: $query + includeTotal: $includeTotal + ) { + totalCount + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + user { + id + name + avatarUrl + } + lastAccessedAt + lastDocId + } + } + } + } + } +} diff --git a/packages/common/graphql/src/graphql/get-doc-page-analytics.gql b/packages/common/graphql/src/graphql/get-doc-page-analytics.gql new file mode 100644 index 0000000000..414f11d4a1 --- /dev/null +++ b/packages/common/graphql/src/graphql/get-doc-page-analytics.gql @@ -0,0 +1,33 @@ +query getDocPageAnalytics( + $workspaceId: String! + $docId: String! + $input: DocPageAnalyticsInput +) { + workspace(id: $workspaceId) { + doc(docId: $docId) { + analytics(input: $input) { + window { + from + to + timezone + bucket + requestedSize + effectiveSize + } + series { + date + totalViews + uniqueViews + guestViews + } + summary { + totalViews + uniqueViews + guestViews + lastAccessedAt + } + generatedAt + } + } + } +} diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 0824a07518..74647207bf 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -144,6 +144,108 @@ export const revokeUserAccessTokenMutation = { }`, }; +export const adminAllSharedLinksQuery = { + id: 'adminAllSharedLinksQuery' as const, + op: 'adminAllSharedLinks', + query: `query adminAllSharedLinks($pagination: PaginationInput!, $filter: AdminAllSharedLinksFilterInput) { + adminAllSharedLinks(pagination: $pagination, filter: $filter) { + totalCount + analyticsWindow { + from + to + timezone + bucket + requestedSize + effectiveSize + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + workspaceId + docId + title + publishedAt + docUpdatedAt + workspaceOwnerId + lastUpdaterId + shareUrl + views + uniqueViews + guestViews + lastAccessedAt + } + } + } +}`, +}; + +export const adminDashboardQuery = { + id: 'adminDashboardQuery' as const, + op: 'adminDashboard', + query: `query adminDashboard($input: AdminDashboardInput) { + adminDashboard(input: $input) { + syncActiveUsers + syncActiveUsersTimeline { + minute + activeUsers + } + syncWindow { + from + to + timezone + bucket + requestedSize + effectiveSize + } + copilotConversations + workspaceStorageBytes + blobStorageBytes + workspaceStorageHistory { + date + value + } + blobStorageHistory { + date + value + } + storageWindow { + from + to + timezone + bucket + requestedSize + effectiveSize + } + topSharedLinks { + workspaceId + docId + title + shareUrl + publishedAt + views + uniqueViews + guestViews + lastAccessedAt + } + topSharedLinksWindow { + from + to + timezone + bucket + requestedSize + effectiveSize + } + generatedAt + } +}`, +}; + export const adminServerConfigQuery = { id: 'adminServerConfigQuery' as const, op: 'adminServerConfig', @@ -1877,6 +1979,76 @@ export const getDocDefaultRoleQuery = { }`, }; +export const getDocLastAccessedMembersQuery = { + id: 'getDocLastAccessedMembersQuery' as const, + op: 'getDocLastAccessedMembers', + query: `query getDocLastAccessedMembers($workspaceId: String!, $docId: String!, $pagination: PaginationInput!, $query: String, $includeTotal: Boolean) { + workspace(id: $workspaceId) { + doc(docId: $docId) { + lastAccessedMembers( + pagination: $pagination + query: $query + includeTotal: $includeTotal + ) { + totalCount + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + user { + id + name + avatarUrl + } + lastAccessedAt + lastDocId + } + } + } + } + } +}`, +}; + +export const getDocPageAnalyticsQuery = { + id: 'getDocPageAnalyticsQuery' as const, + op: 'getDocPageAnalytics', + query: `query getDocPageAnalytics($workspaceId: String!, $docId: String!, $input: DocPageAnalyticsInput) { + workspace(id: $workspaceId) { + doc(docId: $docId) { + analytics(input: $input) { + window { + from + to + timezone + bucket + requestedSize + effectiveSize + } + series { + date + totalViews + uniqueViews + guestViews + } + summary { + totalViews + uniqueViews + guestViews + lastAccessedAt + } + generatedAt + } + } + } +}`, +}; + export const getDocSummaryQuery = { id: 'getDocSummaryQuery' as const, op: 'getDocSummary', diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 4b1f444607..f11bfbad85 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -66,6 +66,91 @@ export interface AddContextFileInput { contextId: Scalars['String']['input']; } +export interface AdminAllSharedLink { + __typename?: 'AdminAllSharedLink'; + docId: Scalars['String']['output']; + docUpdatedAt: Maybe; + guestViews: Maybe; + lastAccessedAt: Maybe; + lastUpdaterId: Maybe; + publishedAt: Maybe; + shareUrl: Scalars['String']['output']; + title: Maybe; + uniqueViews: Maybe; + views: Maybe; + workspaceId: Scalars['String']['output']; + workspaceOwnerId: Maybe; +} + +export interface AdminAllSharedLinkEdge { + __typename?: 'AdminAllSharedLinkEdge'; + cursor: Scalars['String']['output']; + node: AdminAllSharedLink; +} + +export interface AdminAllSharedLinksFilterInput { + analyticsWindowDays?: InputMaybe; + includeTotal?: InputMaybe; + keyword?: InputMaybe; + orderBy?: InputMaybe; + updatedAfter?: InputMaybe; + workspaceId?: InputMaybe; +} + +export interface AdminDashboard { + __typename?: 'AdminDashboard'; + blobStorageBytes: Scalars['SafeInt']['output']; + blobStorageHistory: Array; + copilotConversations: Scalars['SafeInt']['output']; + generatedAt: Scalars['DateTime']['output']; + storageWindow: TimeWindow; + syncActiveUsers: Scalars['Int']['output']; + syncActiveUsersTimeline: Array; + syncWindow: TimeWindow; + topSharedLinks: Array; + topSharedLinksWindow: TimeWindow; + workspaceStorageBytes: Scalars['SafeInt']['output']; + workspaceStorageHistory: Array; +} + +export interface AdminDashboardInput { + sharedLinkWindowDays?: InputMaybe; + storageHistoryDays?: InputMaybe; + syncHistoryHours?: InputMaybe; + timezone?: InputMaybe; +} + +export interface AdminDashboardMinutePoint { + __typename?: 'AdminDashboardMinutePoint'; + activeUsers: Scalars['Int']['output']; + minute: Scalars['DateTime']['output']; +} + +export interface AdminDashboardValueDayPoint { + __typename?: 'AdminDashboardValueDayPoint'; + date: Scalars['DateTime']['output']; + value: Scalars['SafeInt']['output']; +} + +export interface AdminSharedLinkTopItem { + __typename?: 'AdminSharedLinkTopItem'; + docId: Scalars['String']['output']; + guestViews: Scalars['SafeInt']['output']; + lastAccessedAt: Maybe; + publishedAt: Maybe; + shareUrl: Scalars['String']['output']; + title: Maybe; + uniqueViews: Scalars['SafeInt']['output']; + views: Scalars['SafeInt']['output']; + workspaceId: Scalars['String']['output']; +} + +export enum AdminSharedLinksOrder { + PublishedAtDesc = 'PublishedAtDesc', + UpdatedAtDesc = 'UpdatedAtDesc', + ViewsDesc = 'ViewsDesc', +} + export interface AdminUpdateWorkspaceInput { avatarKey?: InputMaybe; enableAi?: InputMaybe; @@ -851,6 +936,19 @@ export interface DocHistoryType { workspaceId: Scalars['String']['output']; } +export interface DocMemberLastAccess { + __typename?: 'DocMemberLastAccess'; + lastAccessedAt: Scalars['DateTime']['output']; + lastDocId: Maybe; + user: PublicUserType; +} + +export interface DocMemberLastAccessEdge { + __typename?: 'DocMemberLastAccessEdge'; + cursor: Scalars['String']['output']; + node: DocMemberLastAccess; +} + /** Doc mode */ export enum DocMode { edgeless = 'edgeless', @@ -863,6 +961,35 @@ export interface DocNotFoundDataType { spaceId: Scalars['String']['output']; } +export interface DocPageAnalytics { + __typename?: 'DocPageAnalytics'; + generatedAt: Scalars['DateTime']['output']; + series: Array; + summary: DocPageAnalyticsSummary; + window: TimeWindow; +} + +export interface DocPageAnalyticsInput { + timezone?: InputMaybe; + windowDays?: InputMaybe; +} + +export interface DocPageAnalyticsPoint { + __typename?: 'DocPageAnalyticsPoint'; + date: Scalars['DateTime']['output']; + guestViews: Scalars['SafeInt']['output']; + totalViews: Scalars['SafeInt']['output']; + uniqueViews: Scalars['SafeInt']['output']; +} + +export interface DocPageAnalyticsSummary { + __typename?: 'DocPageAnalyticsSummary'; + guestViews: Scalars['SafeInt']['output']; + lastAccessedAt: Maybe; + totalViews: Scalars['SafeInt']['output']; + uniqueViews: Scalars['SafeInt']['output']; +} + export interface DocPermissions { __typename?: 'DocPermissions'; Doc_Comments_Create: Scalars['Boolean']['output']; @@ -897,6 +1024,8 @@ export enum DocRole { export interface DocType { __typename?: 'DocType'; + /** Doc page analytics in a time window */ + analytics: DocPageAnalytics; createdAt: Maybe; /** Doc create user */ createdBy: Maybe; @@ -905,6 +1034,8 @@ export interface DocType { /** paginated doc granted users list */ grantedUsersList: PaginatedGrantedDocUserType; id: Scalars['String']['output']; + /** Paginated last accessed members of the current doc */ + lastAccessedMembers: PaginatedDocMemberLastAccess; /** Doc last updated user */ lastUpdatedBy: Maybe; lastUpdaterId: Maybe; @@ -919,10 +1050,20 @@ export interface DocType { workspaceId: Scalars['String']['output']; } +export interface DocTypeAnalyticsArgs { + input?: InputMaybe; +} + export interface DocTypeGrantedUsersListArgs { pagination: PaginationInput; } +export interface DocTypeLastAccessedMembersArgs { + includeTotal?: InputMaybe; + pagination: PaginationInput; + query?: InputMaybe; +} + export interface DocTypeEdge { __typename?: 'DocTypeEdge'; cursor: Scalars['String']['output']; @@ -2282,6 +2423,14 @@ export interface PageInfo { startCursor: Maybe; } +export interface PaginatedAdminAllSharedLink { + __typename?: 'PaginatedAdminAllSharedLink'; + analyticsWindow: TimeWindow; + edges: Array; + pageInfo: PageInfo; + totalCount: Maybe; +} + export interface PaginatedCommentChangeObjectType { __typename?: 'PaginatedCommentChangeObjectType'; edges: Array; @@ -2310,6 +2459,13 @@ export interface PaginatedCopilotWorkspaceFileType { totalCount: Scalars['Int']['output']; } +export interface PaginatedDocMemberLastAccess { + __typename?: 'PaginatedDocMemberLastAccess'; + edges: Array; + pageInfo: PageInfo; + totalCount: Maybe; +} + export interface PaginatedDocType { __typename?: 'PaginatedDocType'; edges: Array; @@ -2376,6 +2532,10 @@ export interface PublicUserType { export interface Query { __typename?: 'Query'; + /** List all shared links across workspaces for admin panel */ + adminAllSharedLinks: PaginatedAdminAllSharedLink; + /** Get aggregated dashboard metrics for admin panel */ + adminDashboard: AdminDashboard; /** Get workspace detail for admin */ adminWorkspace: Maybe; /** List workspaces for admin */ @@ -2428,6 +2588,15 @@ export interface Query { workspaces: Array; } +export interface QueryAdminAllSharedLinksArgs { + filter?: InputMaybe; + pagination: PaginationInput; +} + +export interface QueryAdminDashboardArgs { + input?: InputMaybe; +} + export interface QueryAdminWorkspaceArgs { id: Scalars['String']['input']; } @@ -2871,6 +3040,21 @@ export enum SubscriptionVariant { Onetime = 'Onetime', } +export enum TimeBucket { + Day = 'Day', + Minute = 'Minute', +} + +export interface TimeWindow { + __typename?: 'TimeWindow'; + bucket: TimeBucket; + effectiveSize: Scalars['Int']['output']; + from: Scalars['DateTime']['output']; + requestedSize: Scalars['Int']['output']; + timezone: Scalars['String']['output']; + to: Scalars['DateTime']['output']; +} + export interface TranscriptionItemType { __typename?: 'TranscriptionItemType'; end: Scalars['String']['output']; @@ -3409,6 +3593,124 @@ export type RevokeUserAccessTokenMutation = { revokeUserAccessToken: boolean; }; +export type AdminAllSharedLinksQueryVariables = Exact<{ + pagination: PaginationInput; + filter?: InputMaybe; +}>; + +export type AdminAllSharedLinksQuery = { + __typename?: 'Query'; + adminAllSharedLinks: { + __typename?: 'PaginatedAdminAllSharedLink'; + totalCount: number | null; + analyticsWindow: { + __typename?: 'TimeWindow'; + from: string; + to: string; + timezone: string; + bucket: TimeBucket; + requestedSize: number; + effectiveSize: number; + }; + pageInfo: { + __typename?: 'PageInfo'; + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; + endCursor: string | null; + }; + edges: Array<{ + __typename?: 'AdminAllSharedLinkEdge'; + cursor: string; + node: { + __typename?: 'AdminAllSharedLink'; + workspaceId: string; + docId: string; + title: string | null; + publishedAt: string | null; + docUpdatedAt: string | null; + workspaceOwnerId: string | null; + lastUpdaterId: string | null; + shareUrl: string; + views: number | null; + uniqueViews: number | null; + guestViews: number | null; + lastAccessedAt: string | null; + }; + }>; + }; +}; + +export type AdminDashboardQueryVariables = Exact<{ + input?: InputMaybe; +}>; + +export type AdminDashboardQuery = { + __typename?: 'Query'; + adminDashboard: { + __typename?: 'AdminDashboard'; + syncActiveUsers: number; + copilotConversations: number; + workspaceStorageBytes: number; + blobStorageBytes: number; + generatedAt: string; + syncActiveUsersTimeline: Array<{ + __typename?: 'AdminDashboardMinutePoint'; + minute: string; + activeUsers: number; + }>; + syncWindow: { + __typename?: 'TimeWindow'; + from: string; + to: string; + timezone: string; + bucket: TimeBucket; + requestedSize: number; + effectiveSize: number; + }; + workspaceStorageHistory: Array<{ + __typename?: 'AdminDashboardValueDayPoint'; + date: string; + value: number; + }>; + blobStorageHistory: Array<{ + __typename?: 'AdminDashboardValueDayPoint'; + date: string; + value: number; + }>; + storageWindow: { + __typename?: 'TimeWindow'; + from: string; + to: string; + timezone: string; + bucket: TimeBucket; + requestedSize: number; + effectiveSize: number; + }; + topSharedLinks: Array<{ + __typename?: 'AdminSharedLinkTopItem'; + workspaceId: string; + docId: string; + title: string | null; + shareUrl: string; + publishedAt: string | null; + views: number; + uniqueViews: number; + guestViews: number; + lastAccessedAt: string | null; + }>; + topSharedLinksWindow: { + __typename?: 'TimeWindow'; + from: string; + to: string; + timezone: string; + bucket: TimeBucket; + requestedSize: number; + effectiveSize: number; + }; + }; +}; + export type AdminServerConfigQueryVariables = Exact<{ [key: string]: never }>; export type AdminServerConfigQuery = { @@ -5916,6 +6218,93 @@ export type GetDocDefaultRoleQuery = { }; }; +export type GetDocLastAccessedMembersQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; + docId: Scalars['String']['input']; + pagination: PaginationInput; + query?: InputMaybe; + includeTotal?: InputMaybe; +}>; + +export type GetDocLastAccessedMembersQuery = { + __typename?: 'Query'; + workspace: { + __typename?: 'WorkspaceType'; + doc: { + __typename?: 'DocType'; + lastAccessedMembers: { + __typename?: 'PaginatedDocMemberLastAccess'; + totalCount: number | null; + pageInfo: { + __typename?: 'PageInfo'; + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; + endCursor: string | null; + }; + edges: Array<{ + __typename?: 'DocMemberLastAccessEdge'; + cursor: string; + node: { + __typename?: 'DocMemberLastAccess'; + lastAccessedAt: string; + lastDocId: string | null; + user: { + __typename?: 'PublicUserType'; + id: string; + name: string; + avatarUrl: string | null; + }; + }; + }>; + }; + }; + }; +}; + +export type GetDocPageAnalyticsQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; + docId: Scalars['String']['input']; + input?: InputMaybe; +}>; + +export type GetDocPageAnalyticsQuery = { + __typename?: 'Query'; + workspace: { + __typename?: 'WorkspaceType'; + doc: { + __typename?: 'DocType'; + analytics: { + __typename?: 'DocPageAnalytics'; + generatedAt: string; + window: { + __typename?: 'TimeWindow'; + from: string; + to: string; + timezone: string; + bucket: TimeBucket; + requestedSize: number; + effectiveSize: number; + }; + series: Array<{ + __typename?: 'DocPageAnalyticsPoint'; + date: string; + totalViews: number; + uniqueViews: number; + guestViews: number; + }>; + summary: { + __typename?: 'DocPageAnalyticsSummary'; + totalViews: number; + uniqueViews: number; + guestViews: number; + lastAccessedAt: string | null; + }; + }; + }; + }; +}; + export type GetDocSummaryQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; docId: Scalars['String']['input']; @@ -7199,6 +7588,16 @@ export type Queries = variables: ListUserAccessTokensQueryVariables; response: ListUserAccessTokensQuery; } + | { + name: 'adminAllSharedLinksQuery'; + variables: AdminAllSharedLinksQueryVariables; + response: AdminAllSharedLinksQuery; + } + | { + name: 'adminDashboardQuery'; + variables: AdminDashboardQueryVariables; + response: AdminDashboardQuery; + } | { name: 'adminServerConfigQuery'; variables: AdminServerConfigQueryVariables; @@ -7419,6 +7818,16 @@ export type Queries = variables: GetDocDefaultRoleQueryVariables; response: GetDocDefaultRoleQuery; } + | { + name: 'getDocLastAccessedMembersQuery'; + variables: GetDocLastAccessedMembersQueryVariables; + response: GetDocLastAccessedMembersQuery; + } + | { + name: 'getDocPageAnalyticsQuery'; + variables: GetDocPageAnalyticsQueryVariables; + response: GetDocPageAnalyticsQuery; + } | { name: 'getDocSummaryQuery'; variables: GetDocSummaryQueryVariables; diff --git a/packages/frontend/admin/package.json b/packages/frontend/admin/package.json index 8f3558f38a..0cf2bb693c 100644 --- a/packages/frontend/admin/package.json +++ b/packages/frontend/admin/package.json @@ -53,6 +53,7 @@ "react-hook-form": "^7.54.1", "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.12.0", + "recharts": "^2.15.4", "sonner": "^2.0.7", "swr": "^2.3.7", "vaul": "^1.1.2", diff --git a/packages/frontend/admin/src/app.tsx b/packages/frontend/admin/src/app.tsx index 5e7831ab49..c9d487f02e 100644 --- a/packages/frontend/admin/src/app.tsx +++ b/packages/frontend/admin/src/app.tsx @@ -23,6 +23,9 @@ export const Setup = lazy( export const Accounts = lazy( () => import(/* webpackChunkName: "accounts" */ './modules/accounts') ); +export const Dashboard = lazy( + () => import(/* webpackChunkName: "dashboard" */ './modules/dashboard') +); export const Workspaces = lazy( () => import(/* webpackChunkName: "workspaces" */ './modules/workspaces') ); @@ -75,7 +78,15 @@ function RootRoutes() { } if (/^\/admin\/?$/.test(location.pathname)) { - return ; + return ( + + ); } return ; @@ -96,6 +107,16 @@ export const App = () => { } /> } /> }> + + ) : ( + + ) + } + /> } /> >; + } +>; + +type ChartContextValue = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const value = React.useContext(ChartContext); + if (!value) { + throw new Error('useChart must be used within '); + } + return value; +} + +function ChartStyle({ + chartId, + config, +}: { + chartId: string; + config: ChartConfig; +}) { + const colorEntries = Object.entries(config).filter( + ([, item]) => item.color || item.theme + ); + + if (!colorEntries.length) { + return null; + } + + const css = Object.entries(THEMES) + .map(([themeKey, prefix]) => { + const declarations = colorEntries + .map(([key, item]) => { + const color = + item.theme?.[themeKey as keyof typeof THEMES] ?? item.color; + return color ? ` --color-${key}: ${color};` : ''; + }) + .filter(Boolean) + .join('\n'); + + if (!declarations) { + return ''; + } + + return `${prefix} [data-chart="${chartId}"] {\n${declarations}\n}`; + }) + .filter(Boolean) + .join('\n'); + + if (!css) { + return null; + } + + return