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

View File

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

View File

@@ -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<PrismaWorkspaceDoc> {
class DocType {
@Field(() => String, { name: 'id' })
docId!: string;
@@ -65,6 +64,18 @@ class DocType implements Partial<PrismaWorkspaceDoc> {
@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<DotToUnderline<DocAction>, 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<PaginatedDocType> {
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<DocType> {
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<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, {
description: 'Doc metadata',
complexity: 2,

View File

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

View File

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