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 dockerfile: Dockerfile
pull_policy: always pull_policy: always
ports: ports:
- '8888:3069' - "8888:3069"
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- DISABLE_AUTH=false - DISABLE_AUTH=false
@@ -51,11 +51,11 @@ services:
- LIVEKIT_CERT_FILE=/path/to/turn.crt - LIVEKIT_CERT_FILE=/path/to/turn.crt
- LIVEKIT_KEY_FILE=/path/to/turn.key - LIVEKIT_KEY_FILE=/path/to/turn.key
labels: labels:
- 'traefik.enable=true' - "traefik.enable=true"
- 'traefik.http.routers.api.rule=Host(`api.epess.org`)' - "traefik.http.routers.api.rule=Host(`api.epess.org`)"
- 'traefik.http.routers.api.entrypoints=web' - "traefik.http.routers.api.entrypoints=web"
- 'traefik.http.routers.api.service=api' - "traefik.http.routers.api.service=api"
- 'traefik.http.services.api.loadbalancer.server.port=3069' - "traefik.http.services.api.loadbalancer.server.port=3069"
networks: networks:
- epess-net - epess-net
restart: always restart: always

View File

@@ -140,13 +140,19 @@
"@css-inline/css-inline-linux-x64-musl": "^0.14.3" "@css-inline/css-inline-linux-x64-musl": "^0.14.3"
}, },
"jest": { "jest": {
"moduleFileExtensions": ["js", "json", "ts"], "moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src", "rootDir": "src",
"testRegex": ".*\\.spec\\.ts$", "testRegex": ".*\\.spec\\.ts$",
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"collectCoverageFrom": ["**/*.(t|j)s"], "collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
}, },

View File

@@ -7,5 +7,6 @@ export enum DocumentEvent {
ACTIVE_DOCUMENT_ID_CHANGED = 'document_active_document_id_changed', ACTIVE_DOCUMENT_ID_CHANGED = 'document_active_document_id_changed',
CLIENT_REQUEST_SYNC = 'document_client_request_sync', CLIENT_REQUEST_SYNC = 'document_client_request_sync',
SERVER_REQUEST_SYNC = 'document_server_request_sync', SERVER_REQUEST_SYNC = 'document_server_request_sync',
CURSOR_MOVED = 'document_cursor_moved',
AI_SUGGESTION = 'document_ai_suggestion', AI_SUGGESTION = 'document_ai_suggestion',
} }

View File

@@ -1,16 +1,22 @@
import { Inject, Injectable, Logger } from '@nestjs/common' import { Inject, Injectable, Logger } from "@nestjs/common";
import { Document } from '@prisma/client' import { Document } from "@prisma/client";
import { Pothos, PothosRef, PothosSchema, SchemaBuilderToken } from '@smatch-corp/nestjs-pothos' import {
import Delta from 'quill-delta' Pothos,
import { MinioService } from 'src/Minio/minio.service' PothosRef,
import { PromptType } from 'src/OpenAI/openai.service' PothosSchema,
import { RedisService } from 'src/Redis/redis.service' SchemaBuilderToken,
import { DateTimeUtils } from 'src/common/utils/datetime.utils' } from "@smatch-corp/nestjs-pothos";
import { Builder, SchemaContext } from '../Graphql/graphql.builder' import Delta from "quill-delta";
import { PrismaService } from '../Prisma/prisma.service' import { MinioService } from "src/Minio/minio.service";
import { DocumentEvent } from './document.event' import { PromptType } from "src/OpenAI/openai.service";
import { DocumentService } from './document.service' import { RedisService } from "src/Redis/redis.service";
import { DocumentDelta } from './document.type' 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() @Injectable()
export class DocumentSchema extends PothosSchema { export class DocumentSchema extends PothosSchema {
constructor( constructor(
@@ -18,67 +24,82 @@ export class DocumentSchema extends PothosSchema {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly minio: MinioService, private readonly minio: MinioService,
private readonly documentService: DocumentService, private readonly documentService: DocumentService,
private readonly redis: RedisService, private readonly redis: RedisService
) { ) {
super() super();
} }
@PothosRef() @PothosRef()
document() { document() {
return this.builder.prismaObject('Document', { return this.builder.prismaObject("Document", {
fields: (t) => ({ fields: (t) => ({
id: t.exposeID('id', { description: 'The ID of the document.', nullable: false }), id: t.exposeID("id", {
name: t.exposeString('name', { description: 'The name of the document.', nullable: false }), description: "The ID of the document.",
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, nullable: false,
}), }),
isPublic: t.exposeBoolean('isPublic', { description: 'Whether the document is public.', nullable: false }), name: t.exposeString("name", {
collaborationSession: t.relation('collaborationSession', { description: "The name of the document.",
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, nullable: false,
}), }),
updatedAt: t.expose('updatedAt', { fileUrl: t.exposeString("fileUrl", {
description: 'The update time of the document.', description: "The file URL of the document.",
type: 'DateTime', 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, nullable: false,
}), }),
}), }),
}) });
} }
@PothosRef() @PothosRef()
documentCollaborator() { documentCollaborator() {
return this.builder.prismaObject('DocumentCollaborator', { return this.builder.prismaObject("DocumentCollaborator", {
fields: (t) => ({ fields: (t) => ({
documentId: t.exposeID('documentId', { nullable: false }), documentId: t.exposeID("documentId", { nullable: false }),
userId: t.exposeID('userId', { nullable: false }), userId: t.exposeID("userId", { nullable: false }),
document: t.relation('document', { nullable: false }), document: t.relation("document", { nullable: false }),
user: t.relation('user', { nullable: false }), user: t.relation("user", { nullable: false }),
readable: t.exposeBoolean('readable', { nullable: false }), readable: t.exposeBoolean("readable", { nullable: false }),
writable: t.exposeBoolean('writable', { nullable: false }), writable: t.exposeBoolean("writable", { nullable: false }),
}), }),
}) });
} }
@PothosRef() @PothosRef()
documentDelta() { documentDelta() {
return this.builder.simpleObject('DocumentDelta', { return this.builder.simpleObject("DocumentDelta", {
fields: (t) => ({ fields: (t) => ({
eventType: t.string(), eventType: t.string(),
documentId: t.string({ documentId: t.string({
@@ -88,7 +109,7 @@ export class DocumentSchema extends PothosSchema {
nullable: true, nullable: true,
}), }),
delta: t.field({ delta: t.field({
type: 'Delta', type: "Delta",
nullable: true, nullable: true,
}), }),
senderId: t.string({ senderId: t.string({
@@ -97,39 +118,85 @@ export class DocumentSchema extends PothosSchema {
requestSync: t.boolean({ requestSync: t.boolean({
nullable: true, nullable: true,
}), }),
cursor: t.field({
type: this.cursor(),
nullable: true,
}),
totalPage: t.int({ totalPage: t.int({
nullable: true, 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() @PothosRef()
DocumentExportObject() { DocumentExportObject() {
return this.builder.simpleObject('DocumentExportObject', { return this.builder.simpleObject("DocumentExportObject", {
fields: (t) => ({ fields: (t) => ({
documentId: t.string(), documentId: t.string(),
pageIndex: t.int(), pageIndex: t.int(),
type: t.field({ type: t.field({
type: this.builder.enumType('DocumentExportType', { type: this.builder.enumType("DocumentExportType", {
values: ['PDF', 'DOCX'] as const, values: ["PDF", "DOCX"] as const,
}), }),
nullable: false, nullable: false,
}), }),
fileUrl: t.string(), fileUrl: t.string(),
}), }),
}) });
} }
@PothosRef() @PothosRef()
documentDeltaInput() { documentDeltaInput() {
return this.builder.inputType('DocumentDeltaInput', { return this.builder.inputType("DocumentDeltaInput", {
fields: (t) => ({ fields: (t) => ({
documentId: t.string(), documentId: t.string(),
pageIndex: t.int(), 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() @Pothos()
@@ -137,37 +204,40 @@ export class DocumentSchema extends PothosSchema {
this.builder.queryFields((t) => ({ this.builder.queryFields((t) => ({
myDocuments: t.prismaField({ myDocuments: t.prismaField({
type: [this.document()], type: [this.document()],
args: this.builder.generator.findManyArgs('Document'), args: this.builder.generator.findManyArgs("Document"),
resolve: async (query, _parent, args, ctx: SchemaContext) => { resolve: async (query, _parent, args, ctx: SchemaContext) => {
if (ctx.isSubscription) { if (ctx.isSubscription) {
throw new Error('Not allowed') throw new Error("Not allowed");
} }
if (!ctx.http?.me?.id) { if (!ctx.http?.me?.id) {
throw new Error('User not found') throw new Error("User not found");
} }
return await this.prisma.document.findMany({ return await this.prisma.document.findMany({
...query, ...query,
orderBy: args.orderBy ?? undefined, orderBy: args.orderBy ?? undefined,
where: { 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({ document: t.prismaField({
type: this.document(), type: this.document(),
args: this.builder.generator.findUniqueArgs('Document'), args: this.builder.generator.findUniqueArgs("Document"),
resolve: async (query, _root, args) => { resolve: async (query, _root, args) => {
return await this.prisma.document.findUnique({ return await this.prisma.document.findUnique({
...query, ...query,
where: args.where, where: args.where,
}) });
}, },
}), }),
documents: t.prismaField({ documents: t.prismaField({
type: [this.document()], type: [this.document()],
args: this.builder.generator.findManyArgs('Document'), args: this.builder.generator.findManyArgs("Document"),
resolve: async (query, _root, args) => { resolve: async (query, _root, args) => {
return await this.prisma.document.findMany({ return await this.prisma.document.findMany({
...query, ...query,
@@ -175,7 +245,7 @@ export class DocumentSchema extends PothosSchema {
take: args.take ?? undefined, take: args.take ?? undefined,
orderBy: args.orderBy ?? undefined, orderBy: args.orderBy ?? undefined,
where: args.filter ?? undefined, where: args.filter ?? undefined,
}) });
}, },
}), }),
newDocument: t.field({ newDocument: t.field({
@@ -183,39 +253,49 @@ export class DocumentSchema extends PothosSchema {
args: {}, args: {},
resolve: async (query, _args, ctx, _info) => { resolve: async (query, _args, ctx, _info) => {
if (ctx.isSubscription) { 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) { if (!userId) {
throw new Error('User not found') throw new Error("User not found");
} }
const document = await this.prisma.document.create({ const document = await this.prisma.document.create({
...query, ...query,
data: { data: {
name: 'Untitled', name: "Untitled",
fileUrl: '', // fileUrl, fileUrl: "", // fileUrl,
ownerId: userId, ownerId: userId,
}, },
}) });
return document return document;
}, },
}), }),
testCheckGrammar: t.field({ testCheckGrammar: t.field({
type: 'Boolean', type: "Boolean",
args: { args: {
documentId: t.arg({ type: 'String', required: true }), documentId: t.arg({ type: "String", required: true }),
pageId: t.arg({ type: 'Int', required: true }), pageId: t.arg({ type: "Int", required: true }),
promptType: t.arg({ promptType: t.arg({
type: this.builder.enumType('PromptType', { type: this.builder.enumType("PromptType", {
values: ['CHECK_GRAMMAR', 'REWRITE_TEXT', 'SUMMARIZE', 'TRANSLATE', 'EXPAND_CONTENT'] as const, values: [
"CHECK_GRAMMAR",
"REWRITE_TEXT",
"SUMMARIZE",
"TRANSLATE",
"EXPAND_CONTENT",
] as const,
}), }),
required: true, required: true,
}), }),
}, },
resolve: async (_query, args, _ctx: SchemaContext) => { resolve: async (_query, args, _ctx: SchemaContext) => {
await this.documentService.checkGrammarForPage(args.documentId, args.pageId, args.promptType as PromptType) await this.documentService.checkGrammarForPage(
return true args.documentId,
args.pageId,
args.promptType as PromptType
);
return true;
}, },
}), }),
@@ -260,27 +340,32 @@ export class DocumentSchema extends PothosSchema {
eventDocumentClientRequestSync: t.field({ eventDocumentClientRequestSync: t.field({
type: this.documentDelta(), type: this.documentDelta(),
args: { args: {
documentId: t.arg({ type: 'String', required: true }), documentId: t.arg({ type: "String", required: true }),
pageIndex: t.arg({ type: 'Int', required: true }), pageIndex: t.arg({ type: "Int", required: true }),
}, },
resolve: async (_, args, ctx: SchemaContext) => { resolve: async (_, args, ctx: SchemaContext) => {
if (ctx.isSubscription) { if (ctx.isSubscription) {
throw new Error('Not allowed') throw new Error("Not allowed");
} }
if (!ctx.http?.me?.id) { if (!ctx.http?.me?.id) {
throw new Error('User not found') throw new Error("User not found");
} }
if (!args.documentId) { if (!args.documentId) {
throw new Error('Document id not found') throw new Error("Document id not found");
} }
if (args.pageIndex === undefined || args.pageIndex === null) { 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 { try {
delta = await this.minio.getDocumentPage(args.documentId, args.pageIndex) delta = await this.minio.getDocumentPage(
args.documentId,
args.pageIndex
);
} catch (_error) {} } catch (_error) {}
const totalPage = await this.minio.countDocumentPages(args.documentId) const totalPage = await this.minio.countDocumentPages(
args.documentId
);
return { return {
documentId: args.documentId, documentId: args.documentId,
pageIndex: args.pageIndex, pageIndex: args.pageIndex,
@@ -288,51 +373,51 @@ export class DocumentSchema extends PothosSchema {
totalPage, totalPage,
senderId: ctx.http?.me?.id, senderId: ctx.http?.me?.id,
eventType: DocumentEvent.CLIENT_REQUEST_SYNC, eventType: DocumentEvent.CLIENT_REQUEST_SYNC,
} };
}, },
}), }),
})) }));
this.builder.mutationFields((t) => ({ this.builder.mutationFields((t) => ({
createDocument: t.prismaField({ createDocument: t.prismaField({
type: this.document(), type: this.document(),
args: { args: {
input: t.arg({ input: t.arg({
type: this.builder.generator.getCreateInput('Document', [ type: this.builder.generator.getCreateInput("Document", [
'id', "id",
'ownerId', "ownerId",
'createdAt', "createdAt",
'updatedAt', "updatedAt",
'collaborators', "collaborators",
'owner', "owner",
'fileUrl', "fileUrl",
'previewImageUrl', "previewImageUrl",
'name', "name",
]), ]),
required: false, required: false,
}), }),
}, },
resolve: async (query, _parent, args, ctx: SchemaContext) => { resolve: async (query, _parent, args, ctx: SchemaContext) => {
if (ctx.isSubscription) { 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) { if (!userId) {
throw new Error('Unauthorized') throw new Error("Unauthorized");
} }
return await this.prisma.document.create({ return await this.prisma.document.create({
...query, ...query,
data: { data: {
...args.input, ...args.input,
name: args.input?.name ?? 'Untitled', name: args.input?.name ?? "Untitled",
fileUrl: '', fileUrl: "",
owner: { owner: {
connect: { connect: {
id: userId, id: userId,
}, },
}, },
}, },
}) });
}, },
}), }),
@@ -346,20 +431,32 @@ export class DocumentSchema extends PothosSchema {
}, },
resolve: async (_, args, ctx: SchemaContext) => { resolve: async (_, args, ctx: SchemaContext) => {
if (ctx.isSubscription) { if (ctx.isSubscription) {
throw new Error('Not allowed') throw new Error("Not allowed");
} }
const { const {
http: { pubSub }, http: { pubSub },
} = ctx } = ctx;
const senderId = ctx.http?.me?.id const senderId = ctx.http?.me?.id;
if (!senderId) { 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}`, { pubSub.publish(`${DocumentEvent.CHANGED}.${args.data.documentId}`, {
...args.data, ...args.data,
senderId, senderId,
}) eventType: DocumentEvent.CHANGED,
return args.data });
}
return args.data;
}, },
}), }),
@@ -370,58 +467,66 @@ export class DocumentSchema extends PothosSchema {
}, },
resolve: async (_, args, ctx: SchemaContext) => { resolve: async (_, args, ctx: SchemaContext) => {
if (ctx.isSubscription) { 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) { if (!args.data.documentId) {
throw new Error('Document id not found') throw new Error("Document id not found");
} }
if (!senderId) { if (!senderId) {
throw new Error('User not found') throw new Error("User not found");
} }
if (args.data.pageIndex === undefined || args.data.pageIndex === null) { if (
throw new Error('Page index not found') args.data.pageIndex === undefined ||
args.data.pageIndex === null
) {
throw new Error("Page index not found");
} }
// save delta to minio // save delta to minio
const delta = args.data.delta const delta = args.data.delta;
if (!delta) { if (delta) {
throw new Error('Delta not found') 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(
const totalPage = await this.minio.countDocumentPages(args.data.documentId) args.data.documentId
);
return { return {
...args.data, ...args.data,
totalPage, totalPage,
senderId: 'server', senderId: "server",
eventType: DocumentEvent.SERVER_REQUEST_SYNC, eventType: DocumentEvent.SERVER_REQUEST_SYNC,
} };
}, },
}), }),
updateDocument: t.prismaField({ updateDocument: t.prismaField({
type: this.document(), type: this.document(),
args: { args: {
documentId: t.arg({ type: 'String', required: true }), documentId: t.arg({ type: "String", required: true }),
data: t.arg({ data: t.arg({
type: this.builder.generator.getUpdateInput('Document', [ type: this.builder.generator.getUpdateInput("Document", [
'id', "id",
'ownerId', "ownerId",
'createdAt', "createdAt",
'updatedAt', "updatedAt",
'collaborationSessionId', "collaborationSessionId",
'collaborationSession', "collaborationSession",
'owner', "owner",
'fileUrl', "fileUrl",
'previewImageUrl', "previewImageUrl",
]), ]),
required: true, required: true,
}), }),
}, },
resolve: async (query, _parent, args, ctx: SchemaContext) => { resolve: async (query, _parent, args, ctx: SchemaContext) => {
if (ctx.isSubscription) { if (ctx.isSubscription) {
throw new Error('Not allowed') throw new Error("Not allowed");
} }
if (!ctx.http?.me?.id) { if (!ctx.http?.me?.id) {
throw new Error('Unauthorized') throw new Error("Unauthorized");
} }
// check if user is owner or collaborator // check if user is owner or collaborator
const document = await this.prisma.document.findUnique({ const document = await this.prisma.document.findUnique({
@@ -429,45 +534,47 @@ export class DocumentSchema extends PothosSchema {
include: { include: {
collaborators: true, collaborators: true,
}, },
}) });
if (!document) { if (!document) {
throw new Error('Document not found') throw new Error("Document not found");
} }
if ( if (
!document.isPublic && !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 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({ return await this.prisma.document.update({
...query, ...query,
where: { id: args.documentId }, where: { id: args.documentId },
data: args.data, data: args.data,
}) });
}, },
}), }),
addCollaborator: t.prismaField({ addCollaborator: t.prismaField({
type: this.documentCollaborator(), type: this.documentCollaborator(),
args: { args: {
documentId: t.arg({ type: 'String', required: true }), documentId: t.arg({ type: "String", required: true }),
userId: t.arg({ type: 'String', required: true }), userId: t.arg({ type: "String", required: true }),
readable: t.arg({ type: 'Boolean', required: true }), readable: t.arg({ type: "Boolean", required: true }),
writable: t.arg({ type: 'Boolean', required: true }), writable: t.arg({ type: "Boolean", required: true }),
}, },
resolve: async (_, __, args, ctx: SchemaContext) => { resolve: async (_, __, args, ctx: SchemaContext) => {
if (ctx.isSubscription) { if (ctx.isSubscription) {
throw new Error('Not allowed') throw new Error("Not allowed");
} }
// check if ctx user is owner of document // check if ctx user is owner of document
const document = await this.prisma.document.findUnique({ const document = await this.prisma.document.findUnique({
where: { id: args.documentId }, where: { id: args.documentId },
}) });
if (!document) { if (!document) {
throw new Error('Document not found') throw new Error("Document not found");
} }
if (document.ownerId !== ctx.http?.me?.id) { 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({ return await this.prisma.documentCollaborator.create({
data: { data: {
@@ -476,133 +583,149 @@ export class DocumentSchema extends PothosSchema {
readable: args.readable, readable: args.readable,
writable: args.writable, writable: args.writable,
}, },
}) });
}, },
}), }),
removeCollaborator: t.prismaField({ removeCollaborator: t.prismaField({
type: this.documentCollaborator(), type: this.documentCollaborator(),
args: { args: {
documentId: t.arg({ type: 'String', required: true }), documentId: t.arg({ type: "String", required: true }),
userId: t.arg({ type: 'String', required: true }), userId: t.arg({ type: "String", required: true }),
}, },
resolve: async (_, __, args, ctx: SchemaContext) => { resolve: async (_, __, args, ctx: SchemaContext) => {
if (ctx.isSubscription) { if (ctx.isSubscription) {
throw new Error('Not allowed') throw new Error("Not allowed");
} }
// check if ctx user is owner of document // check if ctx user is owner of document
const document = await this.prisma.document.findUnique({ const document = await this.prisma.document.findUnique({
where: { id: args.documentId }, where: { id: args.documentId },
}) });
if (!document) { if (!document) {
throw new Error('Document not found') throw new Error("Document not found");
} }
if (document.ownerId !== ctx.http?.me?.id) { 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({ 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({ editCollaboratorPermission: t.prismaField({
type: this.documentCollaborator(), type: this.documentCollaborator(),
args: { args: {
documentId: t.arg({ type: 'String', required: true }), documentId: t.arg({ type: "String", required: true }),
userId: t.arg({ type: 'String', required: true }), userId: t.arg({ type: "String", required: true }),
readable: t.arg({ type: 'Boolean', required: true }), readable: t.arg({ type: "Boolean", required: true }),
writable: t.arg({ type: 'Boolean', required: true }), writable: t.arg({ type: "Boolean", required: true }),
}, },
resolve: async (_, __, args, ctx: SchemaContext) => { resolve: async (_, __, args, ctx: SchemaContext) => {
if (ctx.isSubscription) { if (ctx.isSubscription) {
throw new Error('Not allowed') throw new Error("Not allowed");
} }
// check if ctx user is owner of document // check if ctx user is owner of document
const document = await this.prisma.document.findUnique({ const document = await this.prisma.document.findUnique({
where: { id: args.documentId }, where: { id: args.documentId },
}) });
if (!document) { if (!document) {
throw new Error('Document not found') throw new Error("Document not found");
} }
if (document.ownerId !== ctx.http?.me?.id) { 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({ 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 }, data: { readable: args.readable, writable: args.writable },
}) });
}, },
}), }),
updateDocumentPreviewImage: t.prismaField({ updateDocumentPreviewImage: t.prismaField({
type: this.document(), type: this.document(),
args: { args: {
documentId: t.arg({ type: 'String', required: true }), documentId: t.arg({ type: "String", required: true }),
imageId: t.arg({ type: 'String', required: true }), imageId: t.arg({ type: "String", required: true }),
}, },
resolve: async (query, _parent, args, ctx: SchemaContext) => { resolve: async (query, _parent, args, ctx: SchemaContext) => {
if (ctx.isSubscription) { if (ctx.isSubscription) {
throw new Error('Not allowed') throw new Error("Not allowed");
} }
const document = await this.prisma.document.findUnique({ const document = await this.prisma.document.findUnique({
where: { id: args.documentId }, where: { id: args.documentId },
include: { include: {
collaborators: true, collaborators: true,
}, },
}) });
if (!document) { if (!document) {
throw new Error('Document not found') throw new Error("Document not found");
} }
if ( if (
document.ownerId !== ctx.http?.me?.id && 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 // check if imageId is exist
const image = await this.prisma.uploadedFile.findUnique({ const image = await this.prisma.uploadedFile.findUnique({
where: { id: args.imageId }, where: { id: args.imageId },
}) });
if (!image) { if (!image) {
throw new Error('Image not found') throw new Error("Image not found");
} }
return await this.prisma.document.update({ return await this.prisma.document.update({
...query, ...query,
where: { id: args.documentId }, where: { id: args.documentId },
data: { previewImageId: args.imageId }, data: { previewImageId: args.imageId },
}) });
}, },
}), }),
})) }));
this.builder.subscriptionFields((t) => ({ this.builder.subscriptionFields((t) => ({
document: t.field({ document: t.field({
type: this.documentDelta(), type: this.documentDelta(),
args: { args: {
documentId: t.arg({ documentId: t.arg({
type: 'String', type: "String",
required: true, required: true,
}), }),
}, },
subscribe: async (_, args, ctx: SchemaContext) => { subscribe: async (_, args, ctx: SchemaContext) => {
if (!ctx.isSubscription) { if (!ctx.isSubscription) {
throw new Error('Not allowed') throw new Error("Not allowed");
} }
const documentId = args.documentId const documentId = args.documentId;
// check user permission // check user permission
const document = await this.prisma.document.findUnique({ const document = await this.prisma.document.findUnique({
where: { id: documentId }, where: { id: documentId },
include: { include: {
collaborators: true, collaborators: true,
}, },
}) });
if (!document) { if (!document) {
throw new Error('Document not found') throw new Error("Document not found");
} }
if (!document.isPublic) { if (!document.isPublic) {
if ( if (
document.ownerId !== ctx.websocket?.me?.id && 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([ return ctx.websocket.pubSub.asyncIterator([
@@ -613,87 +736,108 @@ export class DocumentSchema extends PothosSchema {
`${DocumentEvent.PAGE_DELETED}.${documentId}`, `${DocumentEvent.PAGE_DELETED}.${documentId}`,
`${DocumentEvent.ACTIVE_DOCUMENT_ID_CHANGED}.${documentId}`, `${DocumentEvent.ACTIVE_DOCUMENT_ID_CHANGED}.${documentId}`,
`${DocumentEvent.AI_SUGGESTION}.${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) => { resolve: async (payload: DocumentDelta, _args, ctx: SchemaContext) => {
if (!ctx.isSubscription) { 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 there's an explicit sync request, pass it through immediately
if (payload.requestSync) { if (payload.requestSync) {
return payload return payload;
} }
// Only perform background check with a very low probability // Only perform background check with a very low probability
const SYNC_PROBABILITY = 0.01 // 0.1% chance const SYNC_PROBABILITY = 0.01; // 0.1% chance
const SYNC_INTERVAL_MS = 15000 // 15 seconds minimum between syncs 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 { try {
// Quick, non-blocking check for potential background processing // Quick, non-blocking check for potential background processing
const lastSyncTimeStr = await this.redis.get(syncKey) const lastSyncTimeStr = await this.redis.get(syncKey);
const lastSyncTime = lastSyncTimeStr ? parseInt(lastSyncTimeStr, 10) : 0 const lastSyncTime = lastSyncTimeStr
const currentTime = DateTimeUtils.now().toMillis() ? parseInt(lastSyncTimeStr, 10)
: 0;
const currentTime = DateTimeUtils.now().toMillis();
// Only proceed if enough time has passed and we hit the random chance // 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 // 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 // biome-ignore lint/suspicious/noExplicitAny: error is any
} catch (error: any) { } catch (error: any) {
Logger.error('Background sync check failed', { Logger.error("Background sync check failed", {
documentId: payload.documentId, documentId: payload.documentId,
error: error.message, error: error.message,
}) });
} }
// Always return payload immediately // Always return payload immediately
return payload return payload;
}, },
}), }),
})) }));
} }
// Separate method for background processing // 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) { if (!ctx.isSubscription) {
throw new Error('Not allowed') throw new Error("Not allowed");
} }
try { try {
const syncKey = `document:sync:${documentId}:${pageIndex}` const syncKey = `document:sync:${documentId}:${pageIndex}`;
const currentTime = DateTimeUtils.now().toMillis().toString() const currentTime = DateTimeUtils.now().toMillis().toString();
// Update sync time immediately to prevent multiple concurrent checks // 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, documentId,
pageIndex, pageIndex,
}) });
// Perform grammar check // 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) { if (!delta) {
return return;
} }
// Optionally publish AI suggestion if needed // Optionally publish AI suggestion if needed
ctx.websocket.pubSub.publish(`${DocumentEvent.AI_SUGGESTION}.${documentId}`, { ctx.websocket.pubSub.publish(
`${DocumentEvent.AI_SUGGESTION}.${documentId}`,
{
documentId, documentId,
pageIndex, pageIndex,
eventType: DocumentEvent.AI_SUGGESTION, eventType: DocumentEvent.AI_SUGGESTION,
delta, delta,
senderId: 'system', senderId: "system",
}) }
);
// biome-ignore lint/suspicious/noExplicitAny: error is any // biome-ignore lint/suspicious/noExplicitAny: error is any
} catch (error: any) { } catch (error: any) {
Logger.error('Background grammar check failed', { Logger.error("Background grammar check failed", {
documentId, documentId,
pageIndex, pageIndex,
error: error.message, error: error.message,
}) });
} }
} }
} }