From 637e044a055171b5e6f4b65690a1d3087d1ac3eb Mon Sep 17 00:00:00 2001 From: Ly Tuan Kiet Date: Tue, 17 Dec 2024 05:24:46 +0700 Subject: [PATCH] refactor: standardize formatting and enhance DocumentSchema - Updated compose.yaml to use consistent quotation marks for port and labels. - Refactored package.json to improve readability of Jest configuration. - Enhanced DocumentSchema by adding a new event type for cursor movement and improving formatting for better clarity. - Consolidated import statements in document.schema.ts for consistency and readability. - Improved error handling and validation across various methods in DocumentSchema to enhance user experience. These changes improve code maintainability and readability, ensuring a more consistent and user-friendly experience in the Document management features. --- compose.yaml | 12 +- package.json | 10 +- src/Document/document.event.ts | 1 + src/Document/document.schema.ts | 600 ++++++++++++++++++++------------ 4 files changed, 387 insertions(+), 236 deletions(-) diff --git a/compose.yaml b/compose.yaml index fc753c0..41c6021 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,7 +6,7 @@ services: dockerfile: Dockerfile pull_policy: always ports: - - '8888:3069' + - "8888:3069" environment: - NODE_ENV=development - DISABLE_AUTH=false @@ -51,11 +51,11 @@ services: - LIVEKIT_CERT_FILE=/path/to/turn.crt - LIVEKIT_KEY_FILE=/path/to/turn.key labels: - - 'traefik.enable=true' - - 'traefik.http.routers.api.rule=Host(`api.epess.org`)' - - 'traefik.http.routers.api.entrypoints=web' - - 'traefik.http.routers.api.service=api' - - 'traefik.http.services.api.loadbalancer.server.port=3069' + - "traefik.enable=true" + - "traefik.http.routers.api.rule=Host(`api.epess.org`)" + - "traefik.http.routers.api.entrypoints=web" + - "traefik.http.routers.api.service=api" + - "traefik.http.services.api.loadbalancer.server.port=3069" networks: - epess-net restart: always diff --git a/package.json b/package.json index fbe00e3..8b5e830 100644 --- a/package.json +++ b/package.json @@ -140,13 +140,19 @@ "@css-inline/css-inline-linux-x64-musl": "^0.14.3" }, "jest": { - "moduleFileExtensions": ["js", "json", "ts"], + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "collectCoverageFrom": ["**/*.(t|j)s"], + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], "coverageDirectory": "../coverage", "testEnvironment": "node" }, diff --git a/src/Document/document.event.ts b/src/Document/document.event.ts index 79dd5c2..077bb15 100644 --- a/src/Document/document.event.ts +++ b/src/Document/document.event.ts @@ -7,5 +7,6 @@ export enum DocumentEvent { ACTIVE_DOCUMENT_ID_CHANGED = 'document_active_document_id_changed', CLIENT_REQUEST_SYNC = 'document_client_request_sync', SERVER_REQUEST_SYNC = 'document_server_request_sync', + CURSOR_MOVED = 'document_cursor_moved', AI_SUGGESTION = 'document_ai_suggestion', } diff --git a/src/Document/document.schema.ts b/src/Document/document.schema.ts index 3821086..728abb7 100644 --- a/src/Document/document.schema.ts +++ b/src/Document/document.schema.ts @@ -1,16 +1,22 @@ -import { Inject, Injectable, Logger } from '@nestjs/common' -import { Document } from '@prisma/client' -import { Pothos, PothosRef, PothosSchema, SchemaBuilderToken } from '@smatch-corp/nestjs-pothos' -import Delta from 'quill-delta' -import { MinioService } from 'src/Minio/minio.service' -import { PromptType } from 'src/OpenAI/openai.service' -import { RedisService } from 'src/Redis/redis.service' -import { DateTimeUtils } from 'src/common/utils/datetime.utils' -import { Builder, SchemaContext } from '../Graphql/graphql.builder' -import { PrismaService } from '../Prisma/prisma.service' -import { DocumentEvent } from './document.event' -import { DocumentService } from './document.service' -import { DocumentDelta } from './document.type' +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { Document } from "@prisma/client"; +import { + Pothos, + PothosRef, + PothosSchema, + SchemaBuilderToken, +} from "@smatch-corp/nestjs-pothos"; +import Delta from "quill-delta"; +import { MinioService } from "src/Minio/minio.service"; +import { PromptType } from "src/OpenAI/openai.service"; +import { RedisService } from "src/Redis/redis.service"; +import { DateTimeUtils } from "src/common/utils/datetime.utils"; +import { Builder, SchemaContext } from "../Graphql/graphql.builder"; +import { PrismaService } from "../Prisma/prisma.service"; +import { DocumentEvent } from "./document.event"; +import { DocumentService } from "./document.service"; +import { DocumentDelta } from "./document.type"; + @Injectable() export class DocumentSchema extends PothosSchema { constructor( @@ -18,67 +24,82 @@ export class DocumentSchema extends PothosSchema { private readonly prisma: PrismaService, private readonly minio: MinioService, private readonly documentService: DocumentService, - private readonly redis: RedisService, + private readonly redis: RedisService ) { - super() + super(); } @PothosRef() document() { - return this.builder.prismaObject('Document', { + return this.builder.prismaObject("Document", { fields: (t) => ({ - id: t.exposeID('id', { description: 'The ID of the document.', nullable: false }), - name: t.exposeString('name', { description: 'The name of the document.', nullable: false }), - fileUrl: t.exposeString('fileUrl', { description: 'The file URL of the document.', nullable: false }), - previewImage: t.relation('previewImage', { - description: 'The preview image of the document.', - nullable: true, - }), - owner: t.relation('owner', { description: 'The owner of the document.', nullable: false }), - collaborators: t.relation('collaborators', { - description: 'The collaborators of the document.', + id: t.exposeID("id", { + description: "The ID of the document.", nullable: false, }), - isPublic: t.exposeBoolean('isPublic', { description: 'Whether the document is public.', nullable: false }), - collaborationSession: t.relation('collaborationSession', { - description: 'The collaboration session of the document.', - nullable: true, - }), - collaborationSessionId: t.exposeID('collaborationSessionId', { - description: 'The ID of the collaboration session of the document.', - nullable: true, - }), - createdAt: t.expose('createdAt', { - description: 'The creation time of the document.', - type: 'DateTime', + name: t.exposeString("name", { + description: "The name of the document.", nullable: false, }), - updatedAt: t.expose('updatedAt', { - description: 'The update time of the document.', - type: 'DateTime', + fileUrl: t.exposeString("fileUrl", { + description: "The file URL of the document.", + nullable: false, + }), + previewImage: t.relation("previewImage", { + description: "The preview image of the document.", + nullable: true, + }), + owner: t.relation("owner", { + description: "The owner of the document.", + nullable: false, + }), + collaborators: t.relation("collaborators", { + description: "The collaborators of the document.", + nullable: false, + }), + isPublic: t.exposeBoolean("isPublic", { + description: "Whether the document is public.", + nullable: false, + }), + collaborationSession: t.relation("collaborationSession", { + description: "The collaboration session of the document.", + nullable: true, + }), + collaborationSessionId: t.exposeID("collaborationSessionId", { + description: "The ID of the collaboration session of the document.", + nullable: true, + }), + createdAt: t.expose("createdAt", { + description: "The creation time of the document.", + type: "DateTime", + nullable: false, + }), + updatedAt: t.expose("updatedAt", { + description: "The update time of the document.", + type: "DateTime", nullable: false, }), }), - }) + }); } @PothosRef() documentCollaborator() { - return this.builder.prismaObject('DocumentCollaborator', { + return this.builder.prismaObject("DocumentCollaborator", { fields: (t) => ({ - documentId: t.exposeID('documentId', { nullable: false }), - userId: t.exposeID('userId', { nullable: false }), - document: t.relation('document', { nullable: false }), - user: t.relation('user', { nullable: false }), - readable: t.exposeBoolean('readable', { nullable: false }), - writable: t.exposeBoolean('writable', { nullable: false }), + documentId: t.exposeID("documentId", { nullable: false }), + userId: t.exposeID("userId", { nullable: false }), + document: t.relation("document", { nullable: false }), + user: t.relation("user", { nullable: false }), + readable: t.exposeBoolean("readable", { nullable: false }), + writable: t.exposeBoolean("writable", { nullable: false }), }), - }) + }); } @PothosRef() documentDelta() { - return this.builder.simpleObject('DocumentDelta', { + return this.builder.simpleObject("DocumentDelta", { fields: (t) => ({ eventType: t.string(), documentId: t.string({ @@ -88,7 +109,7 @@ export class DocumentSchema extends PothosSchema { nullable: true, }), delta: t.field({ - type: 'Delta', + type: "Delta", nullable: true, }), senderId: t.string({ @@ -97,39 +118,85 @@ export class DocumentSchema extends PothosSchema { requestSync: t.boolean({ nullable: true, }), + cursor: t.field({ + type: this.cursor(), + nullable: true, + }), totalPage: t.int({ nullable: true, }), }), - }) + }); + } + + @PothosRef() + range() { + return this.builder.simpleObject("Range", { + fields: (t) => ({ + index: t.int(), + length: t.int(), + }), + }); + } + + @PothosRef() + cursor() { + return this.builder.simpleObject("Cursor", { + fields: (t) => ({ + id: t.string(), + name: t.string(), + color: t.string(), + range: t.field({ + type: this.range(), + nullable: true, + }), + }), + }); } @PothosRef() DocumentExportObject() { - return this.builder.simpleObject('DocumentExportObject', { + return this.builder.simpleObject("DocumentExportObject", { fields: (t) => ({ documentId: t.string(), pageIndex: t.int(), type: t.field({ - type: this.builder.enumType('DocumentExportType', { - values: ['PDF', 'DOCX'] as const, + type: this.builder.enumType("DocumentExportType", { + values: ["PDF", "DOCX"] as const, }), nullable: false, }), fileUrl: t.string(), }), - }) + }); } @PothosRef() documentDeltaInput() { - return this.builder.inputType('DocumentDeltaInput', { + return this.builder.inputType("DocumentDeltaInput", { fields: (t) => ({ documentId: t.string(), pageIndex: t.int(), - delta: t.field({ type: 'Delta' }), + delta: t.field({ type: "Delta" }), + cursor: t.field({ + type: this.builder.inputType("CursorInput", { + fields: (t) => ({ + id: t.string(), + name: t.string(), + color: t.string(), + range: t.field({ + type: this.builder.inputType("RangeInput", { + fields: (t) => ({ + index: t.int(), + length: t.int(), + }), + }), + }), + }), + }), + }), }), - }) + }); } @Pothos() @@ -137,37 +204,40 @@ export class DocumentSchema extends PothosSchema { this.builder.queryFields((t) => ({ myDocuments: t.prismaField({ type: [this.document()], - args: this.builder.generator.findManyArgs('Document'), + args: this.builder.generator.findManyArgs("Document"), resolve: async (query, _parent, args, ctx: SchemaContext) => { if (ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } if (!ctx.http?.me?.id) { - throw new Error('User not found') + throw new Error("User not found"); } return await this.prisma.document.findMany({ ...query, orderBy: args.orderBy ?? undefined, where: { - OR: [{ ownerId: ctx.http.me.id }, { collaborators: { some: { userId: ctx.http.me.id } } }], + OR: [ + { ownerId: ctx.http.me.id }, + { collaborators: { some: { userId: ctx.http.me.id } } }, + ], }, - }) + }); }, }), document: t.prismaField({ type: this.document(), - args: this.builder.generator.findUniqueArgs('Document'), + args: this.builder.generator.findUniqueArgs("Document"), resolve: async (query, _root, args) => { return await this.prisma.document.findUnique({ ...query, where: args.where, - }) + }); }, }), documents: t.prismaField({ type: [this.document()], - args: this.builder.generator.findManyArgs('Document'), + args: this.builder.generator.findManyArgs("Document"), resolve: async (query, _root, args) => { return await this.prisma.document.findMany({ ...query, @@ -175,7 +245,7 @@ export class DocumentSchema extends PothosSchema { take: args.take ?? undefined, orderBy: args.orderBy ?? undefined, where: args.filter ?? undefined, - }) + }); }, }), newDocument: t.field({ @@ -183,39 +253,49 @@ export class DocumentSchema extends PothosSchema { args: {}, resolve: async (query, _args, ctx, _info) => { if (ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } - const userId = ctx.http?.me?.id + const userId = ctx.http?.me?.id; if (!userId) { - throw new Error('User not found') + throw new Error("User not found"); } const document = await this.prisma.document.create({ ...query, data: { - name: 'Untitled', - fileUrl: '', // fileUrl, + name: "Untitled", + fileUrl: "", // fileUrl, ownerId: userId, }, - }) - return document + }); + return document; }, }), testCheckGrammar: t.field({ - type: 'Boolean', + type: "Boolean", args: { - documentId: t.arg({ type: 'String', required: true }), - pageId: t.arg({ type: 'Int', required: true }), + documentId: t.arg({ type: "String", required: true }), + pageId: t.arg({ type: "Int", required: true }), promptType: t.arg({ - type: this.builder.enumType('PromptType', { - values: ['CHECK_GRAMMAR', 'REWRITE_TEXT', 'SUMMARIZE', 'TRANSLATE', 'EXPAND_CONTENT'] as const, + type: this.builder.enumType("PromptType", { + values: [ + "CHECK_GRAMMAR", + "REWRITE_TEXT", + "SUMMARIZE", + "TRANSLATE", + "EXPAND_CONTENT", + ] as const, }), required: true, }), }, resolve: async (_query, args, _ctx: SchemaContext) => { - await this.documentService.checkGrammarForPage(args.documentId, args.pageId, args.promptType as PromptType) - return true + await this.documentService.checkGrammarForPage( + args.documentId, + args.pageId, + args.promptType as PromptType + ); + return true; }, }), @@ -260,27 +340,32 @@ export class DocumentSchema extends PothosSchema { eventDocumentClientRequestSync: t.field({ type: this.documentDelta(), args: { - documentId: t.arg({ type: 'String', required: true }), - pageIndex: t.arg({ type: 'Int', required: true }), + documentId: t.arg({ type: "String", required: true }), + pageIndex: t.arg({ type: "Int", required: true }), }, resolve: async (_, args, ctx: SchemaContext) => { if (ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } if (!ctx.http?.me?.id) { - throw new Error('User not found') + throw new Error("User not found"); } if (!args.documentId) { - throw new Error('Document id not found') + throw new Error("Document id not found"); } if (args.pageIndex === undefined || args.pageIndex === null) { - throw new Error('Page index not found') + throw new Error("Page index not found"); } - let delta = null + let delta = null; try { - delta = await this.minio.getDocumentPage(args.documentId, args.pageIndex) + delta = await this.minio.getDocumentPage( + args.documentId, + args.pageIndex + ); } catch (_error) {} - const totalPage = await this.minio.countDocumentPages(args.documentId) + const totalPage = await this.minio.countDocumentPages( + args.documentId + ); return { documentId: args.documentId, pageIndex: args.pageIndex, @@ -288,51 +373,51 @@ export class DocumentSchema extends PothosSchema { totalPage, senderId: ctx.http?.me?.id, eventType: DocumentEvent.CLIENT_REQUEST_SYNC, - } + }; }, }), - })) + })); this.builder.mutationFields((t) => ({ createDocument: t.prismaField({ type: this.document(), args: { input: t.arg({ - type: this.builder.generator.getCreateInput('Document', [ - 'id', - 'ownerId', - 'createdAt', - 'updatedAt', - 'collaborators', - 'owner', - 'fileUrl', - 'previewImageUrl', - 'name', + type: this.builder.generator.getCreateInput("Document", [ + "id", + "ownerId", + "createdAt", + "updatedAt", + "collaborators", + "owner", + "fileUrl", + "previewImageUrl", + "name", ]), required: false, }), }, resolve: async (query, _parent, args, ctx: SchemaContext) => { if (ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } - const userId = ctx.http?.me?.id + const userId = ctx.http?.me?.id; if (!userId) { - throw new Error('Unauthorized') + throw new Error("Unauthorized"); } return await this.prisma.document.create({ ...query, data: { ...args.input, - name: args.input?.name ?? 'Untitled', - fileUrl: '', + name: args.input?.name ?? "Untitled", + fileUrl: "", owner: { connect: { id: userId, }, }, }, - }) + }); }, }), @@ -346,20 +431,32 @@ export class DocumentSchema extends PothosSchema { }, resolve: async (_, args, ctx: SchemaContext) => { if (ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } const { http: { pubSub }, - } = ctx - const senderId = ctx.http?.me?.id + } = ctx; + const senderId = ctx.http?.me?.id; if (!senderId) { - throw new Error('User not found') + throw new Error("User not found"); } - pubSub.publish(`${DocumentEvent.CHANGED}.${args.data.documentId}`, { - ...args.data, - senderId, - }) - return args.data + if (args.data.cursor) { + pubSub.publish( + `${DocumentEvent.CURSOR_MOVED}.${args.data.documentId}`, + { + ...args.data, + senderId, + eventType: DocumentEvent.CURSOR_MOVED, + } + ); + } else { + pubSub.publish(`${DocumentEvent.CHANGED}.${args.data.documentId}`, { + ...args.data, + senderId, + eventType: DocumentEvent.CHANGED, + }); + } + return args.data; }, }), @@ -370,58 +467,66 @@ export class DocumentSchema extends PothosSchema { }, resolve: async (_, args, ctx: SchemaContext) => { if (ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } - const senderId = ctx.http?.me?.id + const senderId = ctx.http?.me?.id; if (!args.data.documentId) { - throw new Error('Document id not found') + throw new Error("Document id not found"); } if (!senderId) { - throw new Error('User not found') + throw new Error("User not found"); } - if (args.data.pageIndex === undefined || args.data.pageIndex === null) { - throw new Error('Page index not found') + if ( + args.data.pageIndex === undefined || + args.data.pageIndex === null + ) { + throw new Error("Page index not found"); } // save delta to minio - const delta = args.data.delta - if (!delta) { - throw new Error('Delta not found') + const delta = args.data.delta; + if (delta) { + await this.minio.upsertDocumentPage( + args.data.documentId, + args.data.pageIndex, + delta + ); } - await this.minio.upsertDocumentPage(args.data.documentId, args.data.pageIndex, delta) - const totalPage = await this.minio.countDocumentPages(args.data.documentId) + const totalPage = await this.minio.countDocumentPages( + args.data.documentId + ); return { ...args.data, totalPage, - senderId: 'server', + senderId: "server", eventType: DocumentEvent.SERVER_REQUEST_SYNC, - } + }; }, }), updateDocument: t.prismaField({ type: this.document(), args: { - documentId: t.arg({ type: 'String', required: true }), + documentId: t.arg({ type: "String", required: true }), data: t.arg({ - type: this.builder.generator.getUpdateInput('Document', [ - 'id', - 'ownerId', - 'createdAt', - 'updatedAt', - 'collaborationSessionId', - 'collaborationSession', - 'owner', - 'fileUrl', - 'previewImageUrl', + type: this.builder.generator.getUpdateInput("Document", [ + "id", + "ownerId", + "createdAt", + "updatedAt", + "collaborationSessionId", + "collaborationSession", + "owner", + "fileUrl", + "previewImageUrl", ]), required: true, }), }, resolve: async (query, _parent, args, ctx: SchemaContext) => { if (ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } if (!ctx.http?.me?.id) { - throw new Error('Unauthorized') + throw new Error("Unauthorized"); } // check if user is owner or collaborator const document = await this.prisma.document.findUnique({ @@ -429,45 +534,47 @@ export class DocumentSchema extends PothosSchema { include: { collaborators: true, }, - }) + }); if (!document) { - throw new Error('Document not found') + throw new Error("Document not found"); } if ( !document.isPublic && - !document.collaborators.some((c) => c.userId === ctx.http?.me?.id && c.writable) && + !document.collaborators.some( + (c) => c.userId === ctx.http?.me?.id && c.writable + ) && document.ownerId !== ctx.http?.me?.id ) { - throw new Error('User is not owner or collaborator of document') + throw new Error("User is not owner or collaborator of document"); } return await this.prisma.document.update({ ...query, where: { id: args.documentId }, data: args.data, - }) + }); }, }), addCollaborator: t.prismaField({ type: this.documentCollaborator(), args: { - documentId: t.arg({ type: 'String', required: true }), - userId: t.arg({ type: 'String', required: true }), - readable: t.arg({ type: 'Boolean', required: true }), - writable: t.arg({ type: 'Boolean', required: true }), + documentId: t.arg({ type: "String", required: true }), + userId: t.arg({ type: "String", required: true }), + readable: t.arg({ type: "Boolean", required: true }), + writable: t.arg({ type: "Boolean", required: true }), }, resolve: async (_, __, args, ctx: SchemaContext) => { if (ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } // check if ctx user is owner of document const document = await this.prisma.document.findUnique({ where: { id: args.documentId }, - }) + }); if (!document) { - throw new Error('Document not found') + throw new Error("Document not found"); } if (document.ownerId !== ctx.http?.me?.id) { - throw new Error('User is not owner of document') + throw new Error("User is not owner of document"); } return await this.prisma.documentCollaborator.create({ data: { @@ -476,133 +583,149 @@ export class DocumentSchema extends PothosSchema { readable: args.readable, writable: args.writable, }, - }) + }); }, }), removeCollaborator: t.prismaField({ type: this.documentCollaborator(), args: { - documentId: t.arg({ type: 'String', required: true }), - userId: t.arg({ type: 'String', required: true }), + documentId: t.arg({ type: "String", required: true }), + userId: t.arg({ type: "String", required: true }), }, resolve: async (_, __, args, ctx: SchemaContext) => { if (ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } // check if ctx user is owner of document const document = await this.prisma.document.findUnique({ where: { id: args.documentId }, - }) + }); if (!document) { - throw new Error('Document not found') + throw new Error("Document not found"); } if (document.ownerId !== ctx.http?.me?.id) { - throw new Error('User is not owner of document') + throw new Error("User is not owner of document"); } return await this.prisma.documentCollaborator.delete({ - where: { documentId_userId: { documentId: args.documentId, userId: args.userId } }, - }) + where: { + documentId_userId: { + documentId: args.documentId, + userId: args.userId, + }, + }, + }); }, }), editCollaboratorPermission: t.prismaField({ type: this.documentCollaborator(), args: { - documentId: t.arg({ type: 'String', required: true }), - userId: t.arg({ type: 'String', required: true }), - readable: t.arg({ type: 'Boolean', required: true }), - writable: t.arg({ type: 'Boolean', required: true }), + documentId: t.arg({ type: "String", required: true }), + userId: t.arg({ type: "String", required: true }), + readable: t.arg({ type: "Boolean", required: true }), + writable: t.arg({ type: "Boolean", required: true }), }, resolve: async (_, __, args, ctx: SchemaContext) => { if (ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } // check if ctx user is owner of document const document = await this.prisma.document.findUnique({ where: { id: args.documentId }, - }) + }); if (!document) { - throw new Error('Document not found') + throw new Error("Document not found"); } if (document.ownerId !== ctx.http?.me?.id) { - throw new Error('User is not owner of document') + throw new Error("User is not owner of document"); } return await this.prisma.documentCollaborator.update({ - where: { documentId_userId: { documentId: args.documentId, userId: args.userId } }, + where: { + documentId_userId: { + documentId: args.documentId, + userId: args.userId, + }, + }, data: { readable: args.readable, writable: args.writable }, - }) + }); }, }), updateDocumentPreviewImage: t.prismaField({ type: this.document(), args: { - documentId: t.arg({ type: 'String', required: true }), - imageId: t.arg({ type: 'String', required: true }), + documentId: t.arg({ type: "String", required: true }), + imageId: t.arg({ type: "String", required: true }), }, resolve: async (query, _parent, args, ctx: SchemaContext) => { if (ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } const document = await this.prisma.document.findUnique({ where: { id: args.documentId }, include: { collaborators: true, }, - }) + }); if (!document) { - throw new Error('Document not found') + throw new Error("Document not found"); } if ( document.ownerId !== ctx.http?.me?.id && - !document.collaborators.some((c) => c.userId === ctx.http?.me?.id && (c.writable || c.readable)) + !document.collaborators.some( + (c) => c.userId === ctx.http?.me?.id && (c.writable || c.readable) + ) ) { - throw new Error('User is not owner or collaborator of document') + throw new Error("User is not owner or collaborator of document"); } // check if imageId is exist const image = await this.prisma.uploadedFile.findUnique({ where: { id: args.imageId }, - }) + }); if (!image) { - throw new Error('Image not found') + throw new Error("Image not found"); } return await this.prisma.document.update({ ...query, where: { id: args.documentId }, data: { previewImageId: args.imageId }, - }) + }); }, }), - })) + })); this.builder.subscriptionFields((t) => ({ document: t.field({ type: this.documentDelta(), args: { documentId: t.arg({ - type: 'String', + type: "String", required: true, }), }, subscribe: async (_, args, ctx: SchemaContext) => { if (!ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } - const documentId = args.documentId + const documentId = args.documentId; // check user permission const document = await this.prisma.document.findUnique({ where: { id: documentId }, include: { collaborators: true, }, - }) + }); if (!document) { - throw new Error('Document not found') + throw new Error("Document not found"); } if (!document.isPublic) { if ( document.ownerId !== ctx.websocket?.me?.id && - !document.collaborators.some((c) => c.userId === ctx.websocket?.me?.id && (c.writable || c.readable)) + !document.collaborators.some( + (c) => + c.userId === ctx.websocket?.me?.id && + (c.writable || c.readable) + ) ) { - throw new Error('User is not owner or collaborator of document') + throw new Error("User is not owner or collaborator of document"); } } return ctx.websocket.pubSub.asyncIterator([ @@ -613,87 +736,108 @@ export class DocumentSchema extends PothosSchema { `${DocumentEvent.PAGE_DELETED}.${documentId}`, `${DocumentEvent.ACTIVE_DOCUMENT_ID_CHANGED}.${documentId}`, `${DocumentEvent.AI_SUGGESTION}.${documentId}`, - ]) as unknown as AsyncIterable + `${DocumentEvent.CURSOR_MOVED}.${documentId}`, + ]) as unknown as AsyncIterable; }, resolve: async (payload: DocumentDelta, _args, ctx: SchemaContext) => { if (!ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } // If there's an explicit sync request, pass it through immediately if (payload.requestSync) { - return payload + return payload; } // Only perform background check with a very low probability - const SYNC_PROBABILITY = 0.01 // 0.1% chance - const SYNC_INTERVAL_MS = 15000 // 15 seconds minimum between syncs + const SYNC_PROBABILITY = 0.01; // 0.1% chance + const SYNC_INTERVAL_MS = 15000; // 15 seconds minimum between syncs - const syncKey = `document:sync:${payload.documentId}:${payload.pageIndex}` + const syncKey = `document:sync:${payload.documentId}:${payload.pageIndex}`; try { // Quick, non-blocking check for potential background processing - const lastSyncTimeStr = await this.redis.get(syncKey) - const lastSyncTime = lastSyncTimeStr ? parseInt(lastSyncTimeStr, 10) : 0 - const currentTime = DateTimeUtils.now().toMillis() + const lastSyncTimeStr = await this.redis.get(syncKey); + const lastSyncTime = lastSyncTimeStr + ? parseInt(lastSyncTimeStr, 10) + : 0; + const currentTime = DateTimeUtils.now().toMillis(); // Only proceed if enough time has passed and we hit the random chance - if (currentTime - lastSyncTime >= SYNC_INTERVAL_MS && Math.random() <= SYNC_PROBABILITY) { + if ( + currentTime - lastSyncTime >= SYNC_INTERVAL_MS && + Math.random() <= SYNC_PROBABILITY + ) { // Fire and forget - don't await or block - this.backgroundGrammarCheck(payload.documentId, payload.pageIndex, ctx) + this.backgroundGrammarCheck( + payload.documentId, + payload.pageIndex, + ctx + ); } // biome-ignore lint/suspicious/noExplicitAny: error is any } catch (error: any) { - Logger.error('Background sync check failed', { + Logger.error("Background sync check failed", { documentId: payload.documentId, error: error.message, - }) + }); } // Always return payload immediately - return payload + return payload; }, }), - })) + })); } // Separate method for background processing - private async backgroundGrammarCheck(documentId: string, pageIndex: number, ctx: SchemaContext): Promise { + private async backgroundGrammarCheck( + documentId: string, + pageIndex: number, + ctx: SchemaContext + ): Promise { if (!ctx.isSubscription) { - throw new Error('Not allowed') + throw new Error("Not allowed"); } try { - const syncKey = `document:sync:${documentId}:${pageIndex}` - const currentTime = DateTimeUtils.now().toMillis().toString() + const syncKey = `document:sync:${documentId}:${pageIndex}`; + const currentTime = DateTimeUtils.now().toMillis().toString(); // Update sync time immediately to prevent multiple concurrent checks - await this.redis.setPX(syncKey, currentTime, 60000) + await this.redis.setPX(syncKey, currentTime, 60000); - Logger.log('Initiating background grammar check', { + Logger.log("Initiating background grammar check", { documentId, pageIndex, - }) + }); // Perform grammar check - const delta = await this.documentService.checkGrammarForPage(documentId, pageIndex, PromptType.CHECK_GRAMMAR) + const delta = await this.documentService.checkGrammarForPage( + documentId, + pageIndex, + PromptType.CHECK_GRAMMAR + ); if (!delta) { - return + return; } // Optionally publish AI suggestion if needed - ctx.websocket.pubSub.publish(`${DocumentEvent.AI_SUGGESTION}.${documentId}`, { - documentId, - pageIndex, - eventType: DocumentEvent.AI_SUGGESTION, - delta, - senderId: 'system', - }) + ctx.websocket.pubSub.publish( + `${DocumentEvent.AI_SUGGESTION}.${documentId}`, + { + documentId, + pageIndex, + eventType: DocumentEvent.AI_SUGGESTION, + delta, + senderId: "system", + } + ); // biome-ignore lint/suspicious/noExplicitAny: error is any } catch (error: any) { - Logger.error('Background grammar check failed', { + Logger.error("Background grammar check failed", { documentId, pageIndex, error: error.message, - }) + }); } } }