Compare commits

...

3 Commits

Author SHA1 Message Date
DarkSky
4460604dd3 fix: migration compatibility 2026-02-13 03:12:26 +08:00
DarkSky
b4be9118ad feat: doc status & share status (#14426)
#### PR Dependency Tree


* **PR #14426** 👈

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

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

* **New Features**
* Admin dashboard: view workspace analytics (storage, sync activity, top
shared links) with charts and configurable windows.
* Document analytics tab: see total/unique/guest views and trends over
selectable time windows.
* Last-accessed members: view who last accessed a document, with
pagination.
* Shared links analytics: browse and paginate all shared links with
view/unique/guest metrics and share URLs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-13 01:01:29 +08:00
Lakr
b46bf91575 fix(ios): add AI privacy consent alert (#14421)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added AI feature consent flow requiring user agreement before enabling
AI capabilities.
* Added calendar integration support including CalDAV account linking
and management.
* Expanded workspace administration capabilities with detailed workspace
analytics and configuration options.

* **Improvements**
  * Enhanced workspace sharing and configuration controls.
  * Added support for calendar provider presets and subscriptions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-12 18:25:18 +08:00
98 changed files with 7898 additions and 263 deletions

View File

@@ -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 IF EXISTS "user_features_feature_id_fkey";
ALTER TABLE
"workspace_features" DROP CONSTRAINT IF EXISTS "workspace_features_feature_id_fkey";
DROP INDEX IF EXISTS "user_features_feature_id_idx";
DROP INDEX IF EXISTS "workspace_features_feature_id_idx";
ALTER TABLE
"user_features" DROP COLUMN IF EXISTS "feature_id";
ALTER TABLE
"workspace_features" DROP COLUMN IF EXISTS "feature_id";
DROP TABLE IF EXISTS "features";

View File

@@ -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")
}

View File

@@ -0,0 +1,610 @@
import { PrismaClient } from '@prisma/client';
import { app, e2e, Mockers } from '../test';
async function gql(query: string, variables?: Record<string, unknown>) {
const res = await app.POST('/graphql').send({ query, variables }).expect(200);
return res.body as {
data?: Record<string, any>;
errors?: Array<{ message: string; extensions: Record<string, any> }>;
};
}
async function ensureAnalyticsTables(db: PrismaClient) {
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS workspace_admin_stats_daily (
workspace_id VARCHAR NOT NULL,
date DATE NOT NULL,
snapshot_size BIGINT NOT NULL DEFAULT 0,
blob_size BIGINT NOT NULL DEFAULT 0,
member_count BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
PRIMARY KEY (workspace_id, date)
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
minute_ts TIMESTAMPTZ(3) NOT NULL PRIMARY KEY,
active_users INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW()
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS workspace_doc_view_daily (
workspace_id VARCHAR NOT NULL,
doc_id VARCHAR NOT NULL,
date DATE NOT NULL,
total_views BIGINT NOT NULL DEFAULT 0,
unique_views BIGINT NOT NULL DEFAULT 0,
guest_views BIGINT NOT NULL DEFAULT 0,
last_accessed_at TIMESTAMPTZ(3),
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
PRIMARY KEY (workspace_id, doc_id, date)
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS workspace_member_last_access (
workspace_id VARCHAR NOT NULL,
user_id VARCHAR NOT NULL,
last_accessed_at TIMESTAMPTZ(3) NOT NULL,
last_doc_id VARCHAR,
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
PRIMARY KEY (workspace_id, user_id)
);
`);
}
async function createPublicDoc(input: {
workspaceId: string;
ownerId: string;
title: string;
updatedAt: Date;
publishedAt: Date;
}) {
const snapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: input.workspaceId,
user: { id: input.ownerId },
});
await app.create(Mockers.DocMeta, {
workspaceId: input.workspaceId,
docId: snapshot.id,
title: input.title,
public: true,
publishedAt: input.publishedAt,
});
const db = app.get(PrismaClient);
await db.snapshot.update({
where: {
workspaceId_id: {
workspaceId: input.workspaceId,
id: snapshot.id,
},
},
data: {
updatedAt: input.updatedAt,
updatedBy: input.ownerId,
},
});
return snapshot.id;
}
e2e(
'adminAllSharedLinks should support stable pagination and includeTotal',
async t => {
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
await app.login(admin);
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const newerDocId = await createPublicDoc({
workspaceId: workspace.id,
ownerId: owner.id,
title: 'newer-doc',
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
publishedAt: new Date('2026-02-11T10:00:00.000Z'),
});
const olderDocId = await createPublicDoc({
workspaceId: workspace.id,
ownerId: owner.id,
title: 'older-doc',
updatedAt: new Date('2026-02-10T10:00:00.000Z'),
publishedAt: new Date('2026-02-10T10:00:00.000Z'),
});
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
await db.$executeRaw`
INSERT INTO workspace_doc_view_daily (
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
)
VALUES
(${workspace.id}, ${newerDocId}, CURRENT_DATE, 10, 8, 2, NOW(), NOW()),
(${workspace.id}, ${olderDocId}, CURRENT_DATE, 5, 4, 1, NOW(), NOW())
ON CONFLICT (workspace_id, doc_id, date)
DO UPDATE SET
total_views = EXCLUDED.total_views,
unique_views = EXCLUDED.unique_views,
guest_views = EXCLUDED.guest_views,
last_accessed_at = EXCLUDED.last_accessed_at,
updated_at = EXCLUDED.updated_at
`;
const query = `
query AdminAllSharedLinks($pagination: PaginationInput!, $filter: AdminAllSharedLinksFilterInput) {
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
totalCount
analyticsWindow {
requestedSize
effectiveSize
}
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
workspaceId
docId
title
shareUrl
views
uniqueViews
guestViews
}
}
}
}
`;
const firstPage = await gql(query, {
pagination: { first: 1, offset: 0 },
filter: {
includeTotal: false,
orderBy: 'UpdatedAtDesc',
workspaceId: workspace.id,
},
});
t.falsy(firstPage.errors);
const first = firstPage.data!.adminAllSharedLinks;
t.is(first.totalCount, null);
t.true(first.pageInfo.hasNextPage);
t.is(first.edges.length, 1);
t.true([newerDocId, olderDocId].includes(first.edges[0].node.docId));
t.true(
first.edges[0].node.shareUrl.includes(`/workspace/${workspace.id}/`)
);
const secondPage = await gql(query, {
pagination: { first: 1, offset: 0, after: first.pageInfo.endCursor },
filter: {
includeTotal: true,
orderBy: 'UpdatedAtDesc',
workspaceId: workspace.id,
},
});
t.falsy(secondPage.errors);
const second = secondPage.data!.adminAllSharedLinks;
t.is(second.totalCount, 2);
t.is(second.edges.length, 1);
t.not(second.edges[0].node.docId, first.edges[0].node.docId);
const conflict = await gql(query, {
pagination: {
first: 1,
offset: 1,
after: first.pageInfo.endCursor,
},
filter: {
includeTotal: false,
orderBy: 'UpdatedAtDesc',
workspaceId: workspace.id,
},
});
t.truthy(conflict.errors?.length);
t.is(conflict.errors![0].extensions.name, 'BAD_REQUEST');
const malformedDateCursor = await gql(query, {
pagination: {
first: 1,
offset: 0,
after: JSON.stringify({
orderBy: 'UpdatedAtDesc',
sortValue: 'not-a-date',
workspaceId: workspace.id,
docId: newerDocId,
}),
},
filter: {
includeTotal: false,
orderBy: 'UpdatedAtDesc',
workspaceId: workspace.id,
},
});
t.truthy(malformedDateCursor.errors?.length);
t.is(malformedDateCursor.errors![0].extensions.name, 'BAD_REQUEST');
const malformedViewsCursor = await gql(query, {
pagination: {
first: 1,
offset: 0,
after: JSON.stringify({
orderBy: 'ViewsDesc',
sortValue: 'NaN',
workspaceId: workspace.id,
docId: newerDocId,
}),
},
filter: {
includeTotal: false,
orderBy: 'ViewsDesc',
workspaceId: workspace.id,
},
});
t.truthy(malformedViewsCursor.errors?.length);
t.is(malformedViewsCursor.errors![0].extensions.name, 'BAD_REQUEST');
}
);
e2e(
'adminDashboard should clamp window inputs and return expected buckets',
async t => {
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
await app.login(admin);
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const docId = await createPublicDoc({
workspaceId: workspace.id,
ownerId: owner.id,
title: 'dashboard-doc',
updatedAt: new Date(),
publishedAt: new Date(),
});
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
const minute = new Date();
minute.setSeconds(0, 0);
await db.$executeRaw`
INSERT INTO sync_active_users_minutely (minute_ts, active_users, updated_at)
VALUES (${minute}, 7, NOW())
ON CONFLICT (minute_ts)
DO UPDATE SET active_users = EXCLUDED.active_users, updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_admin_stats (
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, features, updated_at
)
VALUES (${workspace.id}, 1, 100, 1, 50, 1, 1, ARRAY[]::text[], NOW())
ON CONFLICT (workspace_id)
DO UPDATE SET
snapshot_count = EXCLUDED.snapshot_count,
snapshot_size = EXCLUDED.snapshot_size,
blob_count = EXCLUDED.blob_count,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
public_page_count = EXCLUDED.public_page_count,
features = EXCLUDED.features,
updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_admin_stats_daily (
workspace_id, date, snapshot_size, blob_size, member_count, updated_at
)
VALUES (${workspace.id}, CURRENT_DATE, 100, 50, 1, NOW())
ON CONFLICT (workspace_id, date)
DO UPDATE SET
snapshot_size = EXCLUDED.snapshot_size,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_doc_view_daily (
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
)
VALUES (${workspace.id}, ${docId}, CURRENT_DATE, 3, 2, 1, NOW(), NOW())
ON CONFLICT (workspace_id, doc_id, date)
DO UPDATE SET
total_views = EXCLUDED.total_views,
unique_views = EXCLUDED.unique_views,
guest_views = EXCLUDED.guest_views,
last_accessed_at = EXCLUDED.last_accessed_at,
updated_at = EXCLUDED.updated_at
`;
const dashboardQuery = `
query AdminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
syncWindow {
bucket
requestedSize
effectiveSize
}
storageWindow {
bucket
requestedSize
effectiveSize
}
topSharedLinksWindow {
bucket
requestedSize
effectiveSize
}
syncActiveUsersTimeline {
minute
activeUsers
}
workspaceStorageHistory {
date
value
}
}
}
`;
const result = await gql(dashboardQuery, {
input: {
storageHistoryDays: -10,
syncHistoryHours: -10,
sharedLinkWindowDays: -10,
},
});
t.falsy(result.errors);
const dashboard = result.data!.adminDashboard;
t.is(dashboard.syncWindow.bucket, 'Minute');
t.is(dashboard.syncWindow.effectiveSize, 1);
t.is(dashboard.storageWindow.bucket, 'Day');
t.is(dashboard.storageWindow.effectiveSize, 1);
t.is(dashboard.topSharedLinksWindow.effectiveSize, 1);
t.is(dashboard.syncActiveUsersTimeline.length, 1);
t.is(dashboard.workspaceStorageHistory.length, 1);
}
);
e2e(
'Doc analytics and lastAccessedMembers should enforce permissions and privacy',
async t => {
const owner = await app.signup();
const member = await app.create(Mockers.User);
const staleMember = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: staleMember.id,
});
const docId = await createPublicDoc({
workspaceId: workspace.id,
ownerId: owner.id,
title: 'page-analytics-doc',
updatedAt: new Date(),
publishedAt: new Date(),
});
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
await db.$executeRaw`
INSERT INTO workspace_doc_view_daily (
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
)
VALUES (${workspace.id}, ${docId}, CURRENT_DATE, 9, 5, 2, NOW(), NOW())
ON CONFLICT (workspace_id, doc_id, date)
DO UPDATE SET
total_views = EXCLUDED.total_views,
unique_views = EXCLUDED.unique_views,
guest_views = EXCLUDED.guest_views,
last_accessed_at = EXCLUDED.last_accessed_at,
updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_member_last_access (
workspace_id, user_id, last_accessed_at, last_doc_id, updated_at
)
VALUES
(${workspace.id}, ${owner.id}, NOW(), ${docId}, NOW()),
(${workspace.id}, ${member.id}, NOW() - interval '1 minute', ${docId}, NOW()),
(${workspace.id}, ${staleMember.id}, NOW() - interval '8 day', ${docId}, NOW())
ON CONFLICT (workspace_id, user_id)
DO UPDATE SET
last_accessed_at = EXCLUDED.last_accessed_at,
last_doc_id = EXCLUDED.last_doc_id,
updated_at = EXCLUDED.updated_at
`;
const analyticsQuery = `
query DocAnalytics($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
analytics(input: { windowDays: 999 }) {
window {
effectiveSize
}
series {
date
totalViews
}
summary {
totalViews
uniqueViews
guestViews
}
}
lastAccessedMembers(
pagination: { first: 100, offset: 0 }
includeTotal: true
) {
totalCount
edges {
node {
user {
id
name
avatarUrl
}
lastAccessedAt
lastDocId
}
}
}
}
}
}
`;
await app.login(owner);
const ownerResult = await gql(analyticsQuery, {
workspaceId: workspace.id,
docId,
});
t.falsy(ownerResult.errors);
t.is(ownerResult.data!.workspace.doc.analytics.window.effectiveSize, 7);
t.true(ownerResult.data!.workspace.doc.analytics.series.length > 0);
t.is(ownerResult.data!.workspace.doc.lastAccessedMembers.totalCount, 2);
t.is(ownerResult.data!.workspace.doc.lastAccessedMembers.edges.length, 2);
t.false(
ownerResult.data!.workspace.doc.lastAccessedMembers.edges.some(
(edge: { node: { user: { id: string } } }) =>
edge.node.user.id === staleMember.id
)
);
const malformedMembersCursor = await gql(
`
query DocMembersCursor($workspaceId: String!, $docId: String!, $after: String) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
lastAccessedMembers(
pagination: { first: 10, offset: 0, after: $after }
) {
edges {
node {
user {
id
}
}
}
}
}
}
}
`,
{
workspaceId: workspace.id,
docId,
after: JSON.stringify({
lastAccessedAt: 'not-a-date',
userId: owner.id,
}),
}
);
t.truthy(malformedMembersCursor.errors?.length);
t.is(malformedMembersCursor.errors![0].extensions.name, 'BAD_REQUEST');
const privacyQuery = `
query DocMembersPrivacy($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
lastAccessedMembers(pagination: { first: 10, offset: 0 }) {
edges {
node {
user {
id
email
}
}
}
}
}
}
}
`;
const privacyRes = await app
.POST('/graphql')
.send({
query: privacyQuery,
variables: {
workspaceId: workspace.id,
docId,
},
})
.expect(400);
const privacyResult = privacyRes.body as {
errors?: Array<{ message: string }>;
};
t.truthy(privacyResult.errors?.length);
t.true(
privacyResult.errors![0].message.includes(
'Cannot query field "email" on type "PublicUserType"'
)
);
await app.login(member);
const memberDeniedRes = await app
.POST('/graphql')
.send({
query: `
query DocMembersDenied($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
lastAccessedMembers(pagination: { first: 10, offset: 0 }) {
edges {
node {
user {
id
}
}
}
}
}
}
}
`,
variables: { workspaceId: workspace.id, docId },
})
.expect(200);
const memberDenied = memberDeniedRes.body as {
errors?: Array<{ extensions: Record<string, unknown> }>;
};
t.truthy(memberDenied.errors?.length);
t.is(memberDenied.errors![0].extensions.name, 'SPACE_ACCESS_DENIED');
}
);

View File

@@ -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)]);
}
});

View File

@@ -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;

View File

@@ -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),

View File

@@ -1,6 +1,8 @@
import { PipeTransform, Type } from '@nestjs/common';
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
import { BadRequest } from '../error';
@InputType()
export class PaginationInput {
/**
@@ -13,11 +15,15 @@ export class PaginationInput {
*/
static decode: PipeTransform<PaginationInput, PaginationInput> = {
transform: value => {
return {
const input = {
...value,
first: Math.min(Math.max(value?.first ?? 10, 1), 100),
offset: Math.max(value?.offset ?? 0, 0),
after: decode(value?.after),
// before: decode(value.before),
};
assertPaginationInput(input);
return input;
},
};
@@ -51,6 +57,18 @@ export class PaginationInput {
// before?: string | null;
}
export function assertPaginationInput(paginationInput?: PaginationInput) {
if (!paginationInput) {
return;
}
if (paginationInput.after && paginationInput.offset > 0) {
throw new BadRequest(
'pagination.after and pagination.offset cannot be used together'
);
}
}
const encode = (input: unknown) => {
let inputStr: string;
if (input instanceof Date) {
@@ -65,7 +83,7 @@ const encode = (input: unknown) => {
const decode = (base64String?: string | null) =>
base64String ? Buffer.from(base64String, 'base64').toString('utf-8') : null;
function encodeWithJson(input: unknown) {
export function encodeWithJson(input: unknown) {
return encode(JSON.stringify(input ?? null));
}

View File

@@ -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();
});

View File

@@ -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);
}

View File

@@ -1,4 +1,10 @@
import { applyDecorators, Logger, UseInterceptors } from '@nestjs/common';
import {
applyDecorators,
Logger,
OnModuleDestroy,
OnModuleInit,
UseInterceptors,
} from '@nestjs/common';
import {
ConnectedSocket,
MessageBody,
@@ -8,6 +14,7 @@ import {
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import type { Request } from 'express';
import { ClsInterceptor } from 'nestjs-cls';
import semver from 'semver';
import { type Server, Socket } from 'socket.io';
@@ -71,6 +78,7 @@ const DOC_UPDATES_PROTOCOL_026 = new semver.Range('>=0.26.0-0', {
});
type SyncProtocolRoomType = Extract<RoomType, 'sync-025' | 'sync-026'>;
const SOCKET_PRESENCE_USER_ID_KEY = 'affinePresenceUserId';
function normalizeWsClientVersion(clientVersion: string): string | null {
if (env.namespaces.canary) {
@@ -190,7 +198,11 @@ interface UpdateAwarenessMessage {
@WebSocketGateway()
@UseInterceptors(ClsInterceptor)
export class SpaceSyncGateway
implements OnGatewayConnection, OnGatewayDisconnect
implements
OnGatewayConnection,
OnGatewayDisconnect,
OnModuleInit,
OnModuleDestroy
{
protected logger = new Logger(SpaceSyncGateway.name);
@@ -198,6 +210,7 @@ export class SpaceSyncGateway
private readonly server!: Server;
private connectionCount = 0;
private flushTimer?: NodeJS.Timeout;
constructor(
private readonly ac: AccessController,
@@ -208,6 +221,22 @@ export class SpaceSyncGateway
private readonly models: Models
) {}
onModuleInit() {
this.flushTimer = setInterval(() => {
this.flushActiveUsersMinute().catch(error => {
this.logger.warn('Failed to flush active users minute', error as Error);
});
}, 60_000);
this.flushTimer.unref?.();
}
onModuleDestroy() {
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.flushTimer = undefined;
}
}
private encodeUpdates(updates: Uint8Array[]) {
return updates.map(update => Buffer.from(update).toString('base64'));
}
@@ -269,18 +298,95 @@ export class SpaceSyncGateway
setImmediate(() => client.disconnect());
}
handleConnection() {
handleConnection(client: Socket) {
this.connectionCount++;
this.logger.debug(`New connection, total: ${this.connectionCount}`);
metrics.socketio.gauge('connections').record(this.connectionCount);
this.attachPresenceUserId(client);
this.flushActiveUsersMinute().catch(error => {
this.logger.warn('Failed to flush active users minute', error as Error);
});
}
handleDisconnect() {
this.connectionCount--;
handleDisconnect(_client: Socket) {
this.connectionCount = Math.max(0, this.connectionCount - 1);
this.logger.debug(
`Connection disconnected, total: ${this.connectionCount}`
);
metrics.socketio.gauge('connections').record(this.connectionCount);
void this.flushActiveUsersMinute({
aggregateAcrossCluster: false,
}).catch(error => {
this.logger.warn('Failed to flush active users minute', error as Error);
});
}
private attachPresenceUserId(client: Socket) {
const request = client.request as Request;
const userId = request.session?.user.id ?? request.token?.user.id;
if (typeof userId !== 'string' || !userId) {
this.logger.warn(
`Unable to resolve authenticated user id for socket ${client.id}`
);
return;
}
client.data[SOCKET_PRESENCE_USER_ID_KEY] = userId;
}
private resolvePresenceUserId(socket: { data?: unknown }) {
if (!socket.data || typeof socket.data !== 'object') {
return null;
}
const userId = (socket.data as Record<string, unknown>)[
SOCKET_PRESENCE_USER_ID_KEY
];
return typeof userId === 'string' && userId ? userId : null;
}
private async flushActiveUsersMinute(options?: {
aggregateAcrossCluster?: boolean;
}) {
const minute = new Date();
minute.setSeconds(0, 0);
const aggregateAcrossCluster = options?.aggregateAcrossCluster ?? true;
let activeUsers = Math.max(0, this.connectionCount);
if (aggregateAcrossCluster) {
try {
const sockets = await this.server.fetchSockets();
const uniqueUsers = new Set<string>();
let missingUserCount = 0;
for (const socket of sockets) {
const userId = this.resolvePresenceUserId(socket);
if (userId) {
uniqueUsers.add(userId);
} else {
missingUserCount++;
}
}
if (missingUserCount > 0) {
activeUsers = sockets.length;
this.logger.warn(
`Unable to resolve user id for ${missingUserCount} active sockets, fallback to connection count`
);
} else {
activeUsers = uniqueUsers.size;
}
} catch (error) {
this.logger.warn(
'Failed to aggregate active users from sockets, using local value',
error as Error
);
}
}
await this.models.workspaceAnalytics.upsertSyncActiveUsersMinute(
minute,
activeUsers
);
}
@OnEvent('doc.updates.pushed')

View File

@@ -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(

View File

@@ -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',
})

View File

@@ -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;
}

View File

@@ -3,6 +3,7 @@ import {
Args,
Field,
InputType,
Int,
Mutation,
ObjectType,
Parent,
@@ -11,6 +12,7 @@ import {
Resolver,
} from '@nestjs/graphql';
import { PrismaClient } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars';
import {
Cache,
@@ -27,6 +29,7 @@ import {
PaginationInput,
registerObjectType,
} from '../../../base';
import { PageInfo } from '../../../base/graphql/pagination';
import { Models, PublicDocMode } from '../../../models';
import { CurrentUser } from '../../auth';
import { Editor } from '../../doc';
@@ -38,6 +41,7 @@ import {
} from '../../permission';
import { PublicUserType, WorkspaceUserType } from '../../user';
import { WorkspaceType } from '../types';
import { TimeBucket, TimeWindow } from './analytics-types';
import {
DotToUnderline,
mapPermissionsToGraphqlPermissions,
@@ -194,6 +198,93 @@ class WorkspaceDocMeta {
updatedBy!: EditorType | null;
}
@InputType()
class DocPageAnalyticsInput {
@Field(() => Int, { nullable: true, defaultValue: 28 })
windowDays?: number;
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
timezone?: string;
}
@ObjectType()
class DocPageAnalyticsPoint {
@Field(() => Date)
date!: Date;
@Field(() => SafeIntResolver)
totalViews!: number;
@Field(() => SafeIntResolver)
uniqueViews!: number;
@Field(() => SafeIntResolver)
guestViews!: number;
}
@ObjectType()
class DocPageAnalyticsSummary {
@Field(() => SafeIntResolver)
totalViews!: number;
@Field(() => SafeIntResolver)
uniqueViews!: number;
@Field(() => SafeIntResolver)
guestViews!: number;
@Field(() => Date, { nullable: true })
lastAccessedAt!: Date | null;
}
@ObjectType()
class DocPageAnalytics {
@Field(() => TimeWindow)
window!: TimeWindow;
@Field(() => [DocPageAnalyticsPoint])
series!: DocPageAnalyticsPoint[];
@Field(() => DocPageAnalyticsSummary)
summary!: DocPageAnalyticsSummary;
@Field(() => Date)
generatedAt!: Date;
}
@ObjectType()
class DocMemberLastAccess {
@Field(() => PublicUserType)
user!: PublicUserType;
@Field(() => Date)
lastAccessedAt!: Date;
@Field(() => String, { nullable: true })
lastDocId!: string | null;
}
@ObjectType()
class DocMemberLastAccessEdge {
@Field(() => String)
cursor!: string;
@Field(() => DocMemberLastAccess)
node!: DocMemberLastAccess;
}
@ObjectType()
class PaginatedDocMemberLastAccess {
@Field(() => [DocMemberLastAccessEdge])
edges!: DocMemberLastAccessEdge[];
@Field(() => PageInfo)
pageInfo!: PageInfo;
@Field(() => Int, { nullable: true })
totalCount?: number;
}
@Resolver(() => WorkspaceType)
export class WorkspaceDocResolver {
private readonly logger = new Logger(WorkspaceDocResolver.name);
@@ -464,6 +555,64 @@ export class DocResolver {
updatedBy: metadata.updatedByUser || null,
};
}
@ResolveField(() => DocPageAnalytics, {
description: 'Doc page analytics in a time window',
complexity: 5,
})
async analytics(
@CurrentUser() me: CurrentUser,
@Parent() doc: DocType,
@Args('input', { nullable: true, type: () => DocPageAnalyticsInput })
input?: DocPageAnalyticsInput
): Promise<DocPageAnalytics> {
await this.ac.user(me.id).doc(doc).assert('Doc.Read');
const analytics = await this.models.workspaceAnalytics.getDocPageAnalytics({
workspaceId: doc.workspaceId,
docId: doc.docId,
windowDays: input?.windowDays,
timezone: input?.timezone,
});
return {
...analytics,
window: {
...analytics.window,
bucket:
analytics.window.bucket === 'Minute'
? TimeBucket.Minute
: TimeBucket.Day,
},
};
}
@ResolveField(() => PaginatedDocMemberLastAccess, {
description: 'Paginated last accessed members of the current doc',
complexity: 5,
})
async lastAccessedMembers(
@CurrentUser() me: CurrentUser,
@Parent() doc: DocType,
@Args('pagination', PaginationInput.decode) pagination: PaginationInput,
@Args('query', { nullable: true }) query?: string,
@Args('includeTotal', { nullable: true, defaultValue: false })
includeTotal?: boolean
): Promise<PaginatedDocMemberLastAccess> {
await this.ac
.user(me.id)
.workspace(doc.workspaceId)
.assert('Workspace.Users.Manage');
return this.models.workspaceAnalytics.paginateDocLastAccessedMembers({
workspaceId: doc.workspaceId,
docId: doc.docId,
pagination,
query,
includeTotal: includeTotal ?? false,
});
}
@ResolveField(() => DocPermissions)
async permissions(
@CurrentUser() user: CurrentUser,

View File

@@ -124,6 +124,21 @@ export class WorkspaceStatsJob {
`Recalibrate admin stats for ${processed} workspace(s) (last sid ${lastSid})`
);
}
try {
const snapshotted = await this.withAdvisoryLock(async tx => {
await this.writeDailySnapshot(tx);
return true;
});
if (snapshotted) {
this.logger.debug('Wrote daily workspace admin stats snapshot');
}
} catch (error) {
this.logger.error(
'Failed to write daily workspace admin stats snapshot',
error as Error
);
}
}
private async withAdvisoryLock<T>(
@@ -304,4 +319,31 @@ export class WorkspaceStatsJob {
LIMIT ${limit}
`;
}
private async writeDailySnapshot(tx: Prisma.TransactionClient) {
await tx.$executeRaw`
INSERT INTO workspace_admin_stats_daily (
workspace_id,
date,
snapshot_size,
blob_size,
member_count,
updated_at
)
SELECT
workspace_id,
CURRENT_DATE,
snapshot_size,
blob_size,
member_count,
NOW()
FROM workspace_admin_stats
ON CONFLICT (workspace_id, date)
DO UPDATE SET
snapshot_size = EXCLUDED.snapshot_size,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
updated_at = EXCLUDED.updated_at
`;
}
}

View File

@@ -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';

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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!

View File

@@ -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
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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',

View File

@@ -66,6 +66,91 @@ export interface AddContextFileInput {
contextId: Scalars['String']['input'];
}
export interface AdminAllSharedLink {
__typename?: 'AdminAllSharedLink';
docId: Scalars['String']['output'];
docUpdatedAt: Maybe<Scalars['DateTime']['output']>;
guestViews: Maybe<Scalars['SafeInt']['output']>;
lastAccessedAt: Maybe<Scalars['DateTime']['output']>;
lastUpdaterId: Maybe<Scalars['String']['output']>;
publishedAt: Maybe<Scalars['DateTime']['output']>;
shareUrl: Scalars['String']['output'];
title: Maybe<Scalars['String']['output']>;
uniqueViews: Maybe<Scalars['SafeInt']['output']>;
views: Maybe<Scalars['SafeInt']['output']>;
workspaceId: Scalars['String']['output'];
workspaceOwnerId: Maybe<Scalars['String']['output']>;
}
export interface AdminAllSharedLinkEdge {
__typename?: 'AdminAllSharedLinkEdge';
cursor: Scalars['String']['output'];
node: AdminAllSharedLink;
}
export interface AdminAllSharedLinksFilterInput {
analyticsWindowDays?: InputMaybe<Scalars['Int']['input']>;
includeTotal?: InputMaybe<Scalars['Boolean']['input']>;
keyword?: InputMaybe<Scalars['String']['input']>;
orderBy?: InputMaybe<AdminSharedLinksOrder>;
updatedAfter?: InputMaybe<Scalars['DateTime']['input']>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
}
export interface AdminDashboard {
__typename?: 'AdminDashboard';
blobStorageBytes: Scalars['SafeInt']['output'];
blobStorageHistory: Array<AdminDashboardValueDayPoint>;
copilotConversations: Scalars['SafeInt']['output'];
generatedAt: Scalars['DateTime']['output'];
storageWindow: TimeWindow;
syncActiveUsers: Scalars['Int']['output'];
syncActiveUsersTimeline: Array<AdminDashboardMinutePoint>;
syncWindow: TimeWindow;
topSharedLinks: Array<AdminSharedLinkTopItem>;
topSharedLinksWindow: TimeWindow;
workspaceStorageBytes: Scalars['SafeInt']['output'];
workspaceStorageHistory: Array<AdminDashboardValueDayPoint>;
}
export interface AdminDashboardInput {
sharedLinkWindowDays?: InputMaybe<Scalars['Int']['input']>;
storageHistoryDays?: InputMaybe<Scalars['Int']['input']>;
syncHistoryHours?: InputMaybe<Scalars['Int']['input']>;
timezone?: InputMaybe<Scalars['String']['input']>;
}
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<Scalars['DateTime']['output']>;
publishedAt: Maybe<Scalars['DateTime']['output']>;
shareUrl: Scalars['String']['output'];
title: Maybe<Scalars['String']['output']>;
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<Scalars['String']['input']>;
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
@@ -851,6 +936,19 @@ export interface DocHistoryType {
workspaceId: Scalars['String']['output'];
}
export interface DocMemberLastAccess {
__typename?: 'DocMemberLastAccess';
lastAccessedAt: Scalars['DateTime']['output'];
lastDocId: Maybe<Scalars['String']['output']>;
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<DocPageAnalyticsPoint>;
summary: DocPageAnalyticsSummary;
window: TimeWindow;
}
export interface DocPageAnalyticsInput {
timezone?: InputMaybe<Scalars['String']['input']>;
windowDays?: InputMaybe<Scalars['Int']['input']>;
}
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<Scalars['DateTime']['output']>;
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<Scalars['DateTime']['output']>;
/** Doc create user */
createdBy: Maybe<PublicUserType>;
@@ -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<PublicUserType>;
lastUpdaterId: Maybe<Scalars['String']['output']>;
@@ -919,10 +1050,20 @@ export interface DocType {
workspaceId: Scalars['String']['output'];
}
export interface DocTypeAnalyticsArgs {
input?: InputMaybe<DocPageAnalyticsInput>;
}
export interface DocTypeGrantedUsersListArgs {
pagination: PaginationInput;
}
export interface DocTypeLastAccessedMembersArgs {
includeTotal?: InputMaybe<Scalars['Boolean']['input']>;
pagination: PaginationInput;
query?: InputMaybe<Scalars['String']['input']>;
}
export interface DocTypeEdge {
__typename?: 'DocTypeEdge';
cursor: Scalars['String']['output'];
@@ -2282,6 +2423,14 @@ export interface PageInfo {
startCursor: Maybe<Scalars['String']['output']>;
}
export interface PaginatedAdminAllSharedLink {
__typename?: 'PaginatedAdminAllSharedLink';
analyticsWindow: TimeWindow;
edges: Array<AdminAllSharedLinkEdge>;
pageInfo: PageInfo;
totalCount: Maybe<Scalars['Int']['output']>;
}
export interface PaginatedCommentChangeObjectType {
__typename?: 'PaginatedCommentChangeObjectType';
edges: Array<CommentChangeObjectTypeEdge>;
@@ -2310,6 +2459,13 @@ export interface PaginatedCopilotWorkspaceFileType {
totalCount: Scalars['Int']['output'];
}
export interface PaginatedDocMemberLastAccess {
__typename?: 'PaginatedDocMemberLastAccess';
edges: Array<DocMemberLastAccessEdge>;
pageInfo: PageInfo;
totalCount: Maybe<Scalars['Int']['output']>;
}
export interface PaginatedDocType {
__typename?: 'PaginatedDocType';
edges: Array<DocTypeEdge>;
@@ -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<AdminWorkspace>;
/** List workspaces for admin */
@@ -2428,6 +2588,15 @@ export interface Query {
workspaces: Array<WorkspaceType>;
}
export interface QueryAdminAllSharedLinksArgs {
filter?: InputMaybe<AdminAllSharedLinksFilterInput>;
pagination: PaginationInput;
}
export interface QueryAdminDashboardArgs {
input?: InputMaybe<AdminDashboardInput>;
}
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<AdminAllSharedLinksFilterInput>;
}>;
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<AdminDashboardInput>;
}>;
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<Scalars['String']['input']>;
includeTotal?: InputMaybe<Scalars['Boolean']['input']>;
}>;
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<DocPageAnalyticsInput>;
}>;
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;

View File

@@ -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",

View File

@@ -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 <Navigate to="/admin/accounts" />;
return (
<Navigate
to={
environment.isSelfHosted
? ROUTES.admin.accounts
: ROUTES.admin.dashboard
}
/>
);
}
return <Outlet />;
@@ -96,6 +107,16 @@ export const App = () => {
<Route path={ROUTES.admin.auth} element={<Auth />} />
<Route path={ROUTES.admin.setup} element={<Setup />} />
<Route element={<AuthenticatedRoutes />}>
<Route
path={ROUTES.admin.dashboard}
element={
environment.isSelfHosted ? (
<Navigate to={ROUTES.admin.accounts} replace />
) : (
<Dashboard />
)
}
/>
<Route path={ROUTES.admin.accounts} element={<Accounts />} />
<Route
path={ROUTES.admin.workspaces}

View File

@@ -0,0 +1,173 @@
import { cn } from '@affine/admin/utils';
import * as React from 'react';
import type { TooltipProps } from 'recharts';
import { ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
color?: string;
theme?: Partial<Record<keyof typeof THEMES, string>>;
}
>;
type ChartContextValue = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextValue | null>(null);
function useChart() {
const value = React.useContext(ChartContext);
if (!value) {
throw new Error('useChart must be used within <ChartContainer />');
}
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 <style dangerouslySetInnerHTML={{ __html: css }} />;
}
type ChartContainerProps = React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<typeof ResponsiveContainer>['children'];
};
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
ref={ref}
data-chart={chartId}
className={cn(
'flex min-h-0 w-full items-center justify-center text-xs',
className
)}
{...props}
>
<ChartStyle chartId={chartId} config={config} />
<ResponsiveContainer>{children}</ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
);
ChartContainer.displayName = 'ChartContainer';
const ChartTooltip = RechartsTooltip;
type TooltipContentProps = {
active?: boolean;
payload?: TooltipProps<number, string>['payload'];
label?: string | number;
labelFormatter?: (
label: string | number,
payload: TooltipProps<number, string>['payload']
) => React.ReactNode;
valueFormatter?: (value: number, key: string) => React.ReactNode;
};
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
TooltipContentProps
>(({ active, payload, label, labelFormatter, valueFormatter }, ref) => {
const { config } = useChart();
if (!active || !payload?.length) {
return null;
}
const title = labelFormatter ? labelFormatter(label ?? '', payload) : label;
return (
<div
ref={ref}
className="min-w-44 rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
>
{title ? (
<div className="mb-2 font-medium text-foreground/90">{title}</div>
) : null}
<div className="space-y-1">
{payload.map((item, index) => {
const dataKey = String(item.dataKey ?? item.name ?? index);
const itemConfig = config[dataKey];
const labelText = itemConfig?.label ?? item.name ?? dataKey;
const numericValue =
typeof item.value === 'number'
? item.value
: Number(item.value ?? 0);
const valueText = valueFormatter
? valueFormatter(numericValue, dataKey)
: numericValue;
const color = item.color ?? `var(--color-${dataKey})`;
return (
<div
key={`${dataKey}-${index}`}
className="flex items-center gap-2"
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<span className="text-muted-foreground">{labelText}</span>
<span className="ml-auto font-medium tabular-nums">
{valueText}
</span>
</div>
);
})}
</div>
</div>
);
});
ChartTooltipContent.displayName = 'ChartTooltipContent';
export { ChartContainer, ChartTooltip, ChartTooltipContent };

View File

@@ -0,0 +1,645 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@affine/admin/components/ui/card';
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@affine/admin/components/ui/chart';
import { Label } from '@affine/admin/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@affine/admin/components/ui/select';
import { Separator } from '@affine/admin/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@affine/admin/components/ui/table';
import { useQuery } from '@affine/admin/use-query';
import { adminDashboardQuery } from '@affine/graphql';
import { ROUTES } from '@affine/routes';
import {
DatabaseIcon,
MessageSquareTextIcon,
RefreshCwIcon,
UsersIcon,
} from 'lucide-react';
import { type ReactNode, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
import { Header } from '../header';
import { formatBytes } from '../workspaces/utils';
const intFormatter = new Intl.NumberFormat('en-US');
const compactFormatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
});
const utcDateTimeFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const utcDateFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
});
const STORAGE_DAY_OPTIONS = [7, 14, 30, 60, 90] as const;
const SYNC_HOUR_OPTIONS = [1, 6, 12, 24, 48, 72] as const;
const SHARED_DAY_OPTIONS = [7, 14, 28, 60, 90] as const;
type DualNumberPoint = {
label: string;
primary: number;
secondary: number;
};
type TrendPoint = {
x: number;
label: string;
primary: number;
secondary?: number;
};
function formatDateTime(value: string) {
return utcDateTimeFormatter.format(new Date(value));
}
function formatDate(value: string) {
return utcDateFormatter.format(new Date(value));
}
function downsample<T>(items: T[], maxPoints: number) {
if (items.length <= maxPoints) {
return items;
}
const step = Math.ceil(items.length / maxPoints);
return items.filter(
(_, index) => index % step === 0 || index === items.length - 1
);
}
function toIndexedTrendPoints<T extends Omit<TrendPoint, 'x'>>(points: T[]) {
return points.map((point, index) => ({
...point,
x: index,
}));
}
function TrendChart({
ariaLabel,
points,
primaryLabel,
primaryFormatter,
secondaryLabel,
secondaryFormatter,
}: {
ariaLabel: string;
points: TrendPoint[];
primaryLabel: string;
primaryFormatter: (value: number) => string;
secondaryLabel?: string;
secondaryFormatter?: (value: number) => string;
}) {
if (points.length === 0) {
return <div className="text-sm text-muted-foreground">No data</div>;
}
const chartPoints =
points.length === 1
? [points[0], { ...points[0], x: points[0].x + 1 }]
: points;
const hasSecondary =
Boolean(secondaryLabel) &&
chartPoints.some(point => typeof point.secondary === 'number');
const config: ChartConfig = {
primary: {
label: primaryLabel,
color: 'hsl(var(--primary))',
},
...(hasSecondary
? {
secondary: {
label: secondaryLabel,
color: 'hsl(var(--foreground) / 0.6)',
},
}
: {}),
};
return (
<div className="space-y-3">
<ChartContainer
config={config}
className="h-44 w-full"
aria-label={ariaLabel}
role="img"
>
<LineChart
data={chartPoints}
margin={{ top: 8, right: 0, bottom: 0, left: 0 }}
>
<CartesianGrid
vertical={false}
stroke="hsl(var(--border) / 0.6)"
strokeDasharray="3 4"
/>
<XAxis
dataKey="x"
type="number"
hide
allowDecimals={false}
domain={['dataMin', 'dataMax']}
/>
<YAxis
hide
domain={[
0,
(max: number) => {
if (max <= 0) {
return 1;
}
return Math.ceil(max * 1.1);
},
]}
/>
<ChartTooltip
cursor={{
stroke: 'hsl(var(--border))',
strokeDasharray: '4 4',
strokeWidth: 1,
}}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const item = payload?.[0];
return item?.payload?.label ?? '';
}}
valueFormatter={(value, key) => {
if (key === 'secondary') {
return secondaryFormatter
? secondaryFormatter(value)
: intFormatter.format(value);
}
return primaryFormatter(value);
}}
/>
}
/>
<Area
dataKey="primary"
type="monotone"
fill="var(--color-primary)"
fillOpacity={0.16}
stroke="none"
isAnimationActive={false}
/>
<Line
dataKey="primary"
type="monotone"
stroke="var(--color-primary)"
strokeWidth={3}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
{hasSecondary ? (
<Line
dataKey="secondary"
type="monotone"
stroke="var(--color-secondary)"
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
strokeDasharray="6 4"
connectNulls
isAnimationActive={false}
/>
) : null}
</LineChart>
</ChartContainer>
<div className="flex justify-between text-[11px] text-muted-foreground tabular-nums">
<span>{points[0]?.label}</span>
<span>{points[points.length - 1]?.label}</span>
</div>
</div>
);
}
function PrimaryMetricCard({
value,
description,
}: {
value: string;
description: string;
}) {
return (
<Card className="lg:col-span-5 border-primary/30 bg-gradient-to-br from-primary/10 via-card to-card shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2 text-foreground/75">
<UsersIcon className="h-4 w-4" aria-hidden="true" />
Current Sync Active Users
</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
<div className="text-4xl font-bold tracking-tight tabular-nums">
{value}
</div>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
function SecondaryMetricCard({
title,
value,
description,
icon,
}: {
title: string;
value: string;
description: string;
icon: ReactNode;
}) {
return (
<Card className="lg:col-span-3 border-border/70 bg-card/95 shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<span aria-hidden="true">{icon}</span>
{title}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold tracking-tight tabular-nums">
{value}
</div>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</CardContent>
</Card>
);
}
function WindowSelect({
id,
label,
value,
options,
unit,
onChange,
}: {
id: string;
label: string;
value: number;
options: readonly number[];
unit: string;
onChange: (value: number) => void;
}) {
return (
<div className="flex flex-col gap-2 min-w-40">
<Label
htmlFor={id}
className="text-xs uppercase tracking-wide text-muted-foreground"
>
{label}
</Label>
<Select
value={String(value)}
onValueChange={next => onChange(Number(next))}
>
<SelectTrigger id={id}>
<SelectValue placeholder={`Select ${label.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
{options.map(option => (
<SelectItem key={option} value={String(option)}>
{option} {unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export function DashboardPage() {
const [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
const variables = useMemo(
() => ({
input: {
storageHistoryDays,
syncHistoryHours,
sharedLinkWindowDays,
timezone: 'UTC',
},
}),
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
);
const { data, isValidating, mutate } = useQuery(
{
query: adminDashboardQuery,
variables,
},
{
keepPreviousData: true,
revalidateOnFocus: false,
revalidateIfStale: true,
revalidateOnReconnect: true,
}
);
const dashboard = data.adminDashboard;
const syncPoints = useMemo(
() =>
toIndexedTrendPoints(
downsample(
dashboard.syncActiveUsersTimeline.map(point => ({
label: formatDateTime(point.minute),
primary: point.activeUsers,
})),
96
)
),
[dashboard.syncActiveUsersTimeline]
);
const storagePoints = useMemo(() => {
const merged: DualNumberPoint[] = dashboard.workspaceStorageHistory.map(
(point, index) => ({
label: formatDate(point.date),
primary: point.value,
secondary: dashboard.blobStorageHistory[index]?.value ?? 0,
})
);
return toIndexedTrendPoints(downsample(merged, 60));
}, [dashboard.blobStorageHistory, dashboard.workspaceStorageHistory]);
const totalStorageBytes =
dashboard.workspaceStorageBytes + dashboard.blobStorageBytes;
return (
<div className="h-screen flex-1 flex-col flex overflow-hidden">
<Header
title="Dashboard"
endFix={
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="text-xs text-muted-foreground tabular-nums">
Updated at {formatDateTime(dashboard.generatedAt)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
mutate().catch(() => {});
}}
disabled={isValidating}
>
<RefreshCwIcon
className={`h-3.5 w-3.5 mr-1.5 ${isValidating ? 'animate-spin' : ''}`}
aria-hidden="true"
/>
Refresh
</Button>
</div>
}
/>
<div className="flex-1 overflow-auto p-6 space-y-6">
<Card className="border-primary/20 bg-gradient-to-r from-primary/5 via-card to-card shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base">Window Controls</CardTitle>
<CardDescription>
Tune dashboard windows. Data is sampled in UTC and refreshes
automatically.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 grid-cols-1 md:grid-cols-3 items-end">
<WindowSelect
id="storage-history-window"
label="Storage History"
value={storageHistoryDays}
options={STORAGE_DAY_OPTIONS}
unit="days"
onChange={setStorageHistoryDays}
/>
<WindowSelect
id="sync-history-window"
label="Sync History"
value={syncHistoryHours}
options={SYNC_HOUR_OPTIONS}
unit="hours"
onChange={setSyncHistoryHours}
/>
<WindowSelect
id="shared-link-window"
label="Shared Link Window"
value={sharedLinkWindowDays}
options={SHARED_DAY_OPTIONS}
unit="days"
onChange={setSharedLinkWindowDays}
/>
</CardContent>
</Card>
<div className="grid gap-5 grid-cols-1 lg:grid-cols-12">
<PrimaryMetricCard
value={intFormatter.format(dashboard.syncActiveUsers)}
description={`${dashboard.syncWindow.effectiveSize}h active window`}
/>
<SecondaryMetricCard
title="Copilot Conversations"
value={intFormatter.format(dashboard.copilotConversations)}
description={`${dashboard.topSharedLinksWindow.effectiveSize}d aggregation`}
icon={
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
}
/>
<Card className="lg:col-span-4 border-border/70 bg-gradient-to-br from-card via-card to-muted/15 shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
Managed Storage
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold tracking-tight tabular-nums">
{formatBytes(totalStorageBytes)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Workspace {formatBytes(dashboard.workspaceStorageBytes)} Blob{' '}
{formatBytes(dashboard.blobStorageBytes)}
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
<Card className="xl:col-span-1 border-border/70 bg-card/95 shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Sync Active Users Trend
</CardTitle>
<CardDescription>
{dashboard.syncWindow.effectiveSize}h at minute bucket
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<TrendChart
ariaLabel="Sync active users trend"
points={syncPoints}
primaryLabel="Sync Active Users"
primaryFormatter={value => intFormatter.format(value)}
/>
</CardContent>
</Card>
<Card className="xl:col-span-2 border-border/70 bg-gradient-to-br from-primary/5 via-card to-card shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Storage Trend (Workspace + Blob)
</CardTitle>
<CardDescription>
{dashboard.storageWindow.effectiveSize}d at day bucket
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<TrendChart
ariaLabel="Workspace and blob storage trend"
points={storagePoints}
primaryLabel="Workspace Storage"
primaryFormatter={value => formatBytes(value)}
secondaryLabel="Blob Storage"
secondaryFormatter={value => formatBytes(value)}
/>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-primary" />
Workspace: {formatBytes(dashboard.workspaceStorageBytes)}
</div>
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-foreground/50" />
Blob: {formatBytes(dashboard.blobStorageBytes)}
</div>
</div>
</CardContent>
</Card>
</div>
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Top Shared Links</CardTitle>
<CardDescription>
Top {dashboard.topSharedLinks.length} links in the last{' '}
{dashboard.topSharedLinksWindow.effectiveSize} days
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{dashboard.topSharedLinks.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center bg-muted/20">
<div className="text-sm font-medium">
No shared links in this window
</div>
<div className="text-xs text-muted-foreground mt-2">
Publish pages and collect traffic, then this table will rank
links by views.
</div>
<Button asChild variant="outline" size="sm" className="mt-4">
<Link to={ROUTES.admin.workspaces}>Go to Workspaces</Link>
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Document</TableHead>
<TableHead>Workspace</TableHead>
<TableHead className="text-right">Views</TableHead>
<TableHead className="text-right">Unique</TableHead>
<TableHead className="text-right">Guest</TableHead>
<TableHead>Last Accessed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboard.topSharedLinks.map(link => (
<TableRow
key={`${link.workspaceId}-${link.docId}`}
className="hover:bg-muted/40"
>
<TableCell className="max-w-80 min-w-0">
<a
href={link.shareUrl}
target="_blank"
rel="noreferrer"
className="font-medium underline-offset-4 hover:underline truncate block"
>
{link.title || link.docId}
</a>
</TableCell>
<TableCell className="font-mono text-xs tabular-nums">
{link.workspaceId}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.views)}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.uniqueViews)}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.guestViews)}
</TableCell>
<TableCell className="tabular-nums">
{link.lastAccessedAt
? formatDateTime(link.lastAccessedAt)
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Separator />
<div className="flex justify-between text-xs text-muted-foreground tabular-nums">
<span>{formatDate(dashboard.topSharedLinksWindow.from)}</span>
<span>{formatDate(dashboard.topSharedLinksWindow.to)}</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
export { DashboardPage as Component };

View File

@@ -1,8 +1,13 @@
import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils';
import { ROUTES } from '@affine/routes';
import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import { LayoutDashboardIcon, ListChecksIcon } from 'lucide-react';
import {
BarChart3Icon,
LayoutDashboardIcon,
ListChecksIcon,
} from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { ServerVersion } from './server-version';
@@ -85,22 +90,30 @@ export function Nav({ isCollapsed = false }: NavProps) {
isCollapsed && 'items-center px-0 gap-1 overflow-visible'
)}
>
{environment.isSelfHosted ? null : (
<NavItem
to={ROUTES.admin.dashboard}
icon={<BarChart3Icon size={18} />}
label="Dashboard"
isCollapsed={isCollapsed}
/>
)}
<NavItem
to="/admin/accounts"
to={ROUTES.admin.accounts}
icon={<AccountIcon fontSize={20} />}
label="Accounts"
isCollapsed={isCollapsed}
/>
{environment.isSelfHosted ? null : (
<NavItem
to="/admin/workspaces"
to={ROUTES.admin.workspaces}
icon={<LayoutDashboardIcon size={18} />}
label="Workspaces"
isCollapsed={isCollapsed}
/>
)}
<NavItem
to="/admin/queue"
to={ROUTES.admin.queue}
icon={<ListChecksIcon size={18} />}
label="Queue"
isCollapsed={isCollapsed}
@@ -113,7 +126,7 @@ export function Nav({ isCollapsed = false }: NavProps) {
/> */}
<SettingsItem isCollapsed={isCollapsed} />
<NavItem
to="/admin/about"
to={ROUTES.admin.about}
icon={<SelfhostIcon fontSize={20} />}
label="About"
isCollapsed={isCollapsed}

View File

@@ -18,6 +18,15 @@
"version" : "0.1.5"
}
},
{
"identity" : "highlightr",
"kind" : "remoteSourceControl",
"location" : "https://github.com/raspu/Highlightr",
"state" : {
"revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef",
"version" : "2.3.0"
}
},
{
"identity" : "listviewkit",
"kind" : "remoteSourceControl",
@@ -27,13 +36,22 @@
"version" : "1.1.8"
}
},
{
"identity" : "litext",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Litext",
"state" : {
"revision" : "c7e83f2f580ce34a102ca9ba9d2bb24e507dccd9",
"version" : "0.5.6"
}
},
{
"identity" : "lrucache",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/LRUCache",
"state" : {
"revision" : "542f0449556327415409ededc9c43a4bd0a397dc",
"version" : "1.0.7"
"revision" : "cb5b2bd0da83ad29c0bec762d39f41c8ad0eaf3e",
"version" : "1.2.1"
}
},
{
@@ -41,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/MarkdownView",
"state" : {
"revision" : "20fa808889944921e8da3a1c8317e8a557db373e",
"version" : "3.4.7"
"revision" : "8b8c1eecd251051c5ec2bdd5f31a2243efd9be6c",
"version" : "3.6.2"
}
},
{
@@ -59,8 +77,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/RevenueCat/purchases-ios-spm.git",
"state" : {
"revision" : "6676da5c4c6a61e53b3139216a775d1224bf056e",
"version" : "5.56.1"
"revision" : "8f5df97653eb361a2097119479332afccf0aa816",
"version" : "5.58.0"
}
},
{
@@ -72,15 +90,6 @@
"version" : "5.7.1"
}
},
{
"identity" : "splash",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Splash",
"state" : {
"revision" : "de9cde249fdb7a173a6e6b950ab18b11f6c2a557",
"version" : "0.18.0"
}
},
{
"identity" : "springinterpolation",
"kind" : "remoteSourceControl",
@@ -95,8 +104,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "b022b08312decdc46585e0b3440d97f6f22ef703",
"version" : "0.6.0"
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
@@ -120,10 +129,10 @@
{
"identity" : "swiftmath",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/SwiftMath",
"location" : "https://github.com/mgriebling/SwiftMath",
"state" : {
"revision" : "cfd646dcac0c5553e21ebf1ee05f9078277518bc",
"version" : "1.7.2"
"revision" : "fa8244ed032f4a1ade4cb0571bf87d2f1a9fd2d7",
"version" : "1.7.3"
}
}
],

View File

@@ -9,9 +9,36 @@ import Intelligents
import UIKit
extension AFFiNEViewController: IntelligentsButtonDelegate {
private static let aiConsentKey = "com.affine.intelligents.userConsented"
private var hasUserConsented: Bool {
UserDefaults.standard.bool(forKey: Self.aiConsentKey)
}
func onIntelligentsButtonTapped(_: IntelligentsButton) {
// if it shows up then we are ready to go
if hasUserConsented {
presentIntelligentsController()
return
}
showAIConsentAlert()
}
private func presentIntelligentsController() {
let controller = IntelligentsController()
present(controller, animated: true)
}
private func showAIConsentAlert() {
let alert = UIAlertController(
title: "AI Feature Data Usage",
message: "To provide AI-powered features, your input (such as document content and conversation messages) will be sent to a third-party AI service for processing. This data is used solely to generate responses and is not used for any other purpose.\n\nBy continuing, you agree to share this data with the AI service.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Agree & Continue", style: .default) { [weak self] _ in
UserDefaults.standard.set(true, forKey: Self.aiConsentKey)
self?.presentIntelligentsController()
})
present(alert, animated: true)
}
}

View File

@@ -0,0 +1,170 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public struct CurrentUserProfile: AffineGraphQL.SelectionSet, Fragment {
public static var fragmentDefinition: StaticString {
#"fragment CurrentUserProfile on UserType { __typename id name email avatarUrl emailVerified features settings { __typename receiveInvitationEmail receiveMentionEmail receiveCommentEmail } quota { __typename name blobLimit storageQuota historyPeriod memberLimit humanReadable { __typename name blobLimit storageQuota historyPeriod memberLimit } } quotaUsage { __typename storageQuota } copilot { __typename quota { __typename limit used } } }"#
}
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", AffineGraphQL.ID.self),
.field("name", String.self),
.field("email", String.self),
.field("avatarUrl", String?.self),
.field("emailVerified", Bool.self),
.field("features", [GraphQLEnum<AffineGraphQL.FeatureType>].self),
.field("settings", Settings.self),
.field("quota", Quota.self),
.field("quotaUsage", QuotaUsage.self),
.field("copilot", Copilot.self),
] }
public var id: AffineGraphQL.ID { __data["id"] }
/// User name
public var name: String { __data["name"] }
/// User email
public var email: String { __data["email"] }
/// User avatar url
public var avatarUrl: String? { __data["avatarUrl"] }
/// User email verified
public var emailVerified: Bool { __data["emailVerified"] }
/// Enabled features of a user
public var features: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["features"] }
/// Get user settings
public var settings: Settings { __data["settings"] }
public var quota: Quota { __data["quota"] }
public var quotaUsage: QuotaUsage { __data["quotaUsage"] }
public var copilot: Copilot { __data["copilot"] }
/// Settings
///
/// Parent Type: `UserSettingsType`
public struct Settings: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserSettingsType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("receiveInvitationEmail", Bool.self),
.field("receiveMentionEmail", Bool.self),
.field("receiveCommentEmail", Bool.self),
] }
/// Receive invitation email
public var receiveInvitationEmail: Bool { __data["receiveInvitationEmail"] }
/// Receive mention email
public var receiveMentionEmail: Bool { __data["receiveMentionEmail"] }
/// Receive comment email
public var receiveCommentEmail: Bool { __data["receiveCommentEmail"] }
}
/// Quota
///
/// Parent Type: `UserQuotaType`
public struct Quota: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserQuotaType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("name", String.self),
.field("blobLimit", AffineGraphQL.SafeInt.self),
.field("storageQuota", AffineGraphQL.SafeInt.self),
.field("historyPeriod", AffineGraphQL.SafeInt.self),
.field("memberLimit", Int.self),
.field("humanReadable", HumanReadable.self),
] }
public var name: String { __data["name"] }
public var blobLimit: AffineGraphQL.SafeInt { __data["blobLimit"] }
public var storageQuota: AffineGraphQL.SafeInt { __data["storageQuota"] }
public var historyPeriod: AffineGraphQL.SafeInt { __data["historyPeriod"] }
public var memberLimit: Int { __data["memberLimit"] }
public var humanReadable: HumanReadable { __data["humanReadable"] }
/// Quota.HumanReadable
///
/// Parent Type: `UserQuotaHumanReadableType`
public struct HumanReadable: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserQuotaHumanReadableType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("name", String.self),
.field("blobLimit", String.self),
.field("storageQuota", String.self),
.field("historyPeriod", String.self),
.field("memberLimit", String.self),
] }
public var name: String { __data["name"] }
public var blobLimit: String { __data["blobLimit"] }
public var storageQuota: String { __data["storageQuota"] }
public var historyPeriod: String { __data["historyPeriod"] }
public var memberLimit: String { __data["memberLimit"] }
}
}
/// QuotaUsage
///
/// Parent Type: `UserQuotaUsageType`
public struct QuotaUsage: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserQuotaUsageType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("storageQuota", AffineGraphQL.SafeInt.self),
] }
@available(*, deprecated, message: "use `UserQuotaType[\'usedStorageQuota\']` instead")
public var storageQuota: AffineGraphQL.SafeInt { __data["storageQuota"] }
}
/// Copilot
///
/// Parent Type: `Copilot`
public struct Copilot: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Copilot }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("quota", Quota.self),
] }
/// Get the quota of the user in the workspace
public var quota: Quota { __data["quota"] }
/// Copilot.Quota
///
/// Parent Type: `CopilotQuota`
public struct Quota: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotQuota }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("limit", AffineGraphQL.SafeInt?.self),
.field("used", AffineGraphQL.SafeInt.self),
] }
public var limit: AffineGraphQL.SafeInt? { __data["limit"] }
public var used: AffineGraphQL.SafeInt { __data["used"] }
}
}
}

View File

@@ -0,0 +1,103 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class AdminUpdateWorkspaceMutation: GraphQLMutation {
public static let operationName: String = "adminUpdateWorkspace"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation adminUpdateWorkspace($input: AdminUpdateWorkspaceInput!) { adminUpdateWorkspace(input: $input) { __typename id public createdAt name avatarKey enableAi enableSharing enableUrlPreview enableDocEmbedding features owner { __typename id name email avatarUrl } memberCount publicPageCount snapshotCount snapshotSize blobCount blobSize } }"#
))
public var input: AdminUpdateWorkspaceInput
public init(input: AdminUpdateWorkspaceInput) {
self.input = input
}
public var __variables: Variables? { ["input": input] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("adminUpdateWorkspace", AdminUpdateWorkspace?.self, arguments: ["input": .variable("input")]),
] }
/// Update workspace flags and features for admin
public var adminUpdateWorkspace: AdminUpdateWorkspace? { __data["adminUpdateWorkspace"] }
/// AdminUpdateWorkspace
///
/// Parent Type: `AdminWorkspace`
public struct AdminUpdateWorkspace: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AdminWorkspace }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("public", Bool.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("name", String?.self),
.field("avatarKey", String?.self),
.field("enableAi", Bool.self),
.field("enableSharing", Bool.self),
.field("enableUrlPreview", Bool.self),
.field("enableDocEmbedding", Bool.self),
.field("features", [GraphQLEnum<AffineGraphQL.FeatureType>].self),
.field("owner", Owner?.self),
.field("memberCount", Int.self),
.field("publicPageCount", Int.self),
.field("snapshotCount", Int.self),
.field("snapshotSize", AffineGraphQL.SafeInt.self),
.field("blobCount", Int.self),
.field("blobSize", AffineGraphQL.SafeInt.self),
] }
public var id: String { __data["id"] }
public var `public`: Bool { __data["public"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var name: String? { __data["name"] }
public var avatarKey: String? { __data["avatarKey"] }
public var enableAi: Bool { __data["enableAi"] }
public var enableSharing: Bool { __data["enableSharing"] }
public var enableUrlPreview: Bool { __data["enableUrlPreview"] }
public var enableDocEmbedding: Bool { __data["enableDocEmbedding"] }
public var features: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["features"] }
public var owner: Owner? { __data["owner"] }
public var memberCount: Int { __data["memberCount"] }
public var publicPageCount: Int { __data["publicPageCount"] }
public var snapshotCount: Int { __data["snapshotCount"] }
public var snapshotSize: AffineGraphQL.SafeInt { __data["snapshotSize"] }
public var blobCount: Int { __data["blobCount"] }
public var blobSize: AffineGraphQL.SafeInt { __data["blobSize"] }
/// AdminUpdateWorkspace.Owner
///
/// Parent Type: `WorkspaceUserType`
public struct Owner: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceUserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("name", String.self),
.field("email", String.self),
.field("avatarUrl", String?.self),
] }
public var id: String { __data["id"] }
public var name: String { __data["name"] }
public var email: String { __data["email"] }
public var avatarUrl: String? { __data["avatarUrl"] }
}
}
}
}

View File

@@ -3,11 +3,11 @@
@_exported import ApolloAPI
public class ApplyDocUpdatesQuery: GraphQLQuery {
public class ApplyDocUpdatesMutation: GraphQLMutation {
public static let operationName: String = "applyDocUpdates"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query applyDocUpdates($workspaceId: String!, $docId: String!, $op: String!, $updates: String!) { applyDocUpdates( workspaceId: $workspaceId docId: $docId op: $op updates: $updates ) }"#
#"mutation applyDocUpdates($workspaceId: String!, $docId: String!, $op: String!, $updates: String!) { applyDocUpdates( workspaceId: $workspaceId docId: $docId op: $op updates: $updates ) }"#
))
public var workspaceId: String
@@ -38,7 +38,7 @@ public class ApplyDocUpdatesQuery: GraphQLQuery {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("applyDocUpdates", String.self, arguments: [
"workspaceId": .variable("workspaceId"),

View File

@@ -1,73 +0,0 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class GetBlobUploadPartUrlMutation: GraphQLMutation {
public static let operationName: String = "getBlobUploadPartUrl"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation getBlobUploadPartUrl($workspaceId: String!, $key: String!, $uploadId: String!, $partNumber: Int!) { getBlobUploadPartUrl( workspaceId: $workspaceId key: $key uploadId: $uploadId partNumber: $partNumber ) { __typename uploadUrl headers expiresAt } }"#
))
public var workspaceId: String
public var key: String
public var uploadId: String
public var partNumber: Int
public init(
workspaceId: String,
key: String,
uploadId: String,
partNumber: Int
) {
self.workspaceId = workspaceId
self.key = key
self.uploadId = uploadId
self.partNumber = partNumber
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"key": key,
"uploadId": uploadId,
"partNumber": partNumber
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("getBlobUploadPartUrl", GetBlobUploadPartUrl.self, arguments: [
"workspaceId": .variable("workspaceId"),
"key": .variable("key"),
"uploadId": .variable("uploadId"),
"partNumber": .variable("partNumber")
]),
] }
public var getBlobUploadPartUrl: GetBlobUploadPartUrl { __data["getBlobUploadPartUrl"] }
/// GetBlobUploadPartUrl
///
/// Parent Type: `BlobUploadPart`
public struct GetBlobUploadPartUrl: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.BlobUploadPart }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("uploadUrl", String.self),
.field("headers", AffineGraphQL.JSONObject?.self),
.field("expiresAt", AffineGraphQL.DateTime?.self),
] }
public var uploadUrl: String { __data["uploadUrl"] }
public var headers: AffineGraphQL.JSONObject? { __data["headers"] }
public var expiresAt: AffineGraphQL.DateTime? { __data["expiresAt"] }
}
}
}

View File

@@ -0,0 +1,68 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class LinkCalDavAccountMutation: GraphQLMutation {
public static let operationName: String = "linkCalDavAccount"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation linkCalDavAccount($input: LinkCalDAVAccountInput!) { linkCalDAVAccount(input: $input) { __typename id provider providerAccountId displayName email status lastError refreshIntervalMinutes calendarsCount createdAt updatedAt } }"#
))
public var input: LinkCalDAVAccountInput
public init(input: LinkCalDAVAccountInput) {
self.input = input
}
public var __variables: Variables? { ["input": input] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("linkCalDAVAccount", LinkCalDAVAccount.self, arguments: ["input": .variable("input")]),
] }
public var linkCalDAVAccount: LinkCalDAVAccount { __data["linkCalDAVAccount"] }
/// LinkCalDAVAccount
///
/// Parent Type: `CalendarAccountObjectType`
public struct LinkCalDAVAccount: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CalendarAccountObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("provider", GraphQLEnum<AffineGraphQL.CalendarProviderType>.self),
.field("providerAccountId", String.self),
.field("displayName", String?.self),
.field("email", String?.self),
.field("status", String.self),
.field("lastError", String?.self),
.field("refreshIntervalMinutes", Int.self),
.field("calendarsCount", Int.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("updatedAt", AffineGraphQL.DateTime.self),
] }
public var id: String { __data["id"] }
public var provider: GraphQLEnum<AffineGraphQL.CalendarProviderType> { __data["provider"] }
public var providerAccountId: String { __data["providerAccountId"] }
public var displayName: String? { __data["displayName"] }
public var email: String? { __data["email"] }
public var status: String { __data["status"] }
public var lastError: String? { __data["lastError"] }
public var refreshIntervalMinutes: Int { __data["refreshIntervalMinutes"] }
public var calendarsCount: Int { __data["calendarsCount"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
}
}
}

View File

@@ -0,0 +1,32 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class LinkCalendarAccountMutation: GraphQLMutation {
public static let operationName: String = "linkCalendarAccount"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation linkCalendarAccount($input: LinkCalendarAccountInput!) { linkCalendarAccount(input: $input) }"#
))
public var input: LinkCalendarAccountInput
public init(input: LinkCalendarAccountInput) {
self.input = input
}
public var __variables: Variables? { ["input": input] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("linkCalendarAccount", String.self, arguments: ["input": .variable("input")]),
] }
public var linkCalendarAccount: String { __data["linkCalendarAccount"] }
}
}

View File

@@ -0,0 +1,60 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class SetEnableSharingMutation: GraphQLMutation {
public static let operationName: String = "setEnableSharing"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation setEnableSharing($id: ID!, $enableSharing: Boolean!) { updateWorkspace(input: { id: $id, enableSharing: $enableSharing }) { __typename id } }"#
))
public var id: ID
public var enableSharing: Bool
public init(
id: ID,
enableSharing: Bool
) {
self.id = id
self.enableSharing = enableSharing
}
public var __variables: Variables? { [
"id": id,
"enableSharing": enableSharing
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("updateWorkspace", UpdateWorkspace.self, arguments: ["input": [
"id": .variable("id"),
"enableSharing": .variable("enableSharing")
]]),
] }
/// Update workspace
public var updateWorkspace: UpdateWorkspace { __data["updateWorkspace"] }
/// UpdateWorkspace
///
/// Parent Type: `WorkspaceType`
public struct UpdateWorkspace: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", AffineGraphQL.ID.self),
] }
public var id: AffineGraphQL.ID { __data["id"] }
}
}
}

View File

@@ -0,0 +1,32 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class UnlinkCalendarAccountMutation: GraphQLMutation {
public static let operationName: String = "unlinkCalendarAccount"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation unlinkCalendarAccount($accountId: String!) { unlinkCalendarAccount(accountId: $accountId) }"#
))
public var accountId: String
public init(accountId: String) {
self.accountId = accountId
}
public var __variables: Variables? { ["accountId": accountId] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("unlinkCalendarAccount", Bool.self, arguments: ["accountId": .variable("accountId")]),
] }
public var unlinkCalendarAccount: Bool { __data["unlinkCalendarAccount"] }
}
}

View File

@@ -0,0 +1,79 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class UpdateCalendarAccountMutation: GraphQLMutation {
public static let operationName: String = "updateCalendarAccount"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation updateCalendarAccount($accountId: String!, $refreshIntervalMinutes: Int!) { updateCalendarAccount( accountId: $accountId refreshIntervalMinutes: $refreshIntervalMinutes ) { __typename id provider providerAccountId displayName email status lastError refreshIntervalMinutes calendarsCount createdAt updatedAt } }"#
))
public var accountId: String
public var refreshIntervalMinutes: Int
public init(
accountId: String,
refreshIntervalMinutes: Int
) {
self.accountId = accountId
self.refreshIntervalMinutes = refreshIntervalMinutes
}
public var __variables: Variables? { [
"accountId": accountId,
"refreshIntervalMinutes": refreshIntervalMinutes
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("updateCalendarAccount", UpdateCalendarAccount?.self, arguments: [
"accountId": .variable("accountId"),
"refreshIntervalMinutes": .variable("refreshIntervalMinutes")
]),
] }
public var updateCalendarAccount: UpdateCalendarAccount? { __data["updateCalendarAccount"] }
/// UpdateCalendarAccount
///
/// Parent Type: `CalendarAccountObjectType`
public struct UpdateCalendarAccount: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CalendarAccountObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("provider", GraphQLEnum<AffineGraphQL.CalendarProviderType>.self),
.field("providerAccountId", String.self),
.field("displayName", String?.self),
.field("email", String?.self),
.field("status", String.self),
.field("lastError", String?.self),
.field("refreshIntervalMinutes", Int.self),
.field("calendarsCount", Int.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("updatedAt", AffineGraphQL.DateTime.self),
] }
public var id: String { __data["id"] }
public var provider: GraphQLEnum<AffineGraphQL.CalendarProviderType> { __data["provider"] }
public var providerAccountId: String { __data["providerAccountId"] }
public var displayName: String? { __data["displayName"] }
public var email: String? { __data["email"] }
public var status: String { __data["status"] }
public var lastError: String? { __data["lastError"] }
public var refreshIntervalMinutes: Int { __data["refreshIntervalMinutes"] }
public var calendarsCount: Int { __data["calendarsCount"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
}
}
}

View File

@@ -0,0 +1,84 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class UpdateWorkspaceCalendarsMutation: GraphQLMutation {
public static let operationName: String = "updateWorkspaceCalendars"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation updateWorkspaceCalendars($input: UpdateWorkspaceCalendarsInput!) { updateWorkspaceCalendars(input: $input) { __typename id workspaceId createdByUserId displayNameOverride colorOverride enabled items { __typename id subscriptionId sortOrder colorOverride enabled } } }"#
))
public var input: UpdateWorkspaceCalendarsInput
public init(input: UpdateWorkspaceCalendarsInput) {
self.input = input
}
public var __variables: Variables? { ["input": input] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("updateWorkspaceCalendars", UpdateWorkspaceCalendars.self, arguments: ["input": .variable("input")]),
] }
public var updateWorkspaceCalendars: UpdateWorkspaceCalendars { __data["updateWorkspaceCalendars"] }
/// UpdateWorkspaceCalendars
///
/// Parent Type: `WorkspaceCalendarObjectType`
public struct UpdateWorkspaceCalendars: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceCalendarObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("workspaceId", String.self),
.field("createdByUserId", String.self),
.field("displayNameOverride", String?.self),
.field("colorOverride", String?.self),
.field("enabled", Bool.self),
.field("items", [Item].self),
] }
public var id: String { __data["id"] }
public var workspaceId: String { __data["workspaceId"] }
public var createdByUserId: String { __data["createdByUserId"] }
public var displayNameOverride: String? { __data["displayNameOverride"] }
public var colorOverride: String? { __data["colorOverride"] }
public var enabled: Bool { __data["enabled"] }
public var items: [Item] { __data["items"] }
/// UpdateWorkspaceCalendars.Item
///
/// Parent Type: `WorkspaceCalendarItemObjectType`
public struct Item: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceCalendarItemObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("subscriptionId", String.self),
.field("sortOrder", Int?.self),
.field("colorOverride", String?.self),
.field("enabled", Bool.self),
] }
public var id: String { __data["id"] }
public var subscriptionId: String { __data["subscriptionId"] }
public var sortOrder: Int? { __data["sortOrder"] }
public var colorOverride: String? { __data["colorOverride"] }
public var enabled: Bool { __data["enabled"] }
}
}
}
}

View File

@@ -7,7 +7,7 @@ public class AdminServerConfigQuery: GraphQLQuery {
public static let operationName: String = "adminServerConfig"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query adminServerConfig { serverConfig { __typename version baseUrl name features type initialized credentialsRequirement { __typename ...CredentialsRequirements } availableUpgrade { __typename changelog version publishedAt url } availableUserFeatures } }"#,
#"query adminServerConfig { serverConfig { __typename version baseUrl name features type initialized credentialsRequirement { __typename ...CredentialsRequirements } availableUpgrade { __typename changelog version publishedAt url } availableUserFeatures availableWorkspaceFeatures } }"#,
fragments: [CredentialsRequirements.self, PasswordLimits.self]
))
@@ -44,6 +44,7 @@ public class AdminServerConfigQuery: GraphQLQuery {
.field("credentialsRequirement", CredentialsRequirement.self),
.field("availableUpgrade", AvailableUpgrade?.self),
.field("availableUserFeatures", [GraphQLEnum<AffineGraphQL.FeatureType>].self),
.field("availableWorkspaceFeatures", [GraphQLEnum<AffineGraphQL.FeatureType>].self),
] }
/// server version
@@ -64,6 +65,8 @@ public class AdminServerConfigQuery: GraphQLQuery {
public var availableUpgrade: AvailableUpgrade? { __data["availableUpgrade"] }
/// Features for user that can be configured
public var availableUserFeatures: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["availableUserFeatures"] }
/// Workspace features available for admin configuration
public var availableWorkspaceFeatures: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["availableWorkspaceFeatures"] }
/// ServerConfig.CredentialsRequirement
///

View File

@@ -0,0 +1,174 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class AdminWorkspaceQuery: GraphQLQuery {
public static let operationName: String = "adminWorkspace"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query adminWorkspace($id: String!, $memberSkip: Int, $memberTake: Int, $memberQuery: String) { adminWorkspace(id: $id) { __typename id public createdAt name avatarKey enableAi enableSharing enableUrlPreview enableDocEmbedding features owner { __typename id name email avatarUrl } memberCount publicPageCount snapshotCount snapshotSize blobCount blobSize sharedLinks { __typename docId title publishedAt } members(skip: $memberSkip, take: $memberTake, query: $memberQuery) { __typename id name email avatarUrl role status } } }"#
))
public var id: String
public var memberSkip: GraphQLNullable<Int>
public var memberTake: GraphQLNullable<Int>
public var memberQuery: GraphQLNullable<String>
public init(
id: String,
memberSkip: GraphQLNullable<Int>,
memberTake: GraphQLNullable<Int>,
memberQuery: GraphQLNullable<String>
) {
self.id = id
self.memberSkip = memberSkip
self.memberTake = memberTake
self.memberQuery = memberQuery
}
public var __variables: Variables? { [
"id": id,
"memberSkip": memberSkip,
"memberTake": memberTake,
"memberQuery": memberQuery
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("adminWorkspace", AdminWorkspace?.self, arguments: ["id": .variable("id")]),
] }
/// Get workspace detail for admin
public var adminWorkspace: AdminWorkspace? { __data["adminWorkspace"] }
/// AdminWorkspace
///
/// Parent Type: `AdminWorkspace`
public struct AdminWorkspace: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AdminWorkspace }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("public", Bool.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("name", String?.self),
.field("avatarKey", String?.self),
.field("enableAi", Bool.self),
.field("enableSharing", Bool.self),
.field("enableUrlPreview", Bool.self),
.field("enableDocEmbedding", Bool.self),
.field("features", [GraphQLEnum<AffineGraphQL.FeatureType>].self),
.field("owner", Owner?.self),
.field("memberCount", Int.self),
.field("publicPageCount", Int.self),
.field("snapshotCount", Int.self),
.field("snapshotSize", AffineGraphQL.SafeInt.self),
.field("blobCount", Int.self),
.field("blobSize", AffineGraphQL.SafeInt.self),
.field("sharedLinks", [SharedLink].self),
.field("members", [Member].self, arguments: [
"skip": .variable("memberSkip"),
"take": .variable("memberTake"),
"query": .variable("memberQuery")
]),
] }
public var id: String { __data["id"] }
public var `public`: Bool { __data["public"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var name: String? { __data["name"] }
public var avatarKey: String? { __data["avatarKey"] }
public var enableAi: Bool { __data["enableAi"] }
public var enableSharing: Bool { __data["enableSharing"] }
public var enableUrlPreview: Bool { __data["enableUrlPreview"] }
public var enableDocEmbedding: Bool { __data["enableDocEmbedding"] }
public var features: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["features"] }
public var owner: Owner? { __data["owner"] }
public var memberCount: Int { __data["memberCount"] }
public var publicPageCount: Int { __data["publicPageCount"] }
public var snapshotCount: Int { __data["snapshotCount"] }
public var snapshotSize: AffineGraphQL.SafeInt { __data["snapshotSize"] }
public var blobCount: Int { __data["blobCount"] }
public var blobSize: AffineGraphQL.SafeInt { __data["blobSize"] }
public var sharedLinks: [SharedLink] { __data["sharedLinks"] }
/// Members of workspace
public var members: [Member] { __data["members"] }
/// AdminWorkspace.Owner
///
/// Parent Type: `WorkspaceUserType`
public struct Owner: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceUserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("name", String.self),
.field("email", String.self),
.field("avatarUrl", String?.self),
] }
public var id: String { __data["id"] }
public var name: String { __data["name"] }
public var email: String { __data["email"] }
public var avatarUrl: String? { __data["avatarUrl"] }
}
/// AdminWorkspace.SharedLink
///
/// Parent Type: `AdminWorkspaceSharedLink`
public struct SharedLink: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AdminWorkspaceSharedLink }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("docId", String.self),
.field("title", String?.self),
.field("publishedAt", AffineGraphQL.DateTime?.self),
] }
public var docId: String { __data["docId"] }
public var title: String? { __data["title"] }
public var publishedAt: AffineGraphQL.DateTime? { __data["publishedAt"] }
}
/// AdminWorkspace.Member
///
/// Parent Type: `AdminWorkspaceMember`
public struct Member: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AdminWorkspaceMember }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("name", String.self),
.field("email", String.self),
.field("avatarUrl", String?.self),
.field("role", GraphQLEnum<AffineGraphQL.Permission>.self),
.field("status", GraphQLEnum<AffineGraphQL.WorkspaceMemberStatus>.self),
] }
public var id: String { __data["id"] }
public var name: String { __data["name"] }
public var email: String { __data["email"] }
public var avatarUrl: String? { __data["avatarUrl"] }
public var role: GraphQLEnum<AffineGraphQL.Permission> { __data["role"] }
public var status: GraphQLEnum<AffineGraphQL.WorkspaceMemberStatus> { __data["status"] }
}
}
}
}

View File

@@ -0,0 +1,33 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class AdminWorkspacesCountQuery: GraphQLQuery {
public static let operationName: String = "adminWorkspacesCount"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query adminWorkspacesCount($filter: ListWorkspaceInput!) { adminWorkspacesCount(filter: $filter) }"#
))
public var filter: ListWorkspaceInput
public init(filter: ListWorkspaceInput) {
self.filter = filter
}
public var __variables: Variables? { ["filter": filter] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("adminWorkspacesCount", Int.self, arguments: ["filter": .variable("filter")]),
] }
/// Workspaces count for admin
public var adminWorkspacesCount: Int { __data["adminWorkspacesCount"] }
}
}

View File

@@ -0,0 +1,103 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class AdminWorkspacesQuery: GraphQLQuery {
public static let operationName: String = "adminWorkspaces"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query adminWorkspaces($filter: ListWorkspaceInput!) { adminWorkspaces(filter: $filter) { __typename id public createdAt name avatarKey enableAi enableSharing enableUrlPreview enableDocEmbedding features owner { __typename id name email avatarUrl } memberCount publicPageCount snapshotCount snapshotSize blobCount blobSize } }"#
))
public var filter: ListWorkspaceInput
public init(filter: ListWorkspaceInput) {
self.filter = filter
}
public var __variables: Variables? { ["filter": filter] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("adminWorkspaces", [AdminWorkspace].self, arguments: ["filter": .variable("filter")]),
] }
/// List workspaces for admin
public var adminWorkspaces: [AdminWorkspace] { __data["adminWorkspaces"] }
/// AdminWorkspace
///
/// Parent Type: `AdminWorkspace`
public struct AdminWorkspace: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AdminWorkspace }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("public", Bool.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("name", String?.self),
.field("avatarKey", String?.self),
.field("enableAi", Bool.self),
.field("enableSharing", Bool.self),
.field("enableUrlPreview", Bool.self),
.field("enableDocEmbedding", Bool.self),
.field("features", [GraphQLEnum<AffineGraphQL.FeatureType>].self),
.field("owner", Owner?.self),
.field("memberCount", Int.self),
.field("publicPageCount", Int.self),
.field("snapshotCount", Int.self),
.field("snapshotSize", AffineGraphQL.SafeInt.self),
.field("blobCount", Int.self),
.field("blobSize", AffineGraphQL.SafeInt.self),
] }
public var id: String { __data["id"] }
public var `public`: Bool { __data["public"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var name: String? { __data["name"] }
public var avatarKey: String? { __data["avatarKey"] }
public var enableAi: Bool { __data["enableAi"] }
public var enableSharing: Bool { __data["enableSharing"] }
public var enableUrlPreview: Bool { __data["enableUrlPreview"] }
public var enableDocEmbedding: Bool { __data["enableDocEmbedding"] }
public var features: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["features"] }
public var owner: Owner? { __data["owner"] }
public var memberCount: Int { __data["memberCount"] }
public var publicPageCount: Int { __data["publicPageCount"] }
public var snapshotCount: Int { __data["snapshotCount"] }
public var snapshotSize: AffineGraphQL.SafeInt { __data["snapshotSize"] }
public var blobCount: Int { __data["blobCount"] }
public var blobSize: AffineGraphQL.SafeInt { __data["blobSize"] }
/// AdminWorkspace.Owner
///
/// Parent Type: `WorkspaceUserType`
public struct Owner: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceUserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("name", String.self),
.field("email", String.self),
.field("avatarUrl", String?.self),
] }
public var id: String { __data["id"] }
public var name: String { __data["name"] }
public var email: String { __data["email"] }
public var avatarUrl: String? { __data["avatarUrl"] }
}
}
}
}

View File

@@ -0,0 +1,113 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class CalendarAccountsQuery: GraphQLQuery {
public static let operationName: String = "calendarAccounts"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query calendarAccounts { currentUser { __typename calendarAccounts { __typename id provider providerAccountId displayName email status lastError refreshIntervalMinutes calendarsCount createdAt updatedAt calendars { __typename id accountId provider externalCalendarId displayName timezone color enabled lastSyncAt } } } }"#
))
public init() {}
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("currentUser", CurrentUser?.self),
] }
/// Get current user
public var currentUser: CurrentUser? { __data["currentUser"] }
/// CurrentUser
///
/// Parent Type: `UserType`
public struct CurrentUser: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("calendarAccounts", [CalendarAccount].self),
] }
public var calendarAccounts: [CalendarAccount] { __data["calendarAccounts"] }
/// CurrentUser.CalendarAccount
///
/// Parent Type: `CalendarAccountObjectType`
public struct CalendarAccount: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CalendarAccountObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("provider", GraphQLEnum<AffineGraphQL.CalendarProviderType>.self),
.field("providerAccountId", String.self),
.field("displayName", String?.self),
.field("email", String?.self),
.field("status", String.self),
.field("lastError", String?.self),
.field("refreshIntervalMinutes", Int.self),
.field("calendarsCount", Int.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("updatedAt", AffineGraphQL.DateTime.self),
.field("calendars", [Calendar].self),
] }
public var id: String { __data["id"] }
public var provider: GraphQLEnum<AffineGraphQL.CalendarProviderType> { __data["provider"] }
public var providerAccountId: String { __data["providerAccountId"] }
public var displayName: String? { __data["displayName"] }
public var email: String? { __data["email"] }
public var status: String { __data["status"] }
public var lastError: String? { __data["lastError"] }
public var refreshIntervalMinutes: Int { __data["refreshIntervalMinutes"] }
public var calendarsCount: Int { __data["calendarsCount"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
public var calendars: [Calendar] { __data["calendars"] }
/// CurrentUser.CalendarAccount.Calendar
///
/// Parent Type: `CalendarSubscriptionObjectType`
public struct Calendar: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CalendarSubscriptionObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("accountId", String.self),
.field("provider", GraphQLEnum<AffineGraphQL.CalendarProviderType>.self),
.field("externalCalendarId", String.self),
.field("displayName", String?.self),
.field("timezone", String?.self),
.field("color", String?.self),
.field("enabled", Bool.self),
.field("lastSyncAt", AffineGraphQL.DateTime?.self),
] }
public var id: String { __data["id"] }
public var accountId: String { __data["accountId"] }
public var provider: GraphQLEnum<AffineGraphQL.CalendarProviderType> { __data["provider"] }
public var externalCalendarId: String { __data["externalCalendarId"] }
public var displayName: String? { __data["displayName"] }
public var timezone: String? { __data["timezone"] }
public var color: String? { __data["color"] }
public var enabled: Bool { __data["enabled"] }
public var lastSyncAt: AffineGraphQL.DateTime? { __data["lastSyncAt"] }
}
}
}
}
}

View File

@@ -0,0 +1,120 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class CalendarEventsQuery: GraphQLQuery {
public static let operationName: String = "calendarEvents"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query calendarEvents($workspaceId: String!, $from: DateTime!, $to: DateTime!) { workspace(id: $workspaceId) { __typename calendars { __typename id events(from: $from, to: $to) { __typename id subscriptionId externalEventId recurrenceId status title description location startAtUtc endAtUtc originalTimezone allDay } } } }"#
))
public var workspaceId: String
public var from: DateTime
public var to: DateTime
public init(
workspaceId: String,
from: DateTime,
to: DateTime
) {
self.workspaceId = workspaceId
self.from = from
self.to = to
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"from": from,
"to": to
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("workspace", Workspace.self, arguments: ["id": .variable("workspaceId")]),
] }
/// Get workspace by id
public var workspace: Workspace { __data["workspace"] }
/// Workspace
///
/// Parent Type: `WorkspaceType`
public struct Workspace: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("calendars", [Calendar].self),
] }
public var calendars: [Calendar] { __data["calendars"] }
/// Workspace.Calendar
///
/// Parent Type: `WorkspaceCalendarObjectType`
public struct Calendar: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceCalendarObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("events", [Event].self, arguments: [
"from": .variable("from"),
"to": .variable("to")
]),
] }
public var id: String { __data["id"] }
public var events: [Event] { __data["events"] }
/// Workspace.Calendar.Event
///
/// Parent Type: `CalendarEventObjectType`
public struct Event: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CalendarEventObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("subscriptionId", String.self),
.field("externalEventId", String.self),
.field("recurrenceId", String?.self),
.field("status", String?.self),
.field("title", String?.self),
.field("description", String?.self),
.field("location", String?.self),
.field("startAtUtc", AffineGraphQL.DateTime.self),
.field("endAtUtc", AffineGraphQL.DateTime.self),
.field("originalTimezone", String?.self),
.field("allDay", Bool.self),
] }
public var id: String { __data["id"] }
public var subscriptionId: String { __data["subscriptionId"] }
public var externalEventId: String { __data["externalEventId"] }
public var recurrenceId: String? { __data["recurrenceId"] }
public var status: String? { __data["status"] }
public var title: String? { __data["title"] }
public var description: String? { __data["description"] }
public var location: String? { __data["location"] }
public var startAtUtc: AffineGraphQL.DateTime { __data["startAtUtc"] }
public var endAtUtc: AffineGraphQL.DateTime { __data["endAtUtc"] }
public var originalTimezone: String? { __data["originalTimezone"] }
public var allDay: Bool { __data["allDay"] }
}
}
}
}
}

View File

@@ -0,0 +1,67 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class CalendarProvidersQuery: GraphQLQuery {
public static let operationName: String = "calendarProviders"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query calendarProviders { serverConfig { __typename calendarCalDAVProviders { __typename id label requiresAppPassword docsUrl } calendarProviders } }"#
))
public init() {}
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("serverConfig", ServerConfig.self),
] }
/// server config
public var serverConfig: ServerConfig { __data["serverConfig"] }
/// ServerConfig
///
/// Parent Type: `ServerConfigType`
public struct ServerConfig: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.ServerConfigType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("calendarCalDAVProviders", [CalendarCalDAVProvider].self),
.field("calendarProviders", [GraphQLEnum<AffineGraphQL.CalendarProviderType>].self),
] }
public var calendarCalDAVProviders: [CalendarCalDAVProvider] { __data["calendarCalDAVProviders"] }
public var calendarProviders: [GraphQLEnum<AffineGraphQL.CalendarProviderType>] { __data["calendarProviders"] }
/// ServerConfig.CalendarCalDAVProvider
///
/// Parent Type: `CalendarCalDAVProviderPresetObjectType`
public struct CalendarCalDAVProvider: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CalendarCalDAVProviderPresetObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("label", String.self),
.field("requiresAppPassword", Bool?.self),
.field("docsUrl", String?.self),
] }
public var id: String { __data["id"] }
public var label: String { __data["label"] }
public var requiresAppPassword: Bool? { __data["requiresAppPassword"] }
public var docsUrl: String? { __data["docsUrl"] }
}
}
}
}

View File

@@ -0,0 +1,90 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class GetBlobUploadPartUrlQuery: GraphQLQuery {
public static let operationName: String = "getBlobUploadPartUrl"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getBlobUploadPartUrl($workspaceId: String!, $key: String!, $uploadId: String!, $partNumber: Int!) { workspace(id: $workspaceId) { __typename blobUploadPartUrl(key: $key, uploadId: $uploadId, partNumber: $partNumber) { __typename uploadUrl headers expiresAt } } }"#
))
public var workspaceId: String
public var key: String
public var uploadId: String
public var partNumber: Int
public init(
workspaceId: String,
key: String,
uploadId: String,
partNumber: Int
) {
self.workspaceId = workspaceId
self.key = key
self.uploadId = uploadId
self.partNumber = partNumber
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"key": key,
"uploadId": uploadId,
"partNumber": partNumber
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("workspace", Workspace.self, arguments: ["id": .variable("workspaceId")]),
] }
/// Get workspace by id
public var workspace: Workspace { __data["workspace"] }
/// Workspace
///
/// Parent Type: `WorkspaceType`
public struct Workspace: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("blobUploadPartUrl", BlobUploadPartUrl.self, arguments: [
"key": .variable("key"),
"uploadId": .variable("uploadId"),
"partNumber": .variable("partNumber")
]),
] }
/// Get blob upload part url
public var blobUploadPartUrl: BlobUploadPartUrl { __data["blobUploadPartUrl"] }
/// Workspace.BlobUploadPartUrl
///
/// Parent Type: `BlobUploadPart`
public struct BlobUploadPartUrl: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.BlobUploadPart }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("uploadUrl", String.self),
.field("headers", AffineGraphQL.JSONObject?.self),
.field("expiresAt", AffineGraphQL.DateTime?.self),
] }
public var uploadUrl: String { __data["uploadUrl"] }
public var headers: AffineGraphQL.JSONObject? { __data["headers"] }
public var expiresAt: AffineGraphQL.DateTime? { __data["expiresAt"] }
}
}
}
}

View File

@@ -0,0 +1,74 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class GetCurrentUserProfileQuery: GraphQLQuery {
public static let operationName: String = "getCurrentUserProfile"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getCurrentUserProfile { currentUser { __typename ...CurrentUserProfile } }"#,
fragments: [CurrentUserProfile.self]
))
public init() {}
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("currentUser", CurrentUser?.self),
] }
/// Get current user
public var currentUser: CurrentUser? { __data["currentUser"] }
/// CurrentUser
///
/// Parent Type: `UserType`
public struct CurrentUser: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.fragment(CurrentUserProfile.self),
] }
public var id: AffineGraphQL.ID { __data["id"] }
/// User name
public var name: String { __data["name"] }
/// User email
public var email: String { __data["email"] }
/// User avatar url
public var avatarUrl: String? { __data["avatarUrl"] }
/// User email verified
public var emailVerified: Bool { __data["emailVerified"] }
/// Enabled features of a user
public var features: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["features"] }
/// Get user settings
public var settings: Settings { __data["settings"] }
public var quota: Quota { __data["quota"] }
public var quotaUsage: QuotaUsage { __data["quotaUsage"] }
public var copilot: Copilot { __data["copilot"] }
public struct Fragments: FragmentContainer {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public var currentUserProfile: CurrentUserProfile { _toFragment() }
}
public typealias Settings = CurrentUserProfile.Settings
public typealias Quota = CurrentUserProfile.Quota
public typealias QuotaUsage = CurrentUserProfile.QuotaUsage
public typealias Copilot = CurrentUserProfile.Copilot
}
}
}

View File

@@ -7,7 +7,7 @@ public class GetWorkspaceConfigQuery: GraphQLQuery {
public static let operationName: String = "getWorkspaceConfig"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getWorkspaceConfig($id: String!) { workspace(id: $id) { __typename enableAi enableUrlPreview enableDocEmbedding inviteLink { __typename link expireTime } } }"#
#"query getWorkspaceConfig($id: String!) { workspace(id: $id) { __typename enableAi enableSharing enableUrlPreview enableDocEmbedding inviteLink { __typename link expireTime } } }"#
))
public var id: String
@@ -41,6 +41,7 @@ public class GetWorkspaceConfigQuery: GraphQLQuery {
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("enableAi", Bool.self),
.field("enableSharing", Bool.self),
.field("enableUrlPreview", Bool.self),
.field("enableDocEmbedding", Bool.self),
.field("inviteLink", InviteLink?.self),
@@ -48,6 +49,8 @@ public class GetWorkspaceConfigQuery: GraphQLQuery {
/// Enable AI
public var enableAi: Bool { __data["enableAi"] }
/// Enable workspace sharing
public var enableSharing: Bool { __data["enableSharing"] }
/// Enable url previous when sharing
public var enableUrlPreview: Bool { __data["enableUrlPreview"] }
/// Enable doc embedding

View File

@@ -7,7 +7,7 @@ public class GetWorkspaceInfoQuery: GraphQLQuery {
public static let operationName: String = "getWorkspaceInfo"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getWorkspaceInfo($workspaceId: String!) { workspace(id: $workspaceId) { __typename role team } }"#
#"query getWorkspaceInfo($workspaceId: String!) { workspace(id: $workspaceId) { __typename permissions { __typename Workspace_Administrators_Manage Workspace_Blobs_List Workspace_Blobs_Read Workspace_Blobs_Write Workspace_Copilot Workspace_CreateDoc Workspace_Delete Workspace_Organize_Read Workspace_Payment_Manage Workspace_Properties_Create Workspace_Properties_Delete Workspace_Properties_Read Workspace_Properties_Update Workspace_Read Workspace_Settings_Read Workspace_Settings_Update Workspace_Sync Workspace_TransferOwner Workspace_Users_Manage Workspace_Users_Read } role team } }"#
))
public var workspaceId: String
@@ -40,14 +40,71 @@ public class GetWorkspaceInfoQuery: GraphQLQuery {
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("permissions", Permissions.self),
.field("role", GraphQLEnum<AffineGraphQL.Permission>.self),
.field("team", Bool.self),
] }
/// map of action permissions
public var permissions: Permissions { __data["permissions"] }
/// Role of current signed in user in workspace
public var role: GraphQLEnum<AffineGraphQL.Permission> { __data["role"] }
/// if workspace is team workspace
public var team: Bool { __data["team"] }
/// Workspace.Permissions
///
/// Parent Type: `WorkspacePermissions`
public struct Permissions: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspacePermissions }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("Workspace_Administrators_Manage", Bool.self),
.field("Workspace_Blobs_List", Bool.self),
.field("Workspace_Blobs_Read", Bool.self),
.field("Workspace_Blobs_Write", Bool.self),
.field("Workspace_Copilot", Bool.self),
.field("Workspace_CreateDoc", Bool.self),
.field("Workspace_Delete", Bool.self),
.field("Workspace_Organize_Read", Bool.self),
.field("Workspace_Payment_Manage", Bool.self),
.field("Workspace_Properties_Create", Bool.self),
.field("Workspace_Properties_Delete", Bool.self),
.field("Workspace_Properties_Read", Bool.self),
.field("Workspace_Properties_Update", Bool.self),
.field("Workspace_Read", Bool.self),
.field("Workspace_Settings_Read", Bool.self),
.field("Workspace_Settings_Update", Bool.self),
.field("Workspace_Sync", Bool.self),
.field("Workspace_TransferOwner", Bool.self),
.field("Workspace_Users_Manage", Bool.self),
.field("Workspace_Users_Read", Bool.self),
] }
public var workspace_Administrators_Manage: Bool { __data["Workspace_Administrators_Manage"] }
public var workspace_Blobs_List: Bool { __data["Workspace_Blobs_List"] }
public var workspace_Blobs_Read: Bool { __data["Workspace_Blobs_Read"] }
public var workspace_Blobs_Write: Bool { __data["Workspace_Blobs_Write"] }
public var workspace_Copilot: Bool { __data["Workspace_Copilot"] }
public var workspace_CreateDoc: Bool { __data["Workspace_CreateDoc"] }
public var workspace_Delete: Bool { __data["Workspace_Delete"] }
public var workspace_Organize_Read: Bool { __data["Workspace_Organize_Read"] }
public var workspace_Payment_Manage: Bool { __data["Workspace_Payment_Manage"] }
public var workspace_Properties_Create: Bool { __data["Workspace_Properties_Create"] }
public var workspace_Properties_Delete: Bool { __data["Workspace_Properties_Delete"] }
public var workspace_Properties_Read: Bool { __data["Workspace_Properties_Read"] }
public var workspace_Properties_Update: Bool { __data["Workspace_Properties_Update"] }
public var workspace_Read: Bool { __data["Workspace_Read"] }
public var workspace_Settings_Read: Bool { __data["Workspace_Settings_Read"] }
public var workspace_Settings_Update: Bool { __data["Workspace_Settings_Update"] }
public var workspace_Sync: Bool { __data["Workspace_Sync"] }
public var workspace_TransferOwner: Bool { __data["Workspace_TransferOwner"] }
public var workspace_Users_Manage: Bool { __data["Workspace_Users_Manage"] }
public var workspace_Users_Read: Bool { __data["Workspace_Users_Read"] }
}
}
}
}

View File

@@ -7,7 +7,7 @@ public class ListUserAccessTokensQuery: GraphQLQuery {
public static let operationName: String = "listUserAccessTokens"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query listUserAccessTokens { revealedAccessTokens { __typename id name createdAt expiresAt token } }"#
#"query listUserAccessTokens { currentUser { __typename revealedAccessTokens { __typename id name createdAt expiresAt token } } }"#
))
public init() {}
@@ -18,33 +18,50 @@ public class ListUserAccessTokensQuery: GraphQLQuery {
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("revealedAccessTokens", [RevealedAccessToken].self),
.field("currentUser", CurrentUser?.self),
] }
public var revealedAccessTokens: [RevealedAccessToken] { __data["revealedAccessTokens"] }
/// Get current user
public var currentUser: CurrentUser? { __data["currentUser"] }
/// RevealedAccessToken
/// CurrentUser
///
/// Parent Type: `RevealedAccessToken`
public struct RevealedAccessToken: AffineGraphQL.SelectionSet {
/// Parent Type: `UserType`
public struct CurrentUser: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.RevealedAccessToken }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("name", String.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("expiresAt", AffineGraphQL.DateTime?.self),
.field("token", String.self),
.field("revealedAccessTokens", [RevealedAccessToken].self),
] }
public var id: String { __data["id"] }
public var name: String { __data["name"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var expiresAt: AffineGraphQL.DateTime? { __data["expiresAt"] }
public var token: String { __data["token"] }
public var revealedAccessTokens: [RevealedAccessToken] { __data["revealedAccessTokens"] }
/// CurrentUser.RevealedAccessToken
///
/// Parent Type: `RevealedAccessToken`
public struct RevealedAccessToken: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.RevealedAccessToken }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("name", String.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("expiresAt", AffineGraphQL.DateTime?.self),
.field("token", String.self),
] }
public var id: String { __data["id"] }
public var name: String { __data["name"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var expiresAt: AffineGraphQL.DateTime? { __data["expiresAt"] }
public var token: String { __data["token"] }
}
}
}
}

View File

@@ -7,7 +7,7 @@ public class ListUsersQuery: GraphQLQuery {
public static let operationName: String = "listUsers"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query listUsers($filter: ListUserInput!) { users(filter: $filter) { __typename id name email disabled features hasPassword emailVerified avatarUrl } usersCount }"#
#"query listUsers($filter: ListUserInput!) { users(filter: $filter) { __typename id name email disabled features hasPassword emailVerified avatarUrl } usersCount(filter: $filter) }"#
))
public var filter: ListUserInput
@@ -25,7 +25,7 @@ public class ListUsersQuery: GraphQLQuery {
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("users", [User].self, arguments: ["filter": .variable("filter")]),
.field("usersCount", Int.self),
.field("usersCount", Int.self, arguments: ["filter": .variable("filter")]),
] }
/// List registered users

View File

@@ -7,7 +7,7 @@ public class NotificationCountQuery: GraphQLQuery {
public static let operationName: String = "notificationCount"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query notificationCount { currentUser { __typename notificationCount } }"#
#"query notificationCount { currentUser { __typename notifications(pagination: { first: 1 }) { __typename totalCount } } }"#
))
public init() {}
@@ -34,11 +34,27 @@ public class NotificationCountQuery: GraphQLQuery {
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("notificationCount", Int.self),
.field("notifications", Notifications.self, arguments: ["pagination": ["first": 1]]),
] }
/// Get user notification count
public var notificationCount: Int { __data["notificationCount"] }
/// Get current user notifications
public var notifications: Notifications { __data["notifications"] }
/// CurrentUser.Notifications
///
/// Parent Type: `PaginatedNotificationObjectType`
public struct Notifications: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.PaginatedNotificationObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("totalCount", Int.self),
] }
public var totalCount: Int { __data["totalCount"] }
}
}
}
}

View File

@@ -7,7 +7,7 @@ public class ServerConfigQuery: GraphQLQuery {
public static let operationName: String = "serverConfig"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query serverConfig { serverConfig { __typename version baseUrl name features type initialized credentialsRequirement { __typename ...CredentialsRequirements } } }"#,
#"query serverConfig { serverConfig { __typename version baseUrl name features type initialized calendarProviders credentialsRequirement { __typename ...CredentialsRequirements } } }"#,
fragments: [CredentialsRequirements.self, PasswordLimits.self]
))
@@ -41,6 +41,7 @@ public class ServerConfigQuery: GraphQLQuery {
.field("features", [GraphQLEnum<AffineGraphQL.ServerFeature>].self),
.field("type", GraphQLEnum<AffineGraphQL.ServerDeploymentType>.self),
.field("initialized", Bool.self),
.field("calendarProviders", [GraphQLEnum<AffineGraphQL.CalendarProviderType>].self),
.field("credentialsRequirement", CredentialsRequirement.self),
] }
@@ -56,6 +57,7 @@ public class ServerConfigQuery: GraphQLQuery {
public var type: GraphQLEnum<AffineGraphQL.ServerDeploymentType> { __data["type"] }
/// whether server has been initialized
public var initialized: Bool { __data["initialized"] }
public var calendarProviders: [GraphQLEnum<AffineGraphQL.CalendarProviderType>] { __data["calendarProviders"] }
/// credentials requirement
public var credentialsRequirement: CredentialsRequirement { __data["credentialsRequirement"] }

View File

@@ -3,11 +3,11 @@
@_exported import ApolloAPI
public class ValidateConfigMutation: GraphQLMutation {
public class ValidateConfigQuery: GraphQLQuery {
public static let operationName: String = "validateConfig"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation validateConfig($updates: [UpdateAppConfigInput!]!) { validateAppConfig(updates: $updates) { __typename module key value valid error } }"#
#"query validateConfig($updates: [UpdateAppConfigInput!]!) { validateAppConfig(updates: $updates) { __typename module key value valid error } }"#
))
public var updates: [UpdateAppConfigInput]
@@ -22,7 +22,7 @@ public class ValidateConfigMutation: GraphQLMutation {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("validateAppConfig", [ValidateAppConfig].self, arguments: ["updates": .variable("updates")]),
] }

View File

@@ -0,0 +1,101 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class WorkspaceCalendarsQuery: GraphQLQuery {
public static let operationName: String = "workspaceCalendars"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query workspaceCalendars($workspaceId: String!) { workspace(id: $workspaceId) { __typename calendars { __typename id workspaceId createdByUserId displayNameOverride colorOverride enabled items { __typename id subscriptionId sortOrder colorOverride enabled } } } }"#
))
public var workspaceId: String
public init(workspaceId: String) {
self.workspaceId = workspaceId
}
public var __variables: Variables? { ["workspaceId": workspaceId] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("workspace", Workspace.self, arguments: ["id": .variable("workspaceId")]),
] }
/// Get workspace by id
public var workspace: Workspace { __data["workspace"] }
/// Workspace
///
/// Parent Type: `WorkspaceType`
public struct Workspace: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("calendars", [Calendar].self),
] }
public var calendars: [Calendar] { __data["calendars"] }
/// Workspace.Calendar
///
/// Parent Type: `WorkspaceCalendarObjectType`
public struct Calendar: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceCalendarObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("workspaceId", String.self),
.field("createdByUserId", String.self),
.field("displayNameOverride", String?.self),
.field("colorOverride", String?.self),
.field("enabled", Bool.self),
.field("items", [Item].self),
] }
public var id: String { __data["id"] }
public var workspaceId: String { __data["workspaceId"] }
public var createdByUserId: String { __data["createdByUserId"] }
public var displayNameOverride: String? { __data["displayNameOverride"] }
public var colorOverride: String? { __data["colorOverride"] }
public var enabled: Bool { __data["enabled"] }
public var items: [Item] { __data["items"] }
/// Workspace.Calendar.Item
///
/// Parent Type: `WorkspaceCalendarItemObjectType`
public struct Item: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceCalendarItemObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("subscriptionId", String.self),
.field("sortOrder", Int?.self),
.field("colorOverride", String?.self),
.field("enabled", Bool.self),
] }
public var id: String { __data["id"] }
public var subscriptionId: String { __data["subscriptionId"] }
public var sortOrder: Int? { __data["sortOrder"] }
public var colorOverride: String? { __data["colorOverride"] }
public var enabled: Bool { __data["enabled"] }
}
}
}
}
}

View File

@@ -7,4 +7,5 @@
import ApolloAPI
public typealias JSON = JSONObject
/// The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
public typealias JSON = String

View File

@@ -7,35 +7,5 @@
import ApolloAPI
public typealias JSONObject = CustomJSON
public enum CustomJSON: CustomScalarType, Hashable {
case dictionary([String: AnyHashable])
case array([AnyHashable])
public init(_jsonValue value: JSONValue) throws {
if let dict = value as? [String: AnyHashable] {
self = .dictionary(dict)
} else if let array = value as? [AnyHashable] {
self = .array(array)
} else {
throw JSONDecodingError.couldNotConvert(value: value, to: CustomJSON.self)
}
}
public var _jsonValue: JSONValue {
switch self {
case let .dictionary(json as AnyHashable),
let .array(json as AnyHashable):
json
}
}
public static func == (lhs: CustomJSON, rhs: CustomJSON) -> Bool {
lhs._jsonValue == rhs._jsonValue
}
public func hash(into hasher: inout Hasher) {
hasher.combine(_jsonValue)
}
}
/// The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
public typealias JSONObject = String

View File

@@ -0,0 +1,14 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public enum AdminWorkspaceSort: String, EnumType {
case blobCount = "BlobCount"
case blobSize = "BlobSize"
case createdAt = "CreatedAt"
case memberCount = "MemberCount"
case publicPageCount = "PublicPageCount"
case snapshotCount = "SnapshotCount"
case snapshotSize = "SnapshotSize"
}

View File

@@ -0,0 +1,9 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public enum CalendarProviderType: String, EnumType {
case calDAV = "CalDAV"
case google = "Google"
}

View File

@@ -11,20 +11,13 @@ public struct AddContextFileInput: InputObject {
}
public init(
blobId: GraphQLNullable<String> = nil,
contextId: String
) {
__data = InputDict([
"blobId": blobId,
"contextId": contextId
])
}
public var blobId: GraphQLNullable<String> {
get { __data["blobId"] }
set { __data["blobId"] = newValue }
}
public var contextId: String {
get { __data["contextId"] }
set { __data["contextId"] = newValue }

View File

@@ -0,0 +1,81 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public struct AdminUpdateWorkspaceInput: InputObject {
public private(set) var __data: InputDict
public init(_ data: InputDict) {
__data = data
}
public init(
avatarKey: GraphQLNullable<String> = nil,
enableAi: GraphQLNullable<Bool> = nil,
enableDocEmbedding: GraphQLNullable<Bool> = nil,
enableSharing: GraphQLNullable<Bool> = nil,
enableUrlPreview: GraphQLNullable<Bool> = nil,
features: GraphQLNullable<[GraphQLEnum<FeatureType>]> = nil,
id: String,
name: GraphQLNullable<String> = nil,
`public`: GraphQLNullable<Bool> = nil
) {
__data = InputDict([
"avatarKey": avatarKey,
"enableAi": enableAi,
"enableDocEmbedding": enableDocEmbedding,
"enableSharing": enableSharing,
"enableUrlPreview": enableUrlPreview,
"features": features,
"id": id,
"name": name,
"public": `public`
])
}
public var avatarKey: GraphQLNullable<String> {
get { __data["avatarKey"] }
set { __data["avatarKey"] = newValue }
}
public var enableAi: GraphQLNullable<Bool> {
get { __data["enableAi"] }
set { __data["enableAi"] = newValue }
}
public var enableDocEmbedding: GraphQLNullable<Bool> {
get { __data["enableDocEmbedding"] }
set { __data["enableDocEmbedding"] = newValue }
}
public var enableSharing: GraphQLNullable<Bool> {
get { __data["enableSharing"] }
set { __data["enableSharing"] = newValue }
}
public var enableUrlPreview: GraphQLNullable<Bool> {
get { __data["enableUrlPreview"] }
set { __data["enableUrlPreview"] = newValue }
}
public var features: GraphQLNullable<[GraphQLEnum<FeatureType>]> {
get { __data["features"] }
set { __data["features"] = newValue }
}
public var id: String {
get { __data["id"] }
set { __data["id"] = newValue }
}
public var name: GraphQLNullable<String> {
get { __data["name"] }
set { __data["name"] = newValue }
}
public var `public`: GraphQLNullable<Bool> {
get { __data["public"] }
set { __data["public"] = newValue }
}
}

View File

@@ -0,0 +1,46 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public struct LinkCalDAVAccountInput: InputObject {
public private(set) var __data: InputDict
public init(_ data: InputDict) {
__data = data
}
public init(
displayName: GraphQLNullable<String> = nil,
password: String,
providerPresetId: String,
username: String
) {
__data = InputDict([
"displayName": displayName,
"password": password,
"providerPresetId": providerPresetId,
"username": username
])
}
public var displayName: GraphQLNullable<String> {
get { __data["displayName"] }
set { __data["displayName"] = newValue }
}
public var password: String {
get { __data["password"] }
set { __data["password"] = newValue }
}
public var providerPresetId: String {
get { __data["providerPresetId"] }
set { __data["providerPresetId"] = newValue }
}
public var username: String {
get { __data["username"] }
set { __data["username"] = newValue }
}
}

View File

@@ -0,0 +1,32 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public struct LinkCalendarAccountInput: InputObject {
public private(set) var __data: InputDict
public init(_ data: InputDict) {
__data = data
}
public init(
provider: GraphQLEnum<CalendarProviderType>,
redirectUri: GraphQLNullable<String> = nil
) {
__data = InputDict([
"provider": provider,
"redirectUri": redirectUri
])
}
public var provider: GraphQLEnum<CalendarProviderType> {
get { __data["provider"] }
set { __data["provider"] = newValue }
}
public var redirectUri: GraphQLNullable<String> {
get { __data["redirectUri"] }
set { __data["redirectUri"] = newValue }
}
}

View File

@@ -11,20 +11,34 @@ public struct ListUserInput: InputObject {
}
public init(
features: GraphQLNullable<[GraphQLEnum<FeatureType>]> = nil,
first: GraphQLNullable<Int> = nil,
keyword: GraphQLNullable<String> = nil,
skip: GraphQLNullable<Int> = nil
) {
__data = InputDict([
"features": features,
"first": first,
"keyword": keyword,
"skip": skip
])
}
public var features: GraphQLNullable<[GraphQLEnum<FeatureType>]> {
get { __data["features"] }
set { __data["features"] = newValue }
}
public var first: GraphQLNullable<Int> {
get { __data["first"] }
set { __data["first"] = newValue }
}
public var keyword: GraphQLNullable<String> {
get { __data["keyword"] }
set { __data["keyword"] = newValue }
}
public var skip: GraphQLNullable<Int> {
get { __data["skip"] }
set { __data["skip"] = newValue }

View File

@@ -0,0 +1,88 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public struct ListWorkspaceInput: InputObject {
public private(set) var __data: InputDict
public init(_ data: InputDict) {
__data = data
}
public init(
enableAi: GraphQLNullable<Bool> = nil,
enableDocEmbedding: GraphQLNullable<Bool> = nil,
enableSharing: GraphQLNullable<Bool> = nil,
enableUrlPreview: GraphQLNullable<Bool> = nil,
features: GraphQLNullable<[GraphQLEnum<FeatureType>]> = nil,
first: Int? = nil,
keyword: GraphQLNullable<String> = nil,
orderBy: GraphQLNullable<GraphQLEnum<AdminWorkspaceSort>> = nil,
`public`: GraphQLNullable<Bool> = nil,
skip: Int? = nil
) {
__data = InputDict([
"enableAi": enableAi,
"enableDocEmbedding": enableDocEmbedding,
"enableSharing": enableSharing,
"enableUrlPreview": enableUrlPreview,
"features": features,
"first": first,
"keyword": keyword,
"orderBy": orderBy,
"public": `public`,
"skip": skip
])
}
public var enableAi: GraphQLNullable<Bool> {
get { __data["enableAi"] }
set { __data["enableAi"] = newValue }
}
public var enableDocEmbedding: GraphQLNullable<Bool> {
get { __data["enableDocEmbedding"] }
set { __data["enableDocEmbedding"] = newValue }
}
public var enableSharing: GraphQLNullable<Bool> {
get { __data["enableSharing"] }
set { __data["enableSharing"] = newValue }
}
public var enableUrlPreview: GraphQLNullable<Bool> {
get { __data["enableUrlPreview"] }
set { __data["enableUrlPreview"] = newValue }
}
public var features: GraphQLNullable<[GraphQLEnum<FeatureType>]> {
get { __data["features"] }
set { __data["features"] = newValue }
}
public var first: Int? {
get { __data["first"] }
set { __data["first"] = newValue }
}
public var keyword: GraphQLNullable<String> {
get { __data["keyword"] }
set { __data["keyword"] = newValue }
}
public var orderBy: GraphQLNullable<GraphQLEnum<AdminWorkspaceSort>> {
get { __data["orderBy"] }
set { __data["orderBy"] = newValue }
}
public var `public`: GraphQLNullable<Bool> {
get { __data["public"] }
set { __data["public"] = newValue }
}
public var skip: Int? {
get { __data["skip"] }
set { __data["skip"] = newValue }
}
}

View File

@@ -0,0 +1,32 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public struct UpdateWorkspaceCalendarsInput: InputObject {
public private(set) var __data: InputDict
public init(_ data: InputDict) {
__data = data
}
public init(
items: [WorkspaceCalendarItemInput],
workspaceId: String
) {
__data = InputDict([
"items": items,
"workspaceId": workspaceId
])
}
public var items: [WorkspaceCalendarItemInput] {
get { __data["items"] }
set { __data["items"] = newValue }
}
public var workspaceId: String {
get { __data["workspaceId"] }
set { __data["workspaceId"] = newValue }
}
}

View File

@@ -0,0 +1,39 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public struct WorkspaceCalendarItemInput: InputObject {
public private(set) var __data: InputDict
public init(_ data: InputDict) {
__data = data
}
public init(
colorOverride: GraphQLNullable<String> = nil,
sortOrder: GraphQLNullable<Int> = nil,
subscriptionId: String
) {
__data = InputDict([
"colorOverride": colorOverride,
"sortOrder": sortOrder,
"subscriptionId": subscriptionId
])
}
public var colorOverride: GraphQLNullable<String> {
get { __data["colorOverride"] }
set { __data["colorOverride"] = newValue }
}
public var sortOrder: GraphQLNullable<Int> {
get { __data["sortOrder"] }
set { __data["sortOrder"] = newValue }
}
public var subscriptionId: String {
get { __data["subscriptionId"] }
set { __data["subscriptionId"] = newValue }
}
}

View File

@@ -0,0 +1,12 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public extension Objects {
static let AdminWorkspace = ApolloAPI.Object(
typename: "AdminWorkspace",
implementedInterfaces: [],
keyFields: nil
)
}

View File

@@ -0,0 +1,12 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public extension Objects {
static let AdminWorkspaceMember = ApolloAPI.Object(
typename: "AdminWorkspaceMember",
implementedInterfaces: [],
keyFields: nil
)
}

View File

@@ -0,0 +1,12 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public extension Objects {
static let AdminWorkspaceSharedLink = ApolloAPI.Object(
typename: "AdminWorkspaceSharedLink",
implementedInterfaces: [],
keyFields: nil
)
}

View File

@@ -0,0 +1,12 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public extension Objects {
static let CalendarAccountObjectType = ApolloAPI.Object(
typename: "CalendarAccountObjectType",
implementedInterfaces: [],
keyFields: nil
)
}

View File

@@ -0,0 +1,12 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public extension Objects {
static let CalendarCalDAVProviderPresetObjectType = ApolloAPI.Object(
typename: "CalendarCalDAVProviderPresetObjectType",
implementedInterfaces: [],
keyFields: nil
)
}

View File

@@ -0,0 +1,12 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public extension Objects {
static let CalendarEventObjectType = ApolloAPI.Object(
typename: "CalendarEventObjectType",
implementedInterfaces: [],
keyFields: nil
)
}

View File

@@ -0,0 +1,12 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public extension Objects {
static let CalendarSubscriptionObjectType = ApolloAPI.Object(
typename: "CalendarSubscriptionObjectType",
implementedInterfaces: [],
keyFields: nil
)
}

View File

@@ -0,0 +1,12 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public extension Objects {
static let WorkspaceCalendarItemObjectType = ApolloAPI.Object(
typename: "WorkspaceCalendarItemObjectType",
implementedInterfaces: [],
keyFields: nil
)
}

View File

@@ -0,0 +1,12 @@
// @generated
// This file was automatically generated and should not be edited.
import ApolloAPI
public extension Objects {
static let WorkspaceCalendarObjectType = ApolloAPI.Object(
typename: "WorkspaceCalendarObjectType",
implementedInterfaces: [],
keyFields: nil
)
}

View File

@@ -20,6 +20,9 @@ public enum SchemaMetadata: ApolloAPI.SchemaMetadata {
public static func objectType(forTypename typename: String) -> ApolloAPI.Object? {
switch typename {
case "AdminWorkspace": return AffineGraphQL.Objects.AdminWorkspace
case "AdminWorkspaceMember": return AffineGraphQL.Objects.AdminWorkspaceMember
case "AdminWorkspaceSharedLink": return AffineGraphQL.Objects.AdminWorkspaceSharedLink
case "AggregateBucketHitsObjectType": return AffineGraphQL.Objects.AggregateBucketHitsObjectType
case "AggregateBucketObjectType": return AffineGraphQL.Objects.AggregateBucketObjectType
case "AggregateResultObjectType": return AffineGraphQL.Objects.AggregateResultObjectType
@@ -27,6 +30,10 @@ public enum SchemaMetadata: ApolloAPI.SchemaMetadata {
case "BlobUploadInit": return AffineGraphQL.Objects.BlobUploadInit
case "BlobUploadPart": return AffineGraphQL.Objects.BlobUploadPart
case "BlobUploadedPart": return AffineGraphQL.Objects.BlobUploadedPart
case "CalendarAccountObjectType": return AffineGraphQL.Objects.CalendarAccountObjectType
case "CalendarCalDAVProviderPresetObjectType": return AffineGraphQL.Objects.CalendarCalDAVProviderPresetObjectType
case "CalendarEventObjectType": return AffineGraphQL.Objects.CalendarEventObjectType
case "CalendarSubscriptionObjectType": return AffineGraphQL.Objects.CalendarSubscriptionObjectType
case "ChatMessage": return AffineGraphQL.Objects.ChatMessage
case "CommentChangeObjectType": return AffineGraphQL.Objects.CommentChangeObjectType
case "CommentChangeObjectTypeEdge": return AffineGraphQL.Objects.CommentChangeObjectTypeEdge
@@ -107,6 +114,8 @@ public enum SchemaMetadata: ApolloAPI.SchemaMetadata {
case "UserQuotaUsageType": return AffineGraphQL.Objects.UserQuotaUsageType
case "UserSettingsType": return AffineGraphQL.Objects.UserSettingsType
case "UserType": return AffineGraphQL.Objects.UserType
case "WorkspaceCalendarItemObjectType": return AffineGraphQL.Objects.WorkspaceCalendarItemObjectType
case "WorkspaceCalendarObjectType": return AffineGraphQL.Objects.WorkspaceCalendarObjectType
case "WorkspaceDocMeta": return AffineGraphQL.Objects.WorkspaceDocMeta
case "WorkspacePermissions": return AffineGraphQL.Objects.WorkspacePermissions
case "WorkspaceQuotaHumanReadableType": return AffineGraphQL.Objects.WorkspaceQuotaHumanReadableType

View File

@@ -45,13 +45,13 @@ EXTERNAL SOURCES:
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
SPEC CHECKSUMS:
Capacitor: a5bf59e09f9dd82694fdcca4d107b4d215ac470f
CapacitorApp: 3ddbd30ac18c321531c3da5e707b60873d89dd60
CapacitorBrowser: 66aa8ff09cdca2a327ce464b113b470e6f667753
Capacitor: 12914e6f1b7835e161a74ebd19cb361efa37a7dd
CapacitorApp: 63b237168fc869e758481dba283315a85743ee78
CapacitorBrowser: b98aa3db018a2ce4c68242d27e596c344f3b81b3
CapacitorCordova: 31bbe4466000c6b86d9b7f1181ee286cff0205aa
CapacitorHaptics: d17da7dd984cae34111b3f097ccd3e21f9feec62
CapacitorKeyboard: 45cae3956a6f4fb1753f9a4df3e884aeaed8fe82
CapacitorPluginAppTrackingTransparency: 2a2792623a5a72795f2e8f9ab3f1147573732fd8
CapacitorHaptics: ce15be8f287fa2c61c7d2d9e958885b90cf0bebc
CapacitorKeyboard: 5660c760113bfa48962817a785879373cf5339c3
CapacitorPluginAppTrackingTransparency: 92ae9c1cfb5cf477753db9269689332a686f675a
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082

View File

@@ -11,6 +11,25 @@ trap error_help ERR
# XCode tries to be helpful and overwrites the PATH. Reset that.
PATH="$(bash -l -c 'echo $PATH')"
# Resolve cargo binary: prefer ~/.cargo/bin, then PATH, then rustup
CARGO=""
if [ -x "$HOME/.cargo/bin/cargo" ]; then
CARGO="$HOME/.cargo/bin/cargo"
elif command -v cargo &>/dev/null; then
CARGO="$(command -v cargo)"
elif command -v rustup &>/dev/null; then
CARGO="$(rustup which cargo 2>/dev/null)" || true
fi
if [ -z "$CARGO" ] || [ ! -x "$CARGO" ]; then
echo "error: cargo not found. Install Rust via https://rustup.rs" >&2
exit 1
fi
# Ensure rustc and other toolchain binaries are on PATH
export PATH="$(dirname "$CARGO"):$PATH"
# Ensure IPHONEOS_DEPLOYMENT_TARGET is set for Rust/cc crate builds
export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-16.5}"
# This should be invoked from inside xcode, not manually
if [[ "${#}" -ne 3 ]]
then
@@ -47,20 +66,20 @@ for arch in $ARCHS; do
# Intel iOS simulator
export CFLAGS_x86_64_apple_ios="-target x86_64-apple-ios"
$HOME/.cargo/bin/cargo rustc -p "${FFI_TARGET}" --lib --crate-type staticlib --$RELFLAG --target x86_64-apple-ios --features use-as-lib
$CARGO rustc -p "${FFI_TARGET}" --lib --crate-type staticlib --$RELFLAG --target x86_64-apple-ios --features use-as-lib
;;
arm64)
if [ $IS_SIMULATOR -eq 0 ]; then
# Hardware iOS targets
$HOME/.cargo/bin/cargo rustc -p "${FFI_TARGET}" --lib --crate-type staticlib --$RELFLAG --target aarch64-apple-ios --features use-as-lib
$CARGO rustc -p "${FFI_TARGET}" --lib --crate-type staticlib --$RELFLAG --target aarch64-apple-ios --features use-as-lib
cp $SRC_ROOT/../../../target/aarch64-apple-ios/${RELFLAG}/lib${FFI_TARGET}.a $SRCROOT/lib${FFI_TARGET}.a
else
# M1 iOS simulator
$HOME/.cargo/bin/cargo rustc -p "${FFI_TARGET}" --lib --crate-type staticlib --$RELFLAG --target aarch64-apple-ios-sim --features use-as-lib
$CARGO rustc -p "${FFI_TARGET}" --lib --crate-type staticlib --$RELFLAG --target aarch64-apple-ios-sim --features use-as-lib
cp $SRC_ROOT/../../../target/aarch64-apple-ios-sim/${RELFLAG}/lib${FFI_TARGET}.a $SRCROOT/lib${FFI_TARGET}.a
fi
esac
done
$HOME/.cargo/bin/cargo run -p affine_mobile_native --features use-as-lib --bin uniffi-bindgen generate --library $SRCROOT/lib${FFI_TARGET}.a --language swift --out-dir $SRCROOT/../../ios/App/App/uniffi
$CARGO run -p affine_mobile_native --features use-as-lib --bin uniffi-bindgen generate --library $SRCROOT/lib${FFI_TARGET}.a --language swift --out-dir $SRCROOT/../../ios/App/App/uniffi

View File

@@ -21,6 +21,7 @@ execSync(
'cargo build -p affine_mobile_native --features use-as-lib --lib --release --target aarch64-apple-ios',
{
stdio: 'inherit',
env: { ...process.env, IPHONEOS_DEPLOYMENT_TARGET: '16.5' },
}
);

View File

@@ -87,6 +87,7 @@
"react-router-dom": "^6.30.3",
"react-transition-state": "^2.2.0",
"react-virtuoso": "^4.12.3",
"recharts": "^2.15.4",
"rxjs": "^7.8.2",
"semver": "^7.7.3",
"ses": "^1.14.0",

View File

@@ -43,6 +43,7 @@ import { focusBlockEnd } from '@blocksuite/affine/shared/commands';
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
import {
AiIcon,
ChartPanelIcon,
CommentIcon,
ExportIcon,
FrameIcon,
@@ -67,6 +68,7 @@ import * as styles from './detail-page.css';
import { DetailPageHeader } from './detail-page-header';
import { DetailPageWrapper } from './detail-page-wrapper';
import { EditorAdapterPanel } from './tabs/adapter';
import { EditorAnalyticsPanel } from './tabs/analytics';
import { EditorChatPanel } from './tabs/chat';
import { EditorFramePanel } from './tabs/frame';
import { EditorJournalPanel } from './tabs/journal';
@@ -433,6 +435,17 @@ const DetailPageImpl = memo(function DetailPageImpl() {
</ViewSidebarTab>
)}
{workspace.flavour === 'affine-cloud' && (
<ViewSidebarTab tabId="analytics" icon={<ChartPanelIcon />}>
<Scrollable.Root className={styles.sidebarScrollArea}>
<Scrollable.Viewport>
<EditorAnalyticsPanel workspaceId={workspace.id} docId={doc.id} />
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
</ViewSidebarTab>
)}
<GlobalPageHistoryModal />
{/* FIXME: wait for better ai, <PageAIOnboarding /> */}
</FrameworkScope>

View File

@@ -0,0 +1,229 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
minHeight: '100%',
padding: '16px',
gap: '20px',
});
export const section = style({
display: 'flex',
flexDirection: 'column',
gap: '12px',
});
export const sectionHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
});
export const sectionTitle = style({
display: 'flex',
alignItems: 'baseline',
gap: '8px',
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontBase'),
fontWeight: 600,
});
export const sectionSubtitle = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontSm'),
fontWeight: 500,
});
export const windowButton = style({
minWidth: '124px',
justifyContent: 'space-between',
});
export const lockButton = style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
background: 'transparent',
padding: 0,
margin: 0,
color: cssVar('textSecondaryColor'),
cursor: 'pointer',
selectors: {
'&:hover': {
color: cssVar('textPrimaryColor'),
},
},
});
export const metrics = style({
display: 'flex',
alignItems: 'stretch',
gap: '8px',
});
export const metricCard = style({
minWidth: '0',
flex: 1,
borderRadius: '10px',
border: `1px solid ${cssVar('borderColor')}`,
backgroundColor: cssVar('backgroundPrimaryColor'),
padding: '8px 10px',
display: 'flex',
flexDirection: 'column',
gap: '4px',
});
export const metricLabel = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
lineHeight: 1.2,
});
export const metricValue = style({
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontBase'),
fontWeight: 600,
lineHeight: 1.2,
fontVariantNumeric: 'tabular-nums',
});
export const chartContainer = style({
height: '228px',
borderRadius: '12px',
border: `1px solid ${cssVar('borderColor')}`,
backgroundColor: cssVar('backgroundPrimaryColor'),
padding: '10px 10px 8px 10px',
});
export const axisLabels = style({
display: 'flex',
justifyContent: 'space-between',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
fontVariantNumeric: 'tabular-nums',
marginTop: '4px',
});
export const chartLegend = style({
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
});
export const legendItem = style({
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
});
export const legendDot = style({
width: '8px',
height: '8px',
borderRadius: '50%',
});
export const tooltip = style({
minWidth: '160px',
borderRadius: '8px',
border: `1px solid ${cssVar('borderColor')}`,
backgroundColor: cssVar('backgroundPrimaryColor'),
boxShadow: cssVar('shadow2'),
padding: '8px 10px',
});
export const tooltipTitle = style({
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontSm'),
fontWeight: 500,
marginBottom: '6px',
});
export const tooltipRow = style({
display: 'flex',
alignItems: 'center',
gap: '6px',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
lineHeight: 1.4,
});
export const tooltipValue = style({
marginLeft: 'auto',
color: cssVar('textPrimaryColor'),
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
});
export const emptyState = style({
borderRadius: '10px',
border: `1px dashed ${cssVar('borderColor')}`,
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontSm'),
minHeight: '96px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
padding: '0 16px',
});
export const viewersList = style({
display: 'flex',
flexDirection: 'column',
gap: '6px',
});
export const viewerRow = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
borderRadius: '8px',
padding: '6px 8px',
selectors: {
'&:hover': {
backgroundColor: cssVar('hoverColor'),
},
},
});
export const viewerUser = style({
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: '8px',
});
export const viewerName = style({
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontSm'),
fontWeight: 500,
});
export const viewerTime = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontSm'),
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
});
export const loadMoreButton = style({
alignSelf: 'flex-start',
});
export const loading = style({
minHeight: '120px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});

View File

@@ -0,0 +1,511 @@
import {
Avatar,
Button,
Loading,
Menu,
MenuItem,
toast,
} from '@affine/component';
import { useQuery } from '@affine/core/components/hooks/use-query';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import {
getDocLastAccessedMembersQuery,
getDocPageAnalyticsQuery,
} from '@affine/graphql';
import { i18nTime, useI18n } from '@affine/i18n';
import {
ArrowDownSmallIcon,
CalendarPanelIcon,
LockIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Area,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip as RechartsTooltip,
type TooltipProps,
XAxis,
YAxis,
} from 'recharts';
import * as styles from './analytics.css';
import {
type AnalyticsChartPoint,
buildAnalyticsChartPoints,
clampAnalyticsWindowDays,
DEFAULT_ANALYTICS_WINDOW_DAYS,
ensureMinimumChartPoints,
getAvailableAnalyticsWindowOptions,
INITIAL_MEMBERS_PAGE_SIZE,
isLockedAnalyticsWindowOption,
MAX_MEMBERS_PAGE_SIZE,
} from './analytics.utils';
const intFormatter = new Intl.NumberFormat('en-US');
const totalViewsColor = cssVar('primaryColor');
const uniqueViewsColor = cssVar('processingColor');
function formatChartDate(value: string) {
return i18nTime(value, { absolute: { accuracy: 'day' } });
}
function AnalyticsChartTooltip({
active,
payload,
}: TooltipProps<number, string>) {
const t = useI18n();
if (!active || !payload?.length) {
return null;
}
const point = payload[0]?.payload as AnalyticsChartPoint | undefined;
if (!point) {
return null;
}
const valueByKey = payload.reduce<Record<string, number>>((acc, item) => {
if (!item.dataKey) {
return acc;
}
acc[String(item.dataKey)] =
typeof item.value === 'number' ? item.value : Number(item.value ?? 0);
return acc;
}, {});
return (
<div className={styles.tooltip}>
<div className={styles.tooltipTitle}>{formatChartDate(point.date)}</div>
<div className={styles.tooltipRow}>
<span
className={styles.legendDot}
style={{ backgroundColor: totalViewsColor }}
aria-hidden="true"
/>
{t['com.affine.doc.analytics.chart.total-views']()}
<span className={styles.tooltipValue}>
{intFormatter.format(valueByKey.totalViews ?? point.totalViews)}
</span>
</div>
<div className={styles.tooltipRow}>
<span
className={styles.legendDot}
style={{ backgroundColor: uniqueViewsColor }}
aria-hidden="true"
/>
{t['com.affine.doc.analytics.chart.unique-views']()}
<span className={styles.tooltipValue}>
{intFormatter.format(valueByKey.uniqueViews ?? point.uniqueViews)}
</span>
</div>
</div>
);
}
export const EditorAnalyticsPanel = ({
workspaceId,
docId,
}: {
workspaceId: string;
docId: string;
}) => {
const t = useI18n();
const permission = useService(WorkspacePermissionService).permission;
const workspaceDialogService = useService(WorkspaceDialogService);
const isTeam = useLiveData(permission.isTeam$);
const isTeamWorkspace = isTeam ?? false;
const [windowDays, setWindowDays] = useState(DEFAULT_ANALYTICS_WINDOW_DAYS);
const [membersPageSize, setMembersPageSize] = useState(
INITIAL_MEMBERS_PAGE_SIZE
);
const allowedWindowOptions = useMemo(
() => getAvailableAnalyticsWindowOptions(),
[]
);
const effectiveWindowDays = useMemo(
() => clampAnalyticsWindowDays(windowDays, isTeamWorkspace),
[isTeamWorkspace, windowDays]
);
const timezone = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
[]
);
useEffect(() => {
setMembersPageSize(INITIAL_MEMBERS_PAGE_SIZE);
}, [docId]);
useEffect(() => {
permission.revalidate();
}, [permission, workspaceId]);
useEffect(() => {
if (windowDays !== effectiveWindowDays) {
setWindowDays(effectiveWindowDays);
}
}, [effectiveWindowDays, windowDays]);
const {
data: analyticsData,
isLoading: analyticsLoading,
error: analyticsError,
} = useQuery(
{
query: getDocPageAnalyticsQuery,
variables: {
workspaceId,
docId,
input: {
windowDays: effectiveWindowDays,
timezone,
},
},
},
{
suspense: false,
keepPreviousData: true,
revalidateOnFocus: false,
shouldRetryOnError: false,
}
);
const {
data: membersData,
isLoading: membersLoading,
error: membersError,
} = useQuery(
{
query: getDocLastAccessedMembersQuery,
variables: {
workspaceId,
docId,
pagination: {
first: membersPageSize,
offset: 0,
},
includeTotal: true,
},
},
{
suspense: false,
keepPreviousData: true,
revalidateOnFocus: false,
shouldRetryOnError: false,
}
);
const analytics = analyticsData?.workspace.doc.analytics;
const summary = analytics?.summary;
const chartPoints = useMemo(
() =>
ensureMinimumChartPoints(
buildAnalyticsChartPoints(analytics?.series ?? [])
),
[analytics?.series]
);
const membersConnection = membersData?.workspace.doc.lastAccessedMembers;
const members = useMemo(
() => membersConnection?.edges.map(edge => edge.node) ?? [],
[membersConnection?.edges]
);
const totalMembers = membersConnection?.totalCount ?? members.length;
const hasMoreMembers =
Boolean(membersConnection?.pageInfo.hasNextPage) &&
membersPageSize < MAX_MEMBERS_PAGE_SIZE;
const openTeamPricing = useCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'plans',
scrollAnchor: 'cloudPricingPlan',
});
}, [workspaceDialogService]);
const showTeamPlanToast = useCallback(() => {
toast(t['com.affine.doc.analytics.paywall.toast']());
}, [t]);
return (
<div className={styles.root}>
<section className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>
<span>{t['com.affine.doc.analytics.title']()}</span>
<span className={styles.sectionSubtitle}>
{summary
? t.t('com.affine.doc.analytics.summary.total', {
count: intFormatter.format(summary.totalViews),
})
: ''}
</span>
</div>
<Menu
contentOptions={{ align: 'end' }}
items={
<>
{allowedWindowOptions.map(option => {
const isLocked = isLockedAnalyticsWindowOption(
option,
isTeamWorkspace
);
return (
<MenuItem
key={option}
selected={effectiveWindowDays === option}
suffixIcon={
isLocked ? (
<button
type="button"
className={styles.lockButton}
aria-label={t[
'com.affine.doc.analytics.paywall.open-pricing'
]()}
onClick={event => {
event.preventDefault();
event.stopPropagation();
openTeamPricing();
}}
>
<LockIcon />
</button>
) : undefined
}
onSelect={() => {
if (isLocked) {
showTeamPlanToast();
return;
}
setWindowDays(option);
}}
>
{t.t('com.affine.doc.analytics.window.last-days', {
days: option,
})}
{isLocked
? ` (${t['com.affine.payment.cloud.team-workspace.name']()})`
: ''}
</MenuItem>
);
})}
</>
}
>
<Button
variant="secondary"
size="default"
className={styles.windowButton}
prefix={<CalendarPanelIcon />}
suffix={<ArrowDownSmallIcon />}
>
{t.t('com.affine.doc.analytics.window.last-days', {
days: effectiveWindowDays,
})}
</Button>
</Menu>
</div>
<div className={styles.metrics}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
{t['com.affine.doc.analytics.metric.total']()}
</div>
<div className={styles.metricValue}>
{intFormatter.format(summary?.totalViews ?? 0)}
</div>
</div>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
{t['com.affine.doc.analytics.metric.unique']()}
</div>
<div className={styles.metricValue}>
{intFormatter.format(summary?.uniqueViews ?? 0)}
</div>
</div>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
{t['com.affine.doc.analytics.metric.guest']()}
</div>
<div className={styles.metricValue}>
{intFormatter.format(summary?.guestViews ?? 0)}
</div>
</div>
</div>
{analyticsLoading && !analytics ? (
<div className={styles.loading}>
<Loading size={20} />
</div>
) : analyticsError && !analytics ? (
<div className={styles.emptyState}>
{t['com.affine.doc.analytics.error.load-analytics']()}
</div>
) : chartPoints.length ? (
<>
<div className={styles.chartContainer}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartPoints}
margin={{ top: 10, right: 6, bottom: 6, left: 6 }}
>
<CartesianGrid
vertical={false}
stroke={cssVar('borderColor')}
strokeDasharray="3 3"
/>
<XAxis
dataKey="x"
type="number"
hide
allowDecimals={false}
domain={['dataMin', 'dataMax']}
/>
<YAxis
hide
domain={[
0,
(max: number) => {
if (max <= 0) {
return 1;
}
return Math.ceil(max * 1.1);
},
]}
/>
<RechartsTooltip
cursor={{
stroke: cssVar('borderColor'),
strokeDasharray: '4 4',
}}
content={<AnalyticsChartTooltip />}
/>
<Area
dataKey="totalViews"
type="monotone"
stroke={totalViewsColor}
fill={totalViewsColor}
fillOpacity={0.15}
isAnimationActive={false}
/>
<Area
dataKey="uniqueViews"
type="monotone"
stroke={uniqueViewsColor}
fill={uniqueViewsColor}
fillOpacity={0.1}
isAnimationActive={false}
/>
<Line
dataKey="totalViews"
type="monotone"
stroke={totalViewsColor}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
<Line
dataKey="uniqueViews"
type="monotone"
stroke={uniqueViewsColor}
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className={styles.axisLabels}>
<span>{formatChartDate(chartPoints[0].date)}</span>
<span>
{formatChartDate(chartPoints[chartPoints.length - 1].date)}
</span>
</div>
<div className={styles.chartLegend}>
<span className={styles.legendItem}>
<span
className={styles.legendDot}
style={{ backgroundColor: totalViewsColor }}
aria-hidden="true"
/>
{t['com.affine.doc.analytics.chart.total-views']()}
</span>
<span className={styles.legendItem}>
<span
className={styles.legendDot}
style={{ backgroundColor: uniqueViewsColor }}
aria-hidden="true"
/>
{t['com.affine.doc.analytics.chart.unique-views']()}
</span>
</div>
</>
) : (
<div className={styles.emptyState}>
{t['com.affine.doc.analytics.empty.no-page-views']()}
</div>
)}
</section>
<section className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>
<span>{t['com.affine.doc.analytics.viewers.title']()}</span>
<span className={styles.sectionSubtitle}>
({intFormatter.format(totalMembers)})
</span>
</div>
</div>
{membersLoading && !membersConnection ? (
<div className={styles.loading}>
<Loading size={20} />
</div>
) : membersError && !membersConnection ? (
<div className={styles.emptyState}>
{t['com.affine.doc.analytics.error.load-viewers']()}
</div>
) : members.length ? (
<>
<div className={styles.viewersList}>
{members.map(member => (
<div className={styles.viewerRow} key={member.user.id}>
<div className={styles.viewerUser}>
<Avatar
size={24}
url={member.user.avatarUrl || ''}
name={member.user.name}
/>
<span className={styles.viewerName}>
{member.user.name}
</span>
</div>
<span className={styles.viewerTime}>
{i18nTime(member.lastAccessedAt, { relative: true })}
</span>
</div>
))}
</div>
{hasMoreMembers ? (
<Button
variant="plain"
className={styles.loadMoreButton}
onClick={() => setMembersPageSize(MAX_MEMBERS_PAGE_SIZE)}
>
{t['com.affine.doc.analytics.viewers.show-all']()}
</Button>
) : null}
</>
) : (
<div className={styles.emptyState}>
{t['com.affine.doc.analytics.empty.no-viewers']()}
</div>
)}
</section>
</div>
);
};

View File

@@ -0,0 +1,107 @@
import { describe, expect, test } from 'vitest';
import {
ANALYTICS_WINDOW_OPTIONS,
buildAnalyticsChartPoints,
clampAnalyticsWindowDays,
DEFAULT_ANALYTICS_WINDOW_DAYS,
ensureMinimumChartPoints,
getAvailableAnalyticsWindowOptions,
isLockedAnalyticsWindowOption,
NON_TEAM_ANALYTICS_WINDOW_DAYS,
} from './analytics.utils';
describe('analytics.utils', () => {
test('clampAnalyticsWindowDays returns default for unsupported values', () => {
expect(clampAnalyticsWindowDays(28, true)).toBe(28);
expect(clampAnalyticsWindowDays(15, true)).toBe(
DEFAULT_ANALYTICS_WINDOW_DAYS
);
});
test('clampAnalyticsWindowDays returns only 7 days for non-team workspaces', () => {
expect(clampAnalyticsWindowDays(7, false)).toBe(
NON_TEAM_ANALYTICS_WINDOW_DAYS
);
expect(clampAnalyticsWindowDays(28, false)).toBe(
NON_TEAM_ANALYTICS_WINDOW_DAYS
);
});
test('getAvailableAnalyticsWindowOptions keeps all options visible', () => {
expect(getAvailableAnalyticsWindowOptions()).toEqual([
...ANALYTICS_WINDOW_OPTIONS,
]);
});
test('isLockedAnalyticsWindowOption locks windows over 7 days for non-team workspaces', () => {
expect(
isLockedAnalyticsWindowOption(NON_TEAM_ANALYTICS_WINDOW_DAYS, false)
).toBe(false);
expect(isLockedAnalyticsWindowOption(14, false)).toBe(true);
expect(isLockedAnalyticsWindowOption(14, true)).toBe(false);
});
test('buildAnalyticsChartPoints sorts series by date and maps values', () => {
const points = buildAnalyticsChartPoints([
{
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
{
date: '2026-02-10',
totalViews: 9,
uniqueViews: 3,
guestViews: 0,
},
]);
expect(points).toEqual([
{
x: 0,
date: '2026-02-10',
totalViews: 9,
uniqueViews: 3,
guestViews: 0,
},
{
x: 1,
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
]);
});
test('ensureMinimumChartPoints duplicates the only data point', () => {
const points = ensureMinimumChartPoints([
{
x: 0,
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
]);
expect(points).toEqual([
{
x: 0,
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
{
x: 1,
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
]);
});
});

View File

@@ -0,0 +1,72 @@
import type { GetDocPageAnalyticsQuery } from '@affine/graphql';
export const ANALYTICS_WINDOW_OPTIONS = [7, 14, 28, 60, 90] as const;
export const DEFAULT_ANALYTICS_WINDOW_DAYS = 28;
export const NON_TEAM_ANALYTICS_WINDOW_DAYS = 7;
export const INITIAL_MEMBERS_PAGE_SIZE = 5;
export const MAX_MEMBERS_PAGE_SIZE = 50;
export type AnalyticsSeriesPoint =
GetDocPageAnalyticsQuery['workspace']['doc']['analytics']['series'][number];
export type AnalyticsChartPoint = {
x: number;
date: string;
totalViews: number;
uniqueViews: number;
guestViews: number;
};
export function getAvailableAnalyticsWindowOptions() {
return [...ANALYTICS_WINDOW_OPTIONS];
}
export function isLockedAnalyticsWindowOption(
value: number,
isTeamWorkspace: boolean
) {
return !isTeamWorkspace && value > NON_TEAM_ANALYTICS_WINDOW_DAYS;
}
export function clampAnalyticsWindowDays(
value: number,
isTeamWorkspace: boolean
) {
if (!isTeamWorkspace) {
return NON_TEAM_ANALYTICS_WINDOW_DAYS;
}
return ANALYTICS_WINDOW_OPTIONS.includes(
value as (typeof ANALYTICS_WINDOW_OPTIONS)[number]
)
? value
: DEFAULT_ANALYTICS_WINDOW_DAYS;
}
export function buildAnalyticsChartPoints(series: AnalyticsSeriesPoint[]) {
const sorted = [...series].sort(
(left, right) =>
new Date(left.date).getTime() - new Date(right.date).getTime()
);
return sorted.map((point, index) => ({
x: index,
date: point.date,
totalViews: point.totalViews,
uniqueViews: point.uniqueViews,
guestViews: point.guestViews,
})) satisfies AnalyticsChartPoint[];
}
export function ensureMinimumChartPoints(points: AnalyticsChartPoint[]) {
if (points.length !== 1) {
return points;
}
return [
points[0],
{
...points[0],
x: points[0].x + 1,
},
] satisfies AnalyticsChartPoint[];
}

View File

@@ -1,27 +1,27 @@
{
"ar": 97,
"ar": 96,
"ca": 98,
"da": 4,
"de": 98,
"el-GR": 97,
"de": 97,
"el-GR": 96,
"en": 100,
"es-AR": 97,
"es-AR": 96,
"es-CL": 98,
"es": 97,
"fa": 97,
"es": 96,
"fa": 96,
"fr": 98,
"hi": 2,
"hi": 1,
"it-IT": 98,
"it": 1,
"ja": 97,
"ko": 98,
"nb-NO": 48,
"ja": 96,
"ko": 97,
"nb-NO": 47,
"pl": 98,
"pt-BR": 97,
"pt-BR": 96,
"ru": 98,
"sv-SE": 97,
"uk": 97,
"uk": 96,
"ur": 2,
"zh-Hans": 99,
"zh-Hans": 98,
"zh-Hant": 97
}

View File

@@ -4574,6 +4574,74 @@ export function useAFFiNEI18N(): {
* `Copied key to clipboard`
*/
["com.affine.payment.license-success.copy"](): string;
/**
* `View analytics`
*/
["com.affine.doc.analytics.title"](): string;
/**
* `({{count}} total)`
*/
["com.affine.doc.analytics.summary.total"](options: {
readonly count: string;
}): string;
/**
* `Last {{days}} days`
*/
["com.affine.doc.analytics.window.last-days"](options: {
readonly days: string;
}): string;
/**
* `Total`
*/
["com.affine.doc.analytics.metric.total"](): string;
/**
* `Unique`
*/
["com.affine.doc.analytics.metric.unique"](): string;
/**
* `Guest`
*/
["com.affine.doc.analytics.metric.guest"](): string;
/**
* `Total views`
*/
["com.affine.doc.analytics.chart.total-views"](): string;
/**
* `Unique views`
*/
["com.affine.doc.analytics.chart.unique-views"](): string;
/**
* `Unable to load analytics.`
*/
["com.affine.doc.analytics.error.load-analytics"](): string;
/**
* `Unable to load viewers.`
*/
["com.affine.doc.analytics.error.load-viewers"](): string;
/**
* `No page views in this window.`
*/
["com.affine.doc.analytics.empty.no-page-views"](): string;
/**
* `No viewers in this window.`
*/
["com.affine.doc.analytics.empty.no-viewers"](): string;
/**
* `Viewers`
*/
["com.affine.doc.analytics.viewers.title"](): string;
/**
* `Show all viewers`
*/
["com.affine.doc.analytics.viewers.show-all"](): string;
/**
* `Open pricing plans`
*/
["com.affine.doc.analytics.paywall.open-pricing"](): string;
/**
* `Doc analytics over 7 days require an AFFiNE Team subscription.`
*/
["com.affine.doc.analytics.paywall.toast"](): string;
/**
* `Close`
*/

View File

@@ -1134,6 +1134,22 @@
"com.affine.payment.license-success.hint": "You can use this key to upgrade in Settings > Workspace > License > Use purchased key",
"com.affine.payment.license-success.open-affine": "Open AFFiNE",
"com.affine.payment.license-success.copy": "Copied key to clipboard",
"com.affine.doc.analytics.title": "View analytics",
"com.affine.doc.analytics.summary.total": "({{count}} total)",
"com.affine.doc.analytics.window.last-days": "Last {{days}} days",
"com.affine.doc.analytics.metric.total": "Total",
"com.affine.doc.analytics.metric.unique": "Unique",
"com.affine.doc.analytics.metric.guest": "Guest",
"com.affine.doc.analytics.chart.total-views": "Total views",
"com.affine.doc.analytics.chart.unique-views": "Unique views",
"com.affine.doc.analytics.error.load-analytics": "Unable to load analytics.",
"com.affine.doc.analytics.error.load-viewers": "Unable to load viewers.",
"com.affine.doc.analytics.empty.no-page-views": "No page views in this window.",
"com.affine.doc.analytics.empty.no-viewers": "No viewers in this window.",
"com.affine.doc.analytics.viewers.title": "Viewers",
"com.affine.doc.analytics.viewers.show-all": "Show all viewers",
"com.affine.doc.analytics.paywall.open-pricing": "Open pricing plans",
"com.affine.doc.analytics.paywall.toast": "Doc analytics over 7 days require an AFFiNE Team subscription.",
"com.affine.peek-view-controls.close": "Close",
"com.affine.peek-view-controls.open-doc": "Open this doc",
"com.affine.peek-view-controls.open-doc-in-edgeless": "Open in edgeless",

View File

@@ -7,7 +7,10 @@
"children": {
"auth": "auth",
"setup": "setup",
"dashboard": "dashboard",
"accounts": "accounts",
"workspaces": "workspaces",
"queue": "queue",
"ai": "ai",
"settings": {
"route": "settings",

View File

@@ -11,6 +11,7 @@ export const ROUTES = {
index: '/admin',
auth: '/admin/auth',
setup: '/admin/setup',
dashboard: '/admin/dashboard',
accounts: '/admin/accounts',
workspaces: '/admin/workspaces',
queue: '/admin/queue',
@@ -29,6 +30,7 @@ export const RELATIVE_ROUTES = {
index: 'admin',
auth: 'auth',
setup: 'setup',
dashboard: 'dashboard',
accounts: 'accounts',
workspaces: 'workspaces',
queue: 'queue',
@@ -45,6 +47,7 @@ const home = () => '/';
const admin = () => '/admin';
admin.auth = () => '/admin/auth';
admin.setup = () => '/admin/setup';
admin.dashboard = () => '/admin/dashboard';
admin.accounts = () => '/admin/accounts';
admin.workspaces = () => '/admin/workspaces';
admin.queue = () => '/admin/queue';

160
yarn.lock
View File

@@ -231,6 +231,7 @@ __metadata:
react-hook-form: "npm:^7.54.1"
react-resizable-panels: "npm:^3.0.6"
react-router-dom: "npm:^7.12.0"
recharts: "npm:^2.15.4"
shadcn-ui: "npm:^0.9.5"
sonner: "npm:^2.0.7"
swr: "npm:^2.3.7"
@@ -481,6 +482,7 @@ __metadata:
react-router-dom: "npm:^6.30.3"
react-transition-state: "npm:^2.2.0"
react-virtuoso: "npm:^4.12.3"
recharts: "npm:^2.15.4"
rxjs: "npm:^7.8.2"
semver: "npm:^7.7.3"
ses: "npm:^1.14.0"
@@ -1776,10 +1778,10 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.27.1, @babel/runtime@npm:^7.7.6":
version: 7.27.1
resolution: "@babel/runtime@npm:7.27.1"
checksum: 10/34cefcbf781ea5a4f1b93f8563327b9ac82694bebdae91e8bd9d7f58d084cbe5b9a6e7f94d77076e15b0bcdaa0040a36cb30737584028df6c4673b4c67b2a31d
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.27.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7":
version: 7.28.6
resolution: "@babel/runtime@npm:7.28.6"
checksum: 10/fbcd439cb74d4a681958eb064c509829e3f46d8a4bfaaf441baa81bb6733d1e680bccc676c813883d7741bcaada1d0d04b15aa320ef280b5734e2192b50decf9
languageName: node
linkType: hard
@@ -16704,7 +16706,7 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-array@npm:*":
"@types/d3-array@npm:*, @types/d3-array@npm:^3.0.3":
version: 3.2.2
resolution: "@types/d3-array@npm:3.2.2"
checksum: 10/1afebd05b688cafaaea295f765b409789f088b274b8a7ca40a4bc2b79760044a898e06a915f40bbc59cf39eabdd2b5d32e960b136fc025fd05c9a9d4435614c6
@@ -16783,7 +16785,7 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-ease@npm:*":
"@types/d3-ease@npm:*, @types/d3-ease@npm:^3.0.0":
version: 3.0.2
resolution: "@types/d3-ease@npm:3.0.2"
checksum: 10/d8f92a8a7a008da71f847a16227fdcb53a8938200ecdf8d831ab6b49aba91e8921769761d3bfa7e7191b28f62783bfd8b0937e66bae39d4dd7fb0b63b50d4a94
@@ -16829,7 +16831,7 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-interpolate@npm:*":
"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:^3.0.1":
version: 3.0.4
resolution: "@types/d3-interpolate@npm:3.0.4"
dependencies:
@@ -16873,7 +16875,7 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-scale@npm:*":
"@types/d3-scale@npm:*, @types/d3-scale@npm:^4.0.2":
version: 4.0.9
resolution: "@types/d3-scale@npm:4.0.9"
dependencies:
@@ -16889,12 +16891,12 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-shape@npm:*":
version: 3.1.7
resolution: "@types/d3-shape@npm:3.1.7"
"@types/d3-shape@npm:*, @types/d3-shape@npm:^3.1.0":
version: 3.1.8
resolution: "@types/d3-shape@npm:3.1.8"
dependencies:
"@types/d3-path": "npm:*"
checksum: 10/b7ddda2a9c916ba438308bfa6e53fa2bb11c2ce13537ba2a7816c16f9432287b57901921c7231d2924f2d7d360535c3795f017865ab05abe5057c6ca06ca81df
checksum: 10/ebc161d49101d84409829fea516ba7ec71ad51a1e97438ca0fafc1c30b56b3feae802d220375323632723a338dda7237c652e831e0b53527a6222ab0d1bb7809
languageName: node
linkType: hard
@@ -16905,14 +16907,14 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-time@npm:*":
"@types/d3-time@npm:*, @types/d3-time@npm:^3.0.0":
version: 3.0.4
resolution: "@types/d3-time@npm:3.0.4"
checksum: 10/b1eb4255066da56023ad243fd4ae5a20462d73bd087a0297c7d49ece42b2304a4a04297568c604a38541019885b2bc35a9e0fd704fad218e9bc9c5f07dc685ce
languageName: node
linkType: hard
"@types/d3-timer@npm:*":
"@types/d3-timer@npm:*, @types/d3-timer@npm:^3.0.0":
version: 3.0.2
resolution: "@types/d3-timer@npm:3.0.2"
checksum: 10/1643eebfa5f4ae3eb00b556bbc509444d88078208ec2589ddd8e4a24f230dd4cf2301e9365947e70b1bee33f63aaefab84cd907822aae812b9bc4871b98ab0e1
@@ -21763,7 +21765,7 @@ __metadata:
languageName: node
linkType: hard
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:2.5.0 - 3, d3-array@npm:3, d3-array@npm:^3.2.0":
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:2.5.0 - 3, d3-array@npm:3, d3-array@npm:^3.1.6, d3-array@npm:^3.2.0":
version: 3.2.4
resolution: "d3-array@npm:3.2.4"
dependencies:
@@ -21864,7 +21866,7 @@ __metadata:
languageName: node
linkType: hard
"d3-ease@npm:1 - 3, d3-ease@npm:3":
"d3-ease@npm:1 - 3, d3-ease@npm:3, d3-ease@npm:^3.0.1":
version: 3.0.1
resolution: "d3-ease@npm:3.0.1"
checksum: 10/985d46e868494e9e6806fedd20bad712a50dcf98f357bf604a843a9f6bc17714a657c83dd762f183173dcde983a3570fa679b2bc40017d40b24163cdc4167796
@@ -21914,7 +21916,7 @@ __metadata:
languageName: node
linkType: hard
"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:3":
"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:3, d3-interpolate@npm:^3.0.1":
version: 3.0.1
resolution: "d3-interpolate@npm:3.0.1"
dependencies:
@@ -21978,7 +21980,7 @@ __metadata:
languageName: node
linkType: hard
"d3-scale@npm:4":
"d3-scale@npm:4, d3-scale@npm:^4.0.2":
version: 4.0.2
resolution: "d3-scale@npm:4.0.2"
dependencies:
@@ -21998,7 +22000,7 @@ __metadata:
languageName: node
linkType: hard
"d3-shape@npm:3":
"d3-shape@npm:3, d3-shape@npm:^3.1.0":
version: 3.2.0
resolution: "d3-shape@npm:3.2.0"
dependencies:
@@ -22025,7 +22027,7 @@ __metadata:
languageName: node
linkType: hard
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:3":
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:3, d3-time@npm:^3.0.0":
version: 3.1.0
resolution: "d3-time@npm:3.1.0"
dependencies:
@@ -22034,7 +22036,7 @@ __metadata:
languageName: node
linkType: hard
"d3-timer@npm:1 - 3, d3-timer@npm:3":
"d3-timer@npm:1 - 3, d3-timer@npm:3, d3-timer@npm:^3.0.1":
version: 3.0.1
resolution: "d3-timer@npm:3.0.1"
checksum: 10/004128602bb187948d72c7dc153f0f063f38ac7a584171de0b45e3a841ad2e17f1e40ad396a4af9cce5551b6ab4a838d5246d23492553843d9da4a4050a911e2
@@ -22245,6 +22247,13 @@ __metadata:
languageName: node
linkType: hard
"decimal.js-light@npm:^2.4.1":
version: 2.5.1
resolution: "decimal.js-light@npm:2.5.1"
checksum: 10/6360911e31221a9b8b90e23020aa969d104e182c5c6518589cdfedc3ced31bf1f19cf931e265bd451ae6ee3a35ee15e81f5456a86813606fda96f8374616688f
languageName: node
linkType: hard
"decimal.js@npm:^10.2.0, decimal.js@npm:^10.4.3":
version: 10.6.0
resolution: "decimal.js@npm:10.6.0"
@@ -22622,6 +22631,16 @@ __metadata:
languageName: node
linkType: hard
"dom-helpers@npm:^5.0.1":
version: 5.2.1
resolution: "dom-helpers@npm:5.2.1"
dependencies:
"@babel/runtime": "npm:^7.8.7"
csstype: "npm:^3.0.2"
checksum: 10/bed2341adf8864bf932b3289c24f35fdd99930af77df46688abf2d753ff291df49a15850c874d686d9be6ec4e1c6835673906e64dbd8b2839d227f117a11fd41
languageName: node
linkType: hard
"dom-serializer@npm:^1.0.1":
version: 1.4.1
resolution: "dom-serializer@npm:1.4.1"
@@ -23881,7 +23900,7 @@ __metadata:
languageName: node
linkType: hard
"eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.4":
"eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.1, eventemitter3@npm:^4.0.4":
version: 4.0.7
resolution: "eventemitter3@npm:4.0.7"
checksum: 10/8030029382404942c01d0037079f1b1bc8fed524b5849c237b80549b01e2fc49709e1d0c557fa65ca4498fc9e24cff1475ef7b855121fcc15f9d61f93e282346
@@ -24208,6 +24227,13 @@ __metadata:
languageName: node
linkType: hard
"fast-equals@npm:^5.0.1":
version: 5.4.0
resolution: "fast-equals@npm:5.4.0"
checksum: 10/bea068ceb7825d486d88a17ccc3fe889d1833efefa8dc64c83806e797f66b3ea953ac4aebd96af022d828de315ec87476e76418a5da774217d0ab66de53d68f5
languageName: node
linkType: hard
"fast-glob@npm:3.3.3, fast-glob@npm:^3.2.7, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.2, fast-glob@npm:^3.3.3":
version: 3.3.3
resolution: "fast-glob@npm:3.3.3"
@@ -32311,7 +32337,7 @@ __metadata:
languageName: node
linkType: hard
"prop-types@npm:^15, prop-types@npm:^15.8.1":
"prop-types@npm:^15, prop-types@npm:^15.6.2, prop-types@npm:^15.8.1":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
dependencies:
@@ -32843,6 +32869,13 @@ __metadata:
languageName: node
linkType: hard
"react-is@npm:^18.3.1":
version: 18.3.1
resolution: "react-is@npm:18.3.1"
checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22
languageName: node
linkType: hard
"react-json-tree@npm:^0.20.0":
version: 0.20.0
resolution: "react-json-tree@npm:0.20.0"
@@ -33009,6 +33042,20 @@ __metadata:
languageName: node
linkType: hard
"react-smooth@npm:^4.0.4":
version: 4.0.4
resolution: "react-smooth@npm:4.0.4"
dependencies:
fast-equals: "npm:^5.0.1"
prop-types: "npm:^15.8.1"
react-transition-group: "npm:^4.4.5"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10/cc5593356d154253f61a2c0b7b2fa8a979527495e2fe47c4252628d86e93c72c75df988c5438867d373de4e5a47d871ab9262474c02e66c411f94f047ecb5b0f
languageName: node
linkType: hard
"react-stately@npm:^3.43.0":
version: 3.43.0
resolution: "react-stately@npm:3.43.0"
@@ -33061,6 +33108,21 @@ __metadata:
languageName: node
linkType: hard
"react-transition-group@npm:^4.4.5":
version: 4.4.5
resolution: "react-transition-group@npm:4.4.5"
dependencies:
"@babel/runtime": "npm:^7.5.5"
dom-helpers: "npm:^5.0.1"
loose-envify: "npm:^1.4.0"
prop-types: "npm:^15.6.2"
peerDependencies:
react: ">=16.6.0"
react-dom: ">=16.6.0"
checksum: 10/ca32d3fd2168c976c5d90a317f25d5f5cd723608b415fb3b9006f9d793c8965c619562d0884503a3e44e4b06efbca4fdd1520f30e58ca3e00a0890e637d55419
languageName: node
linkType: hard
"react-transition-state@npm:^2.2.0":
version: 2.3.1
resolution: "react-transition-state@npm:2.3.1"
@@ -33189,6 +33251,34 @@ __metadata:
languageName: node
linkType: hard
"recharts-scale@npm:^0.4.4":
version: 0.4.5
resolution: "recharts-scale@npm:0.4.5"
dependencies:
decimal.js-light: "npm:^2.4.1"
checksum: 10/6e1118635018bd0622b5e978e56a8764ced5741140709e025c5989a0cb40c4b0bebb7c4e231c11ab8d6127a85fef8c68d92662c6f3c22af9551737a767cea014
languageName: node
linkType: hard
"recharts@npm:^2.15.4":
version: 2.15.4
resolution: "recharts@npm:2.15.4"
dependencies:
clsx: "npm:^2.0.0"
eventemitter3: "npm:^4.0.1"
lodash: "npm:^4.17.21"
react-is: "npm:^18.3.1"
react-smooth: "npm:^4.0.4"
recharts-scale: "npm:^0.4.4"
tiny-invariant: "npm:^1.3.1"
victory-vendor: "npm:^36.6.8"
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10/d29656d465ccdfcf95de7bc35e53c1f30fbd45e3d7b53c70bc2cd1892707058606eab8b0a648c1e1d0b3f21cc3b6774abf3f304da4d4534f550500909623492f
languageName: node
linkType: hard
"rechoir@npm:^0.8.0":
version: 0.8.0
resolution: "rechoir@npm:0.8.0"
@@ -35862,7 +35952,7 @@ __metadata:
languageName: node
linkType: hard
"tiny-invariant@npm:^1.3.3":
"tiny-invariant@npm:^1.3.1, tiny-invariant@npm:^1.3.3":
version: 1.3.3
resolution: "tiny-invariant@npm:1.3.3"
checksum: 10/5e185c8cc2266967984ce3b352a4e57cb89dad5a8abb0dea21468a6ecaa67cd5bb47a3b7a85d08041008644af4f667fb8b6575ba38ba5fb00b3b5068306e59fe
@@ -37105,6 +37195,28 @@ __metadata:
languageName: node
linkType: hard
"victory-vendor@npm:^36.6.8":
version: 36.9.2
resolution: "victory-vendor@npm:36.9.2"
dependencies:
"@types/d3-array": "npm:^3.0.3"
"@types/d3-ease": "npm:^3.0.0"
"@types/d3-interpolate": "npm:^3.0.1"
"@types/d3-scale": "npm:^4.0.2"
"@types/d3-shape": "npm:^3.1.0"
"@types/d3-time": "npm:^3.0.0"
"@types/d3-timer": "npm:^3.0.0"
d3-array: "npm:^3.1.6"
d3-ease: "npm:^3.0.1"
d3-interpolate: "npm:^3.0.1"
d3-scale: "npm:^4.0.2"
d3-shape: "npm:^3.1.0"
d3-time: "npm:^3.0.0"
d3-timer: "npm:^3.0.1"
checksum: 10/db67b3d9b8070d4eae4122edc72be7067b4e32363340cdd4d5b628e7dd65bea0c7c5b4116016658d223adaa575bcc6b7b3a71507aa4f34b2609ed61dbfbba1ea
languageName: node
linkType: hard
"vite-node@npm:3.2.4, vite-node@npm:^3.2.2":
version: 3.2.4
resolution: "vite-node@npm:3.2.4"