feat(server): docs pagination (#12086)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
forehalo
2025-04-30 10:39:00 +00:00
parent 8938da4c24
commit 3feea3dc6c
6 changed files with 271 additions and 8 deletions
@@ -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 // #endregion
@@ -10,7 +10,6 @@ import {
ResolveField, ResolveField,
Resolver, Resolver,
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import type { WorkspaceDoc as PrismaWorkspaceDoc } from '@prisma/client';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { import {
@@ -37,7 +36,7 @@ import {
DocAction, DocAction,
DocRole, DocRole,
} from '../../permission'; } from '../../permission';
import { WorkspaceUserType } from '../../user'; import { PublicUserType, WorkspaceUserType } from '../../user';
import { WorkspaceType } from '../types'; import { WorkspaceType } from '../types';
import { import {
DotToUnderline, DotToUnderline,
@@ -50,7 +49,7 @@ registerEnumType(PublicDocMode, {
}); });
@ObjectType() @ObjectType()
class DocType implements Partial<PrismaWorkspaceDoc> { class DocType {
@Field(() => String, { name: 'id' }) @Field(() => String, { name: 'id' })
docId!: string; docId!: string;
@@ -65,6 +64,18 @@ class DocType implements Partial<PrismaWorkspaceDoc> {
@Field(() => DocRole) @Field(() => DocRole)
defaultRole!: 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() @InputType()
@@ -133,6 +144,9 @@ class GrantedDocUserType {
@ObjectType() @ObjectType()
class PaginatedGrantedDocUserType extends Paginated(GrantedDocUserType) {} class PaginatedGrantedDocUserType extends Paginated(GrantedDocUserType) {}
@ObjectType()
class PaginatedDocType extends Paginated(DocType) {}
const DocPermissions = registerObjectType< const DocPermissions = registerObjectType<
Record<DotToUnderline<DocAction>, boolean> Record<DotToUnderline<DocAction>, boolean>
>( >(
@@ -173,6 +187,7 @@ class WorkspaceDocMeta {
@Field(() => EditorType, { nullable: true }) @Field(() => EditorType, { nullable: true })
updatedBy!: EditorType | null; updatedBy!: EditorType | null;
} }
@Resolver(() => WorkspaceType) @Resolver(() => WorkspaceType)
export class WorkspaceDocResolver { export class WorkspaceDocResolver {
private readonly logger = new Logger(WorkspaceDocResolver.name); private readonly logger = new Logger(WorkspaceDocResolver.name);
@@ -190,7 +205,7 @@ export class WorkspaceDocResolver {
@ResolveField(() => WorkspaceDocMeta, { @ResolveField(() => WorkspaceDocMeta, {
description: 'Cloud page metadata of workspace', description: 'Cloud page metadata of workspace',
complexity: 2, complexity: 2,
deprecationReason: 'use [WorkspaceType.doc.meta] instead', deprecationReason: 'use [WorkspaceType.doc] instead',
}) })
async pageMeta( async pageMeta(
@Parent() workspace: WorkspaceType, @Parent() workspace: WorkspaceType,
@@ -238,6 +253,19 @@ export class WorkspaceDocResolver {
return this.doc(workspace, pageId); return this.doc(workspace, pageId);
} }
@ResolveField(() => PaginatedDocType)
async docs(
@Parent() workspace: WorkspaceType,
@Args('pagination', PaginationInput.decode) pagination: PaginationInput
): Promise<PaginatedDocType> {
const [count, rows] = await this.models.doc.paginateDocInfo(
workspace.id,
pagination
);
return paginate(rows, 'createdAt', pagination, count);
}
@ResolveField(() => DocType, { @ResolveField(() => DocType, {
description: 'Get get with given id', description: 'Get get with given id',
complexity: 2, complexity: 2,
@@ -246,7 +274,7 @@ export class WorkspaceDocResolver {
@Parent() workspace: WorkspaceType, @Parent() workspace: WorkspaceType,
@Args('docId') docId: string @Args('docId') docId: string
): Promise<DocType> { ): Promise<DocType> {
const doc = await this.models.doc.getMeta(workspace.id, docId); const doc = await this.models.doc.getDocInfo(workspace.id, docId);
if (doc) { if (doc) {
return doc; return doc;
} }
@@ -411,6 +439,30 @@ export class DocResolver {
private readonly models: Models private readonly models: Models
) {} ) {}
@ResolveField(() => PublicUserType, {
nullable: true,
description: 'Doc create user',
})
async createdBy(@Parent() doc: DocType): Promise<PublicUserType | null> {
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<PublicUserType | null> {
if (!doc.lastUpdaterId) {
return null;
}
return await this.models.user.get(doc.lastUpdaterId);
}
@ResolveField(() => WorkspaceDocMeta, { @ResolveField(() => WorkspaceDocMeta, {
description: 'Doc metadata', description: 'Doc metadata',
complexity: 2, complexity: 2,
+88
View File
@@ -3,6 +3,7 @@ import { Transactional } from '@nestjs-cls/transactional';
import type { Update } from '@prisma/client'; import type { Update } from '@prisma/client';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PaginationInput } from '../base';
import { DocIsNotPublic } from '../base/error'; import { DocIsNotPublic } from '../base/error';
import { BaseModel } from './base'; import { BaseModel } from './base';
import { Doc, DocRole, PublicDocMode, publicUserSelect } from './common'; import { Doc, DocRole, PublicDocMode, publicUserSelect } from './common';
@@ -480,5 +481,92 @@ export class DocModel extends BaseModel {
}); });
return docMeta?.public ?? false; 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 // #endregion
} }
+23 -1
View File
@@ -425,20 +425,35 @@ enum DocRole {
} }
type DocType { type DocType {
createdAt: DateTime
"""Doc create user"""
createdBy: PublicUserType
creatorId: String
defaultRole: DocRole! defaultRole: DocRole!
"""paginated doc granted users list""" """paginated doc granted users list"""
grantedUsersList(pagination: PaginationInput!): PaginatedGrantedDocUserType! grantedUsersList(pagination: PaginationInput!): PaginatedGrantedDocUserType!
id: String! id: String!
"""Doc last updated user"""
lastUpdatedBy: PublicUserType
lastUpdaterId: String
"""Doc metadata""" """Doc metadata"""
meta: WorkspaceDocMeta! meta: WorkspaceDocMeta!
mode: PublicDocMode! mode: PublicDocMode!
permissions: DocPermissions! permissions: DocPermissions!
public: Boolean! public: Boolean!
updatedAt: DateTime
workspaceId: String! workspaceId: String!
} }
type DocTypeEdge {
cursor: String!
node: DocType!
}
type DocUpdateBlockedDataType { type DocUpdateBlockedDataType {
docId: String! docId: String!
spaceId: String! spaceId: String!
@@ -1189,6 +1204,12 @@ type PaginatedCopilotWorkspaceFileType {
totalCount: Int! totalCount: Int!
} }
type PaginatedDocType {
edges: [DocTypeEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PaginatedGrantedDocUserType { type PaginatedGrantedDocUserType {
edges: [GrantedDocUserTypeEdge!]! edges: [GrantedDocUserTypeEdge!]!
pageInfo: PageInfo! pageInfo: PageInfo!
@@ -1781,6 +1802,7 @@ type WorkspaceType {
"""Get get with given id""" """Get get with given id"""
doc(docId: String!): DocType! doc(docId: String!): DocType!
docs(pagination: PaginationInput!): PaginatedDocType!
embedding: CopilotWorkspaceConfig! embedding: CopilotWorkspaceConfig!
"""Enable AI""" """Enable AI"""
@@ -1817,7 +1839,7 @@ type WorkspaceType {
owner: UserType! owner: UserType!
"""Cloud page metadata of workspace""" """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""" """map of action permissions"""
permissions: WorkspacePermissions! permissions: WorkspacePermissions!
+1 -1
View File
@@ -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 = { export const getWorkspacePublicByIdQuery = {
+27 -1
View File
@@ -532,15 +532,23 @@ export enum DocRole {
export interface DocType { export interface DocType {
__typename?: 'DocType'; __typename?: 'DocType';
createdAt: Maybe<Scalars['DateTime']['output']>;
/** Doc create user */
createdBy: Maybe<PublicUserType>;
creatorId: Maybe<Scalars['String']['output']>;
defaultRole: DocRole; defaultRole: DocRole;
/** paginated doc granted users list */ /** paginated doc granted users list */
grantedUsersList: PaginatedGrantedDocUserType; grantedUsersList: PaginatedGrantedDocUserType;
id: Scalars['String']['output']; id: Scalars['String']['output'];
/** Doc last updated user */
lastUpdatedBy: Maybe<PublicUserType>;
lastUpdaterId: Maybe<Scalars['String']['output']>;
/** Doc metadata */ /** Doc metadata */
meta: WorkspaceDocMeta; meta: WorkspaceDocMeta;
mode: PublicDocMode; mode: PublicDocMode;
permissions: DocPermissions; permissions: DocPermissions;
public: Scalars['Boolean']['output']; public: Scalars['Boolean']['output'];
updatedAt: Maybe<Scalars['DateTime']['output']>;
workspaceId: Scalars['String']['output']; workspaceId: Scalars['String']['output'];
} }
@@ -548,6 +556,12 @@ export interface DocTypeGrantedUsersListArgs {
pagination: PaginationInput; pagination: PaginationInput;
} }
export interface DocTypeEdge {
__typename?: 'DocTypeEdge';
cursor: Scalars['String']['output'];
node: DocType;
}
export interface DocUpdateBlockedDataType { export interface DocUpdateBlockedDataType {
__typename?: 'DocUpdateBlockedDataType'; __typename?: 'DocUpdateBlockedDataType';
docId: Scalars['String']['output']; docId: Scalars['String']['output'];
@@ -1680,6 +1694,13 @@ export interface PaginatedCopilotWorkspaceFileType {
totalCount: Scalars['Int']['output']; totalCount: Scalars['Int']['output'];
} }
export interface PaginatedDocType {
__typename?: 'PaginatedDocType';
edges: Array<DocTypeEdge>;
pageInfo: PageInfo;
totalCount: Scalars['Int']['output'];
}
export interface PaginatedGrantedDocUserType { export interface PaginatedGrantedDocUserType {
__typename?: 'PaginatedGrantedDocUserType'; __typename?: 'PaginatedGrantedDocUserType';
edges: Array<GrantedDocUserTypeEdge>; edges: Array<GrantedDocUserTypeEdge>;
@@ -2346,6 +2367,7 @@ export interface WorkspaceType {
createdAt: Scalars['DateTime']['output']; createdAt: Scalars['DateTime']['output'];
/** Get get with given id */ /** Get get with given id */
doc: DocType; doc: DocType;
docs: PaginatedDocType;
embedding: CopilotWorkspaceConfig; embedding: CopilotWorkspaceConfig;
/** Enable AI */ /** Enable AI */
enableAi: Scalars['Boolean']['output']; enableAi: Scalars['Boolean']['output'];
@@ -2372,7 +2394,7 @@ export interface WorkspaceType {
owner: UserType; owner: UserType;
/** /**
* Cloud page metadata of workspace * Cloud page metadata of workspace
* @deprecated use [WorkspaceType.doc.meta] instead * @deprecated use [WorkspaceType.doc] instead
*/ */
pageMeta: WorkspaceDocMeta; pageMeta: WorkspaceDocMeta;
/** map of action permissions */ /** map of action permissions */
@@ -2402,6 +2424,10 @@ export interface WorkspaceTypeDocArgs {
docId: Scalars['String']['input']; docId: Scalars['String']['input'];
} }
export interface WorkspaceTypeDocsArgs {
pagination: PaginationInput;
}
export interface WorkspaceTypeHistoriesArgs { export interface WorkspaceTypeHistoriesArgs {
before?: InputMaybe<Scalars['DateTime']['input']>; before?: InputMaybe<Scalars['DateTime']['input']>;
guid: Scalars['String']['input']; guid: Scalars['String']['input'];