From 3feea3dc6c6b13f66076b121949170f4f87d6ffa Mon Sep 17 00:00:00 2001 From: forehalo Date: Wed, 30 Apr 2025 10:39:00 +0000 Subject: [PATCH] feat(server): docs pagination (#12086) ## Summary by CodeRabbit - **New Features** - Added paginated document listing for workspaces, allowing users to browse documents with pagination controls. - Enhanced document details to display creation and update timestamps, as well as information about the creator and last updater. - **Bug Fixes** - Updated deprecation notice for workspace document metadata fields to guide users to the latest recommended field. - **Tests** - Added new tests to verify document info retrieval and pagination functionality. --- .../server/src/__tests__/models/doc.spec.ts | 75 ++++++++++++++++ .../src/core/workspaces/resolvers/doc.ts | 62 +++++++++++-- packages/backend/server/src/models/doc.ts | 88 +++++++++++++++++++ packages/backend/server/src/schema.gql | 24 ++++- packages/common/graphql/src/graphql/index.ts | 2 +- packages/common/graphql/src/schema.ts | 28 +++++- 6 files changed, 271 insertions(+), 8 deletions(-) diff --git a/packages/backend/server/src/__tests__/models/doc.spec.ts b/packages/backend/server/src/__tests__/models/doc.spec.ts index 91dfd37d1f..d47e089e5f 100644 --- a/packages/backend/server/src/__tests__/models/doc.spec.ts +++ b/packages/backend/server/src/__tests__/models/doc.spec.ts @@ -658,4 +658,79 @@ test('should find metas by workspaceIds and docIds', async t => { ); }); +test('should get doc info', async t => { + const docId = randomUUID(); + const snapshot = { + spaceId: workspace.id, + docId, + blob: Buffer.from('blob1'), + timestamp: Date.now(), + editorId: user.id, + }; + + await t.context.doc.upsert(snapshot); + await t.context.doc.upsertMeta(workspace.id, docId); + + const docInfo = await t.context.doc.getDocInfo(workspace.id, docId); + + t.like(docInfo, { + workspaceId: workspace.id, + docId, + updatedAt: new Date(snapshot.timestamp), + creatorId: user.id, + lastUpdaterId: user.id, + }); +}); + +test('should paginate docs info', async t => { + const docId1 = randomUUID(); + const docId2 = randomUUID(); + const docId3 = randomUUID(); + const snapshot1 = { + spaceId: workspace.id, + docId: docId1, + blob: Buffer.from('blob1'), + timestamp: Date.now(), + editorId: user.id, + }; + const snapshot2 = { + spaceId: workspace.id, + docId: docId2, + blob: Buffer.from('blob2'), + timestamp: Date.now() + 1, + editorId: user.id, + }; + const snapshot3 = { + spaceId: workspace.id, + docId: docId3, + blob: Buffer.from('blob3'), + timestamp: Date.now() + 2, + editorId: user.id, + }; + await t.context.doc.upsertMeta(workspace.id, docId1); + await t.context.doc.upsertMeta(workspace.id, docId2); + await t.context.doc.upsertMeta(workspace.id, docId3); + await t.context.doc.upsert(snapshot1); + await t.context.doc.upsert(snapshot2); + await t.context.doc.upsert(snapshot3); + + let [count, docs] = await t.context.doc.paginateDocInfo(workspace.id, { + first: 1, + offset: 0, + }); + + t.is(count, 3); + t.is(docs.length, 1); + t.is(docs[0].docId, docId1); + + [count, docs] = await t.context.doc.paginateDocInfo(workspace.id, { + first: 1, + offset: 0, + after: docs[0].createdAt.toISOString(), + }); + + t.is(count, 3); + t.is(docs.length, 1); + t.is(docs[0].docId, docId2); +}); // #endregion diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index 8747c8dc85..1e02196b6d 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -10,7 +10,6 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import type { WorkspaceDoc as PrismaWorkspaceDoc } from '@prisma/client'; import { PrismaClient } from '@prisma/client'; import { @@ -37,7 +36,7 @@ import { DocAction, DocRole, } from '../../permission'; -import { WorkspaceUserType } from '../../user'; +import { PublicUserType, WorkspaceUserType } from '../../user'; import { WorkspaceType } from '../types'; import { DotToUnderline, @@ -50,7 +49,7 @@ registerEnumType(PublicDocMode, { }); @ObjectType() -class DocType implements Partial { +class DocType { @Field(() => String, { name: 'id' }) docId!: string; @@ -65,6 +64,18 @@ class DocType implements Partial { @Field(() => DocRole) defaultRole!: DocRole; + + @Field(() => Date, { nullable: true }) + createdAt?: Date; + + @Field(() => Date, { nullable: true }) + updatedAt?: Date; + + @Field(() => String, { nullable: true }) + creatorId?: string; + + @Field(() => String, { nullable: true }) + lastUpdaterId?: string; } @InputType() @@ -133,6 +144,9 @@ class GrantedDocUserType { @ObjectType() class PaginatedGrantedDocUserType extends Paginated(GrantedDocUserType) {} +@ObjectType() +class PaginatedDocType extends Paginated(DocType) {} + const DocPermissions = registerObjectType< Record, boolean> >( @@ -173,6 +187,7 @@ class WorkspaceDocMeta { @Field(() => EditorType, { nullable: true }) updatedBy!: EditorType | null; } + @Resolver(() => WorkspaceType) export class WorkspaceDocResolver { private readonly logger = new Logger(WorkspaceDocResolver.name); @@ -190,7 +205,7 @@ export class WorkspaceDocResolver { @ResolveField(() => WorkspaceDocMeta, { description: 'Cloud page metadata of workspace', complexity: 2, - deprecationReason: 'use [WorkspaceType.doc.meta] instead', + deprecationReason: 'use [WorkspaceType.doc] instead', }) async pageMeta( @Parent() workspace: WorkspaceType, @@ -238,6 +253,19 @@ export class WorkspaceDocResolver { return this.doc(workspace, pageId); } + @ResolveField(() => PaginatedDocType) + async docs( + @Parent() workspace: WorkspaceType, + @Args('pagination', PaginationInput.decode) pagination: PaginationInput + ): Promise { + const [count, rows] = await this.models.doc.paginateDocInfo( + workspace.id, + pagination + ); + + return paginate(rows, 'createdAt', pagination, count); + } + @ResolveField(() => DocType, { description: 'Get get with given id', complexity: 2, @@ -246,7 +274,7 @@ export class WorkspaceDocResolver { @Parent() workspace: WorkspaceType, @Args('docId') docId: string ): Promise { - const doc = await this.models.doc.getMeta(workspace.id, docId); + const doc = await this.models.doc.getDocInfo(workspace.id, docId); if (doc) { return doc; } @@ -411,6 +439,30 @@ export class DocResolver { private readonly models: Models ) {} + @ResolveField(() => PublicUserType, { + nullable: true, + description: 'Doc create user', + }) + async createdBy(@Parent() doc: DocType): Promise { + if (!doc.creatorId) { + return null; + } + + return await this.models.user.get(doc.creatorId); + } + + @ResolveField(() => PublicUserType, { + nullable: true, + description: 'Doc last updated user', + }) + async lastUpdatedBy(@Parent() doc: DocType): Promise { + if (!doc.lastUpdaterId) { + return null; + } + + return await this.models.user.get(doc.lastUpdaterId); + } + @ResolveField(() => WorkspaceDocMeta, { description: 'Doc metadata', complexity: 2, diff --git a/packages/backend/server/src/models/doc.ts b/packages/backend/server/src/models/doc.ts index 4d197bb680..67ea6fddcb 100644 --- a/packages/backend/server/src/models/doc.ts +++ b/packages/backend/server/src/models/doc.ts @@ -3,6 +3,7 @@ import { Transactional } from '@nestjs-cls/transactional'; import type { Update } from '@prisma/client'; import { Prisma } from '@prisma/client'; +import { PaginationInput } from '../base'; import { DocIsNotPublic } from '../base/error'; import { BaseModel } from './base'; import { Doc, DocRole, PublicDocMode, publicUserSelect } from './common'; @@ -480,5 +481,92 @@ export class DocModel extends BaseModel { }); return docMeta?.public ?? false; } + + async getDocInfo(workspaceId: string, docId: string) { + const rows = await this.db.$queryRaw< + { + workspaceId: string; + docId: string; + mode: PublicDocMode; + public: boolean; + defaultRole: DocRole; + createdAt: Date; + updatedAt: Date; + creatorId?: string; + lastUpdaterId?: string; + }[] + >` + SELECT + "workspace_pages"."workspace_id" as "workspaceId", + "workspace_pages"."page_id" as "docId", + "workspace_pages"."mode" as "mode", + "workspace_pages"."public" as "public", + "workspace_pages"."defaultRole" as "defaultRole", + "snapshots"."created_at" as "createdAt", + "snapshots"."updated_at" as "updatedAt", + "snapshots"."created_by" as "creatorId", + "snapshots"."updated_by" as "lastUpdaterId" + FROM "workspace_pages" + INNER JOIN "snapshots" + ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id" + AND "workspace_pages"."page_id" = "snapshots"."guid" + WHERE + "workspace_pages"."workspace_id" = ${workspaceId} + AND "workspace_pages"."page_id" = ${docId} + LIMIT 1; + `; + + return rows.at(0) ?? null; + } + + async paginateDocInfo(workspaceId: string, pagination: PaginationInput) { + const count = await this.db.workspaceDoc.count({ + where: { + workspaceId, + }, + }); + + const after = pagination.after + ? Prisma.sql`AND "snapshots"."created_at" > ${new Date(pagination.after)}` + : Prisma.sql``; + + const rows = await this.db.$queryRaw< + { + workspaceId: string; + docId: string; + mode: PublicDocMode; + public: boolean; + defaultRole: DocRole; + createdAt: Date; + updatedAt: Date; + creatorId?: string; + lastUpdaterId?: string; + }[] + >` + SELECT + "workspace_pages"."workspace_id" as "workspaceId", + "workspace_pages"."page_id" as "docId", + "workspace_pages"."mode" as "mode", + "workspace_pages"."public" as "public", + "workspace_pages"."defaultRole" as "defaultRole", + "snapshots"."created_at" as "createdAt", + "snapshots"."updated_at" as "updatedAt", + "snapshots"."created_by" as "creatorId", + "snapshots"."updated_by" as "lastUpdaterId" + FROM "workspace_pages" + INNER JOIN "snapshots" + ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id" + AND "workspace_pages"."page_id" = "snapshots"."guid" + WHERE + "workspace_pages"."workspace_id" = ${workspaceId} + ${after} + ORDER BY + "snapshots"."created_at" ASC + LIMIT ${pagination.first} + OFFSET ${pagination.offset} + `; + + return [count, rows] as const; + } // #endregion } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 9d0a3bcac8..29c13fcc4c 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -425,20 +425,35 @@ enum DocRole { } type DocType { + createdAt: DateTime + + """Doc create user""" + createdBy: PublicUserType + creatorId: String defaultRole: DocRole! """paginated doc granted users list""" grantedUsersList(pagination: PaginationInput!): PaginatedGrantedDocUserType! id: String! + """Doc last updated user""" + lastUpdatedBy: PublicUserType + lastUpdaterId: String + """Doc metadata""" meta: WorkspaceDocMeta! mode: PublicDocMode! permissions: DocPermissions! public: Boolean! + updatedAt: DateTime workspaceId: String! } +type DocTypeEdge { + cursor: String! + node: DocType! +} + type DocUpdateBlockedDataType { docId: String! spaceId: String! @@ -1189,6 +1204,12 @@ type PaginatedCopilotWorkspaceFileType { totalCount: Int! } +type PaginatedDocType { + edges: [DocTypeEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + type PaginatedGrantedDocUserType { edges: [GrantedDocUserTypeEdge!]! pageInfo: PageInfo! @@ -1781,6 +1802,7 @@ type WorkspaceType { """Get get with given id""" doc(docId: String!): DocType! + docs(pagination: PaginationInput!): PaginatedDocType! embedding: CopilotWorkspaceConfig! """Enable AI""" @@ -1817,7 +1839,7 @@ type WorkspaceType { owner: UserType! """Cloud page metadata of workspace""" - pageMeta(pageId: String!): WorkspaceDocMeta! @deprecated(reason: "use [WorkspaceType.doc.meta] instead") + pageMeta(pageId: String!): WorkspaceDocMeta! @deprecated(reason: "use [WorkspaceType.doc] instead") """map of action permissions""" permissions: WorkspacePermissions! diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index ce1f5dff0c..ce603e7124 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -1227,7 +1227,7 @@ export const getWorkspacePageMetaByIdQuery = { } } }`, - deprecations: ["'pageMeta' is deprecated: use [WorkspaceType.doc.meta] instead"], + deprecations: ["'pageMeta' is deprecated: use [WorkspaceType.doc] instead"], }; export const getWorkspacePublicByIdQuery = { diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 7f48a60dae..fe61473749 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -532,15 +532,23 @@ export enum DocRole { export interface DocType { __typename?: 'DocType'; + createdAt: Maybe; + /** Doc create user */ + createdBy: Maybe; + creatorId: Maybe; defaultRole: DocRole; /** paginated doc granted users list */ grantedUsersList: PaginatedGrantedDocUserType; id: Scalars['String']['output']; + /** Doc last updated user */ + lastUpdatedBy: Maybe; + lastUpdaterId: Maybe; /** Doc metadata */ meta: WorkspaceDocMeta; mode: PublicDocMode; permissions: DocPermissions; public: Scalars['Boolean']['output']; + updatedAt: Maybe; workspaceId: Scalars['String']['output']; } @@ -548,6 +556,12 @@ export interface DocTypeGrantedUsersListArgs { pagination: PaginationInput; } +export interface DocTypeEdge { + __typename?: 'DocTypeEdge'; + cursor: Scalars['String']['output']; + node: DocType; +} + export interface DocUpdateBlockedDataType { __typename?: 'DocUpdateBlockedDataType'; docId: Scalars['String']['output']; @@ -1680,6 +1694,13 @@ export interface PaginatedCopilotWorkspaceFileType { totalCount: Scalars['Int']['output']; } +export interface PaginatedDocType { + __typename?: 'PaginatedDocType'; + edges: Array; + pageInfo: PageInfo; + totalCount: Scalars['Int']['output']; +} + export interface PaginatedGrantedDocUserType { __typename?: 'PaginatedGrantedDocUserType'; edges: Array; @@ -2346,6 +2367,7 @@ export interface WorkspaceType { createdAt: Scalars['DateTime']['output']; /** Get get with given id */ doc: DocType; + docs: PaginatedDocType; embedding: CopilotWorkspaceConfig; /** Enable AI */ enableAi: Scalars['Boolean']['output']; @@ -2372,7 +2394,7 @@ export interface WorkspaceType { owner: UserType; /** * Cloud page metadata of workspace - * @deprecated use [WorkspaceType.doc.meta] instead + * @deprecated use [WorkspaceType.doc] instead */ pageMeta: WorkspaceDocMeta; /** map of action permissions */ @@ -2402,6 +2424,10 @@ export interface WorkspaceTypeDocArgs { docId: Scalars['String']['input']; } +export interface WorkspaceTypeDocsArgs { + pagination: PaginationInput; +} + export interface WorkspaceTypeHistoriesArgs { before?: InputMaybe; guid: Scalars['String']['input'];