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.
This commit is contained in:
2024-12-17 05:24:46 +07:00
parent 6b0b95bb32
commit 637e044a05
4 changed files with 387 additions and 236 deletions

View File

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

View File

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

View File

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

View File

@@ -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");
}
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,
})
return args.data
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<DocumentDelta>
`${DocumentEvent.CURSOR_MOVED}.${documentId}`,
]) as unknown as AsyncIterable<DocumentDelta>;
},
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<void> {
private async backgroundGrammarCheck(
documentId: string,
pageIndex: number,
ctx: SchemaContext
): Promise<void> {
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}`, {
ctx.websocket.pubSub.publish(
`${DocumentEvent.AI_SUGGESTION}.${documentId}`,
{
documentId,
pageIndex,
eventType: DocumentEvent.AI_SUGGESTION,
delta,
senderId: 'system',
})
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,
})
});
}
}
}