From 142985c6d70f4acbbf5c3a3f525c146c6b17b447 Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Mon, 24 Nov 2025 01:10:45 +0530 Subject: [PATCH] Move `Document` event writing to model layer (#9790) * documents.restore, documents.unarchive * documents.templatize * documents.archive * documents.unpublish * documents.create, documents.update * documents.title_change event * documents.move * documents.delete * tsc, tests * tsc * Copilot feedback --------- Co-authored-by: Tom Moor --- plugins/enterprise/client/translations.tsx | 76 +++++++--- .../slack/server/processors/SlackProcessor.ts | 3 +- server/commands/accountProvisioner.ts | 5 +- server/commands/documentCreator.test.ts | 56 ++----- server/commands/documentCreator.ts | 112 ++++++-------- server/commands/documentDuplicator.test.ts | 33 ++--- server/commands/documentDuplicator.ts | 26 +--- server/commands/documentMover.test.ts | 78 +++++----- server/commands/documentMover.ts | 65 +++----- server/commands/documentUpdater.test.ts | 3 - server/commands/documentUpdater.ts | 39 +---- server/env.ts | 4 +- server/models/Document.ts | 139 ++++++++++++------ server/models/Group.ts | 6 +- server/models/helpers/DocumentHelper.tsx | 4 +- .../processors/BacklinksProcessor.test.ts | 14 +- .../processors/NotificationsProcessor.ts | 6 +- .../processors/RevisionsProcessor.test.ts | 4 +- .../queues/processors/RevisionsProcessor.ts | 2 +- .../queues/processors/WebsocketsProcessor.ts | 15 +- .../tasks/DetachDraftsFromCollectionTask.ts | 12 +- server/queues/tasks/DocumentImportTask.ts | 8 +- ...DocumentPublishedNotificationsTask.test.ts | 12 -- server/queues/tasks/ImportTask.ts | 4 +- server/routes/api/documents/documents.test.ts | 53 ++++--- server/routes/api/documents/documents.ts | 138 ++++------------- server/routes/api/groups/schema.ts | 10 +- server/routes/app.ts | 3 +- server/types.ts | 18 +-- server/utils/oauth.test.ts | 16 +- server/validation.test.ts | 18 ++- 31 files changed, 428 insertions(+), 554 deletions(-) diff --git a/plugins/enterprise/client/translations.tsx b/plugins/enterprise/client/translations.tsx index 1eee7afc76..ebc58a20e1 100644 --- a/plugins/enterprise/client/translations.tsx +++ b/plugins/enterprise/client/translations.tsx @@ -1,4 +1,4 @@ -import { Trans } from 'react-i18next'; +import { Trans } from "react-i18next"; export const Translations = () => ( <> @@ -6,7 +6,9 @@ export const Translations = () => ( - + @@ -21,35 +23,59 @@ export const Translations = () => ( - + - - + + - + - Space Settings -> Manage space -> Export space and choose to export as HTML with the "Normal Export" option.`} /> - + Space Settings -> Manage space -> Export space and choose to export as HTML with the "Normal Export" option.`} + /> + - + - + - + - + - + - + @@ -62,15 +88,25 @@ export const Translations = () => ( - - Glean in realtime.`} /> + + Glean in realtime.`} + /> - priority@getoutline.com.`} /> - - + priority@getoutline.com.`} + /> + + -) \ No newline at end of file +); diff --git a/plugins/slack/server/processors/SlackProcessor.ts b/plugins/slack/server/processors/SlackProcessor.ts index 95606f097b..4ebc33ea2d 100644 --- a/plugins/slack/server/processors/SlackProcessor.ts +++ b/plugins/slack/server/processors/SlackProcessor.ts @@ -82,8 +82,7 @@ export default class SlackProcessor extends BaseProcessor { async documentUpdated(event: DocumentEvent | RevisionEvent) { // never send notifications when batch importing documents - // @ts-expect-error ts-migrate(2339) FIXME: Property 'data' does not exist on type 'DocumentEv... Remove this comment to see the full error message - if (event.data && event.data.source === "import") { + if (event.name === "documents.publish" && event.data?.source === "import") { return; } const [document, team] = await Promise.all([ diff --git a/server/commands/accountProvisioner.ts b/server/commands/accountProvisioner.ts index 4d037a3903..097a5ac077 100644 --- a/server/commands/accountProvisioner.ts +++ b/server/commands/accountProvisioner.ts @@ -21,6 +21,7 @@ import { sequelize } from "@server/storage/database"; import teamProvisioner from "./teamProvisioner"; import userProvisioner from "./userProvisioner"; import { APIContext } from "@server/types"; +import { createContext } from "@server/context"; import { addSeconds } from "date-fns"; type Props = { @@ -246,9 +247,9 @@ async function provisionFirstCollection(team: Team, user: User) { document.content = await DocumentHelper.toJSON(document); - await document.publish(user, collection.id, { + await document.publish(createContext({ user, transaction }), { + collectionId: collection.id, silent: true, - transaction, }); } }); diff --git a/server/commands/documentCreator.test.ts b/server/commands/documentCreator.test.ts index 89ab9be3a5..fde1681e3a 100644 --- a/server/commands/documentCreator.test.ts +++ b/server/commands/documentCreator.test.ts @@ -23,13 +23,11 @@ describe("documentCreator", () => { ).toJSON(); const document = await withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { title: "Test Document", text: testText, content: testContent, collectionId: collection.id, - user, - ctx, }) ); @@ -48,12 +46,10 @@ describe("documentCreator", () => { const testText = "This is plain text"; const document = await withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { title: "Test Document", text: testText, collectionId: collection.id, - user, - ctx, }) ); @@ -68,11 +64,9 @@ describe("documentCreator", () => { }); const document = await withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { title: "Empty Document", collectionId: collection.id, - user, - ctx, }) ); @@ -90,12 +84,10 @@ describe("documentCreator", () => { }); const document = await withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { title: "Test Document", text: "This is a test document", collectionId: collection.id, - user, - ctx, }) ); @@ -115,15 +107,13 @@ describe("documentCreator", () => { }); const document = await withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { title: "Custom Document", text: "Custom content", icon: "📄", color: "#FF0000", fullWidth: true, collectionId: collection.id, - user, - ctx, }) ); @@ -140,13 +130,11 @@ describe("documentCreator", () => { }); const document = await withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { title: "Draft Document", text: "Draft content", collectionId: collection.id, publish: false, - user, - ctx, }) ); @@ -161,13 +149,11 @@ describe("documentCreator", () => { }); const document = await withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { title: "Published Document", text: "Published content", collectionId: collection.id, publish: true, - user, - ctx, }) ); @@ -179,12 +165,10 @@ describe("documentCreator", () => { await expect( withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { title: "Invalid Document", text: "Content", publish: true, - user, - ctx, }) ) ).rejects.toThrow("Collection ID is required to publish"); @@ -211,12 +195,10 @@ describe("documentCreator", () => { }); const document = await withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { title: "From Template", templateDocument, collectionId: collection.id, - user, - ctx, }) ); @@ -242,11 +224,9 @@ describe("documentCreator", () => { }); const document = await withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { templateDocument, collectionId: collection.id, - user, - ctx, }) ); @@ -270,12 +250,10 @@ describe("documentCreator", () => { await expect( withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { templateDocument, state: Buffer.from("some state"), collectionId: collection.id, - user, - ctx, }) ) ).rejects.toThrow( @@ -299,12 +277,10 @@ describe("documentCreator", () => { }); const document = await withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { templateDocument, template: true, collectionId: collection.id, - user, - ctx, }) ); @@ -330,13 +306,11 @@ describe("documentCreator", () => { }); const document = await withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { title: "Child Document", text: "Child content", parentDocumentId: parentDocument.id, collectionId: collection.id, - user, - ctx, }) ); @@ -359,14 +333,12 @@ describe("documentCreator", () => { const sourceMetadata = { fileName: "test" }; const document = await withAPIContext(user, (ctx) => - documentCreator({ + documentCreator(ctx, { title: "fileOperation Document", text: "fileOperation content", importId: fileOperation.id, sourceMetadata, collectionId: collection.id, - user, - ctx, }) ); diff --git a/server/commands/documentCreator.ts b/server/commands/documentCreator.ts index 1c31e29b64..a9cdf06013 100644 --- a/server/commands/documentCreator.ts +++ b/server/commands/documentCreator.ts @@ -1,7 +1,7 @@ import { Optional } from "utility-types"; import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; import { TextHelper } from "@shared/utils/TextHelper"; -import { Document, Event, User } from "@server/models"; +import { Document } from "@server/models"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper"; import { APIContext } from "@server/types"; @@ -32,39 +32,41 @@ type Props = Optional< state?: Buffer; publish?: boolean; templateDocument?: Document | null; - user: User; - ctx: APIContext; }; -export default async function documentCreator({ - title, - text, - icon, - color, - state, - id, - urlId, - publish, - collectionId, - parentDocumentId, - content, - template, - templateDocument, - fullWidth, - importId, - apiImportId, - createdAt, - // allows override for import - updatedAt, - user, - editorVersion, - publishedAt, - sourceMetadata, - ctx, -}: Props): Promise { - const { transaction, ip } = ctx.context; +export default async function documentCreator( + ctx: APIContext, + { + title, + text, + icon, + color, + state, + id, + urlId, + publish, + collectionId, + parentDocumentId, + content, + template, + templateDocument, + fullWidth, + importId, + apiImportId, + createdAt, + // allows override for import + updatedAt, + editorVersion, + publishedAt, + sourceMetadata, + }: Props +): Promise { + const { user } = ctx.state.auth; + const { transaction } = ctx.state; const templateId = templateDocument ? templateDocument.id : undefined; + const eventData = importId || apiImportId ? { source: "import" } : undefined; + if (state && templateDocument) { throw new Error( "State cannot be set when creating a document from a template" @@ -134,28 +136,12 @@ export default async function documentCreator({ includeTitle: false, }); - await document.save({ - silent: !!createdAt, - transaction, - }); - - await Event.create( + await document.saveWithCtx( + ctx, { - name: "documents.create", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - source: importId || apiImportId ? "import" : undefined, - title: document.title, - templateId, - }, - ip, + silent: !!createdAt, }, - { - transaction, - } + { data: eventData } ); if (publish) { @@ -163,26 +149,12 @@ export default async function documentCreator({ throw new Error("Collection ID is required to publish"); } - await document.publish(user, collectionId, { silent: true, transaction }); - if (document.title) { - await Event.create( - { - name: "documents.publish", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - source: importId ? "import" : undefined, - title: document.title, - }, - ip, - }, - { - transaction, - } - ); - } + await document.publish(ctx, { + collectionId, + silent: true, + event: !!document.title, + data: eventData, + }); } // reload to get all of the data needed to present (user, collection etc) diff --git a/server/commands/documentDuplicator.test.ts b/server/commands/documentDuplicator.test.ts index ad721f70f7..3c5a636852 100644 --- a/server/commands/documentDuplicator.test.ts +++ b/server/commands/documentDuplicator.test.ts @@ -5,6 +5,7 @@ import { buildDocument, buildUser, } from "@server/test/factories"; +import { withAPIContext } from "@server/test/support"; import documentDuplicator from "./documentDuplicator"; describe("documentDuplicator", () => { @@ -15,12 +16,10 @@ describe("documentDuplicator", () => { teamId: user.teamId, }); - const response = await sequelize.transaction((transaction) => - documentDuplicator({ + const response = await withAPIContext(user, (ctx) => + documentDuplicator(ctx, { document: original, collection: original.collection, - user, - ctx: createContext({ user, transaction }), }) ); @@ -40,13 +39,11 @@ describe("documentDuplicator", () => { icon: "👋", }); - const response = await sequelize.transaction((transaction) => - documentDuplicator({ + const response = await withAPIContext(user, (ctx) => + documentDuplicator(ctx, { document: original, collection: original.collection, title: "New title", - user, - ctx: createContext({ user, transaction }), }) ); @@ -99,14 +96,12 @@ describe("documentDuplicator", () => { await collection.addDocumentToStructure(child2); await collection.addDocumentToStructure(child3); - await sequelize.transaction((transaction) => - documentDuplicator({ + await withAPIContext(user, (ctx) => + documentDuplicator(ctx, { title: "duplicate", document: original, collection: original.collection, - user, recursive: true, - ctx: createContext({ user, transaction }), }) ); @@ -128,13 +123,11 @@ describe("documentDuplicator", () => { teamId: user.teamId, }); - const response = await sequelize.transaction((transaction) => - documentDuplicator({ + const response = await withAPIContext(user, (ctx) => + documentDuplicator(ctx, { document: original, collection: original.collection, publish: false, - user, - ctx: createContext({ user, transaction }), }) ); @@ -155,11 +148,9 @@ describe("documentDuplicator", () => { }); const response = await sequelize.transaction((transaction) => - documentDuplicator({ + documentDuplicator(createContext({ user, transaction }), { document: original, collection: original.collection, - user, - ctx: createContext({ user, transaction }), }) ); @@ -187,12 +178,10 @@ describe("documentDuplicator", () => { }); const response = await sequelize.transaction((transaction) => - documentDuplicator({ + documentDuplicator(createContext({ user, transaction }), { document: original, collection: original.collection, - user, recursive: true, - ctx: createContext({ user, transaction }), }) ); diff --git a/server/commands/documentDuplicator.ts b/server/commands/documentDuplicator.ts index 9d6ef572ec..a815ea0fc0 100644 --- a/server/commands/documentDuplicator.ts +++ b/server/commands/documentDuplicator.ts @@ -1,13 +1,11 @@ import { Op } from "sequelize"; -import { User, Collection, Document } from "@server/models"; +import { Collection, Document } from "@server/models"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper"; import { APIContext } from "@server/types"; import documentCreator from "./documentCreator"; type Props = { - /** The user who is creating the document */ - user: User; /** The document to duplicate */ document: Document; /** The collection to add the duplicated document to */ @@ -20,29 +18,19 @@ type Props = { publish?: boolean; /** Whether to duplicate child documents */ recursive?: boolean; - /** The request context */ - ctx: APIContext; }; -export default async function documentDuplicator({ - user, - document, - collection, - parentDocumentId, - title, - publish, - recursive, - ctx, -}: Props): Promise { +export default async function documentDuplicator( + ctx: APIContext, + { document, collection, parentDocumentId, title, publish, recursive }: Props +): Promise { const newDocuments: Document[] = []; const sharedProperties = { - user, collectionId: collection?.id, publish: publish ?? !!document.publishedAt, - ctx, }; - const duplicated = await documentCreator({ + const duplicated = await documentCreator(ctx, { parentDocumentId, icon: document.icon, color: document.color, @@ -93,7 +81,7 @@ export default async function documentDuplicator({ ).reverse(); // we have to reverse since the child documents will be added in reverse order for (const childDocument of sorted) { - const duplicatedChildDocument = await documentCreator({ + const duplicatedChildDocument = await documentCreator(ctx, { parentDocumentId: duplicatedDocument.id, icon: childDocument.icon, color: childDocument.color, diff --git a/server/commands/documentMover.test.ts b/server/commands/documentMover.test.ts index 27ed38b74b..6530fbba68 100644 --- a/server/commands/documentMover.test.ts +++ b/server/commands/documentMover.test.ts @@ -1,5 +1,4 @@ import Pin from "@server/models/Pin"; -import { sequelize } from "@server/storage/database"; import { buildDocument, buildCollection, @@ -7,10 +6,9 @@ import { buildUser, } from "@server/test/factories"; import documentMover from "./documentMover"; +import { withAPIContext } from "@server/test/support"; describe("documentMover", () => { - const ip = "127.0.0.1"; - it("should move within a collection", async () => { const team = await buildTeam(); const user = await buildUser({ teamId: team.id }); @@ -23,12 +21,12 @@ describe("documentMover", () => { collectionId: collection.id, teamId: team.id, }); - const response = await documentMover({ - user, - document, - collectionId: collection.id, - ip, - }); + const response = await withAPIContext(user, (ctx) => + documentMover(ctx, { + document, + collectionId: collection.id, + }) + ); expect(response.collections.length).toEqual(1); expect(response.documents.length).toEqual(1); }); @@ -53,14 +51,14 @@ describe("documentMover", () => { title: "Child document", text: "content", }); - const response = await documentMover({ - user, - document, - collectionId: collection.id, - parentDocumentId: undefined, - index: 0, - ip, - }); + const response = await withAPIContext(user, (ctx) => + documentMover(ctx, { + document, + collectionId: collection.id, + parentDocumentId: undefined, + index: 0, + }) + ); expect(response.collections[0].documentStructure![0].children[0].id).toBe( newDocument.id ); @@ -91,14 +89,14 @@ describe("documentMover", () => { text: "content", }); await collection.addDocumentToStructure(newDocument); - const response = await documentMover({ - user, - document, - collectionId: collection.id, - parentDocumentId: undefined, - index: 0, - ip, - }); + const response = await withAPIContext(user, (ctx) => + documentMover(ctx, { + document, + collectionId: collection.id, + parentDocumentId: undefined, + index: 0, + }) + ); expect(response.collections[0].documentStructure![0].children[0].id).toBe( newDocument.id ); @@ -132,14 +130,14 @@ describe("documentMover", () => { text: "content", }); await collection.addDocumentToStructure(newDocument); - const response = await documentMover({ - user, - document, - collectionId: newCollection.id, - parentDocumentId: undefined, - index: 0, - ip, - }); + const response = await withAPIContext(user, (ctx) => + documentMover(ctx, { + document, + collectionId: newCollection.id, + parentDocumentId: undefined, + index: 0, + }) + ); // check document ids where updated await newDocument.reload(); expect(newDocument.collectionId).toBe(newCollection.id); @@ -181,15 +179,12 @@ describe("documentMover", () => { teamId: collection.teamId, }); - const response = await sequelize.transaction(async (transaction) => - documentMover({ - user, + const response = await withAPIContext(user, (ctx) => + documentMover(ctx, { document, collectionId: newCollection.id, parentDocumentId: undefined, index: 0, - ip, - transaction, }) ); @@ -223,14 +218,11 @@ describe("documentMover", () => { teamId: team.id, }); - const response = await sequelize.transaction(async (transaction) => - documentMover({ - user, + const response = await withAPIContext(user, (ctx) => + documentMover(ctx, { document, collectionId: null, index: 0, - ip, - transaction, }) ); diff --git a/server/commands/documentMover.ts b/server/commands/documentMover.ts index 82fc851408..6ca38090a0 100644 --- a/server/commands/documentMover.ts +++ b/server/commands/documentMover.ts @@ -1,11 +1,9 @@ import { Transaction } from "sequelize"; -import { createContext } from "@server/context"; import { traceFunction } from "@server/logging/tracing"; -import { User, Document, Collection, Pin, Event } from "@server/models"; +import { Document, Collection, Pin } from "@server/models"; +import { APIContext } from "@server/types"; type Props = { - /** User attempting to move the document */ - user: User; /** Document which is being moved */ document: Document; /** Destination collection to which the document is moved */ @@ -14,10 +12,6 @@ type Props = { parentDocumentId?: string | null; /** Position of moved document within document structure */ index?: number; - /** The IP address of the user moving the document */ - ip: string | null; - /** The database transaction to run within */ - transaction?: Transaction; }; type Result = { @@ -26,16 +20,19 @@ type Result = { collectionChanged: boolean; }; -async function documentMover({ - user, - document, - collectionId = null, - parentDocumentId = null, - // convert undefined to null so parentId comparison treats them as equal - index, - ip, - transaction, -}: Props): Promise { +async function documentMover( + ctx: APIContext, + { + document, + collectionId = null, + parentDocumentId = null, + // convert undefined to null so parentId comparison treats them as equal + index, + }: Props +): Promise { + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + const collectionChanged = collectionId !== document.collectionId; const previousCollectionId = document.collectionId; const result: Result = { @@ -206,37 +203,19 @@ async function documentMover({ lock: Transaction.LOCK.UPDATE, }); - await pin?.destroyWithCtx( - createContext({ - user, - ip, - transaction, - }) - ); + await pin?.destroyWithCtx(ctx); } } - await document.save({ transaction }); result.documents.push(document); - await Event.create( - { - name: "documents.move", - actorId: user.id, - documentId: document.id, - collectionId, - teamId: document.teamId, - data: { - title: document.title, - collectionIds: result.collections.map((c) => c.id), - documentIds: result.documents.map((d) => d.id), - }, - ip, + await document.saveWithCtx(ctx, undefined, { + name: "move", + data: { + collectionIds: result.collections.map((c) => c.id), + documentIds: result.documents.map((d) => d.id), }, - { - transaction, - } - ); + }); // we need to send all updated models back to the client return result; diff --git a/server/commands/documentUpdater.test.ts b/server/commands/documentUpdater.test.ts index 27b950a901..65ee02ffbc 100644 --- a/server/commands/documentUpdater.test.ts +++ b/server/commands/documentUpdater.test.ts @@ -14,7 +14,6 @@ describe("documentUpdater", () => { documentUpdater(ctx, { text: "Changed", document, - user, }) ); @@ -36,7 +35,6 @@ describe("documentUpdater", () => { documentUpdater(ctx, { title: document.title, document, - user, }) ); @@ -53,7 +51,6 @@ describe("documentUpdater", () => { documentUpdater(ctx, { text: "Changed", document, - user, }) ); diff --git a/server/commands/documentUpdater.ts b/server/commands/documentUpdater.ts index cc97c383ad..8ab5dbe240 100644 --- a/server/commands/documentUpdater.ts +++ b/server/commands/documentUpdater.ts @@ -1,11 +1,9 @@ -import { Event, Document, User } from "@server/models"; +import { Event, Document } from "@server/models"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import { TextHelper } from "@server/models/helpers/TextHelper"; import { APIContext } from "@server/types"; type Props = { - /** The user updating the document */ - user: User; /** The existing document */ document: Document; /** The new title */ @@ -44,7 +42,6 @@ type Props = { export default async function documentUpdater( ctx: APIContext, { - user, document, title, icon, @@ -60,8 +57,8 @@ export default async function documentUpdater( done, }: Props ): Promise { + const { user } = ctx.state.auth; const { transaction } = ctx.state; - const previousTitle = document.title; const cId = collectionId || document.collectionId; if (title !== undefined) { @@ -96,33 +93,24 @@ export default async function documentUpdater( } const changed = document.changed(); + const eventData = done !== undefined ? { done } : undefined; const event = { name: "documents.update", documentId: document.id, collectionId: cId, - data: { - done, - title: document.title, - }, + data: eventData, }; if (publish && (document.template || cId)) { if (!document.collectionId) { document.collectionId = cId; } - await document.publish(user, cId, { transaction }); - - await Event.createFromContext(ctx, { - ...event, - name: "documents.publish", - }); + await document.publish(ctx, { collectionId: cId, data: eventData }); } else if (changed) { document.lastModifiedById = user.id; document.updatedBy = user; - await document.save({ transaction }); - - await Event.createFromContext(ctx, event); + await document.saveWithCtx(ctx, undefined, { data: eventData }); } else if (done) { await Event.schedule({ ...event, @@ -131,21 +119,6 @@ export default async function documentUpdater( }); } - if (document.title !== previousTitle) { - await Event.schedule({ - name: "documents.title_change", - documentId: document.id, - collectionId: cId, - teamId: document.teamId, - actorId: user.id, - data: { - previousTitle, - title: document.title, - }, - ip: ctx.request.ip, - }); - } - return await Document.findByPk(document.id, { userId: user.id, rejectOnEmpty: true, diff --git a/server/env.ts b/server/env.ts index ea8e2aa1e2..dbee4fb19f 100644 --- a/server/env.ts +++ b/server/env.ts @@ -424,7 +424,9 @@ export class Environment { * Setting secure to false therefore does not mean that you would not use an * encrypted connection. */ - public SMTP_DISABLE_STARTTLS = this.toBoolean(environment.SMTP_DISABLE_STARTTLS ?? "false"); + public SMTP_DISABLE_STARTTLS = this.toBoolean( + environment.SMTP_DISABLE_STARTTLS ?? "false" + ); /** * Dropbox app key for embedding Dropbox files diff --git a/server/models/Document.ts b/server/models/Document.ts index 1f42361dc8..d16d50ad2f 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -39,7 +39,9 @@ import { AllowNull, BelongsToMany, Unique, + AfterUpdate, } from "sequelize-typescript"; +import { MaxLength } from "class-validator"; import isUUID from "validator/lib/isUUID"; import type { NavigationNode, @@ -52,6 +54,7 @@ import slugify from "@shared/utils/slugify"; import { DocumentValidation } from "@shared/validations"; import { ValidationError } from "@server/errors"; import { generateUrlId } from "@server/utils/url"; +import { createContext } from "@server/context"; import Collection from "./Collection"; import FileOperation from "./FileOperation"; import Group from "./Group"; @@ -70,7 +73,9 @@ import Fix from "./decorators/Fix"; import { DocumentHelper } from "./helpers/DocumentHelper"; import IsHexColor from "./validators/IsHexColor"; import Length from "./validators/Length"; -import { MaxLength } from "class-validator"; +import { APIContext } from "@server/types"; +import { SkipChangeset } from "./decorators/Changeset"; +import { HookContext } from "./base/Model"; export const DOCUMENT_VERSION = 2; @@ -338,6 +343,7 @@ class Document extends ArchivableModel< * This column will be removed in a future migration. */ @Column(DataType.TEXT) + @SkipChangeset text: string; /** The likely language of the content, in ISO 639-1 format. */ @@ -349,6 +355,7 @@ class Document extends ArchivableModel< * The content of the document as JSON, this is a snapshot at the last time the state was saved. */ @Column(DataType.JSONB) + @SkipChangeset content: ProsemirrorData | null; /** @@ -360,6 +367,7 @@ class Document extends ArchivableModel< msg: `Document collaborative state is too large, you must create a new document`, }) @Column(DataType.BLOB) + @SkipChangeset state?: Uint8Array | null; /** Whether this document is part of onboarding. */ @@ -564,6 +572,20 @@ class Document extends ArchivableModel< } } + @AfterUpdate + static async publishTitleChangeEvent( + model: Document, + ctx: APIContext["context"] + ) { + if (model.changed("title")) { + const hookContext = { + ...ctx, + event: { publish: true, persist: false }, + } as HookContext; + await this.insertEvent("title_change", model, hookContext); + } + } + // associations @BelongsTo(() => FileOperation, "importId") @@ -965,16 +987,30 @@ class Document extends ArchivableModel< }; publish = async ( - user: User, - collectionId: string | null | undefined, - options: SaveOptions + ctx: APIContext, + { + collectionId, + silent = false, + event = true, + data, + }: { + collectionId: string | null | undefined; + silent?: boolean; + event?: boolean; + data?: Record; + } ): Promise => { - const { transaction } = options; + const { user } = ctx.state.auth; + const { transaction } = ctx.state; // If the document is already published then calling publish should act like // a regular save if (this.publishedAt) { - return this.save(options); + if (event) { + return this.saveWithCtx(ctx, { silent }, { name: "publish", data }); + } else { + return this.save({ silent, transaction }); + } } if (!this.collectionId) { @@ -1017,7 +1053,12 @@ class Document extends ArchivableModel< this.lastModifiedById = user.id; this.updatedBy = user; this.publishedAt = new Date(); - return this.save(options); + + if (event) { + return this.saveWithCtx(ctx, { silent }, { name: "publish", data }); + } else { + return this.save({ silent, transaction }); + } }; isCollectionDeleted = async () => { @@ -1042,28 +1083,29 @@ class Document extends ArchivableModel< * @param options.detach Whether to detach the document from the containing collection * @returns Updated document */ - unpublish = async (user: User, options: { detach: boolean }) => { + unpublishWithCtx = async (ctx: APIContext, options: { detach: boolean }) => { // If the document is already a draft then calling unpublish should act like save if (!this.publishedAt) { return this.save(); } - await this.sequelize.transaction(async (transaction: Transaction) => { - const collection = this.collectionId - ? await Collection.findByPk(this.collectionId, { - includeDocumentStructure: true, - transaction, - lock: transaction.LOCK.UPDATE, - }) - : undefined; + const { user } = ctx.state.auth; + const { transaction } = ctx.state; - if (collection) { - await collection.removeDocumentInStructure(this, { transaction }); - if (this.collection) { - this.collection.documentStructure = collection.documentStructure; - } + const collection = this.collectionId + ? await Collection.findByPk(this.collectionId, { + includeDocumentStructure: true, + transaction, + lock: transaction.LOCK.UPDATE, + }) + : undefined; + + if (collection) { + await collection.removeDocumentInStructure(this, { transaction }); + if (this.collection) { + this.collection.documentStructure = collection.documentStructure; } - }); + } // unpublishing a document converts the ownership to yourself, so that it // will appear in your drafts rather than the original creators @@ -1077,13 +1119,13 @@ class Document extends ArchivableModel< this.collectionId = null; } - return this.save(); + return this.saveWithCtx(ctx, undefined, { name: "unpublish" }); }; // Moves a document from being visible to the team within a collection // to the archived area, where it can be subsequently restored. - archive = async (user: User, options?: FindOptions) => { - const { transaction } = { ...options }; + archiveWithCtx = async (ctx: APIContext) => { + const { transaction } = ctx.state; const collection = this.collectionId ? await Collection.findByPk(this.collectionId, { includeDocumentStructure: true, @@ -1099,16 +1141,16 @@ class Document extends ArchivableModel< } } - await this.archiveWithChildren(user, { transaction }); + await this.archiveWithChildren(ctx); return this; }; // Restore an archived document back to being visible to the team restoreTo = async ( - collectionId: string, - options: FindOptions & { user: User } + ctx: APIContext, + { collectionId }: { collectionId: string } ) => { - const { transaction } = { ...options }; + const { transaction } = ctx.state; const collection = collectionId ? await Collection.findByPk(collectionId, { includeDocumentStructure: true, @@ -1141,13 +1183,11 @@ class Document extends ArchivableModel< if (this.deletedAt) { await this.restore({ transaction }); this.collectionId = collectionId; - await this.save({ transaction }); + await this.saveWithCtx(ctx, undefined, { name: "restore" }); } if (this.archivedAt) { - await this.restoreWithChildren(collectionId, options.user, { - transaction, - }); + await this.restoreArchivedWithChildren(ctx, { collectionId }); } if (this.collection && collection) { @@ -1185,7 +1225,9 @@ class Document extends ArchivableModel< this.lastModifiedById = user.id; this.updatedBy = user; - return this.save({ transaction }); + return this.saveWithCtx(createContext({ user, transaction }), undefined, { + name: "delete", + }); }); getTimestamp = () => Math.round(new Date(this.updatedAt).getTime() / 1000); @@ -1255,11 +1297,13 @@ class Document extends ArchivableModel< }; }; - private restoreWithChildren = async ( - collectionId: string, - user: User, - options?: FindOptions + private restoreArchivedWithChildren = async ( + ctx: APIContext, + { collectionId }: { collectionId: string } ) => { + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + const restoreChildren = async (parentDocumentId: string) => { const childDocuments = await ( this.constructor as typeof Document @@ -1267,7 +1311,7 @@ class Document extends ArchivableModel< where: { parentDocumentId, }, - ...options, + transaction, }); for (const child of childDocuments) { await restoreChildren(child.id); @@ -1275,7 +1319,7 @@ class Document extends ArchivableModel< child.lastModifiedById = user.id; child.updatedBy = user; child.collectionId = collectionId; - await child.save(options); + await child.save({ transaction }); } }; @@ -1284,13 +1328,12 @@ class Document extends ArchivableModel< this.lastModifiedById = user.id; this.updatedBy = user; this.collectionId = collectionId; - return this.save(options); + return this.saveWithCtx(ctx, undefined, { name: "unarchive" }); }; - private archiveWithChildren = async ( - user: User, - options?: FindOptions - ) => { + private archiveWithChildren = async (ctx: APIContext) => { + const { user } = ctx.state.auth; + const { transaction } = ctx.state; const archivedAt = new Date(); // Helper to archive all child documents for a document @@ -1301,14 +1344,14 @@ class Document extends ArchivableModel< where: { parentDocumentId, }, - ...options, + transaction, }); for (const child of childDocuments) { await archiveChildren(child.id); child.archivedAt = archivedAt; child.lastModifiedById = user.id; child.updatedBy = user; - await child.save(options); + await child.save({ transaction }); } }; @@ -1316,7 +1359,7 @@ class Document extends ArchivableModel< this.archivedAt = archivedAt; this.lastModifiedById = user.id; this.updatedBy = user; - return this.save(options); + return this.saveWithCtx(ctx, undefined, { name: "archive" }); }; } diff --git a/server/models/Group.ts b/server/models/Group.ts index fdba598396..48d77038ac 100644 --- a/server/models/Group.ts +++ b/server/models/Group.ts @@ -66,7 +66,11 @@ class Group extends ParanoidModel< @Column name: string; - @Length({ min: 0, max: GroupValidation.maxDescriptionLength, msg: `description must be ${GroupValidation.maxDescriptionLength} characters or less` }) + @Length({ + min: 0, + max: GroupValidation.maxDescriptionLength, + msg: `description must be ${GroupValidation.maxDescriptionLength} characters or less`, + }) @Column(DataType.TEXT) description: string; diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx index 656d5b19b7..02bf2f6d30 100644 --- a/server/models/helpers/DocumentHelper.tsx +++ b/server/models/helpers/DocumentHelper.tsx @@ -537,7 +537,9 @@ export class DocumentHelper { documents: Document[], documentStructure: NavigationNode[] ): Document[] { - if (!documentStructure.length) {return documents;} + if (!documentStructure.length) { + return documents; + } const orderMap = new Map(); documentStructure.forEach((node, index) => { diff --git a/server/queues/processors/BacklinksProcessor.test.ts b/server/queues/processors/BacklinksProcessor.test.ts index b842f4b8bf..332d261eb6 100644 --- a/server/queues/processors/BacklinksProcessor.test.ts +++ b/server/queues/processors/BacklinksProcessor.test.ts @@ -21,7 +21,6 @@ describe("documents.publish", () => { collectionId: document.collectionId!, teamId: document.teamId, actorId: document.createdById, - data: { title: document.title }, ip, }); const backlinks = await Relationship.findAll({ @@ -52,7 +51,6 @@ describe("documents.publish", () => { collectionId: document.collectionId!, teamId: document.teamId, actorId: document.createdById, - data: { title: document.title }, ip, }); const backlinks = await Relationship.findAll({ @@ -80,7 +78,7 @@ describe("documents.update", () => { teamId: document.teamId, actorId: document.createdById, createdAt: new Date().toISOString(), - data: { title: document.title, autosave: false, done: true }, + data: { done: true }, ip, }); const backlinks = await Relationship.findAll({ @@ -111,7 +109,7 @@ describe("documents.update", () => { teamId: document.teamId, actorId: document.createdById, createdAt: new Date().toISOString(), - data: { title: document.title, autosave: false, done: true }, + data: { done: true }, ip, }); const backlinks = await Relationship.findAll({ @@ -139,7 +137,7 @@ describe("documents.update", () => { teamId: document.teamId, actorId: document.createdById, createdAt: new Date().toISOString(), - data: { title: document.title, autosave: false, done: true }, + data: { done: true }, ip, }); const backlinks = await Relationship.findAll({ @@ -167,7 +165,6 @@ describe("documents.update", () => { collectionId: document.collectionId!, teamId: document.teamId, actorId: document.createdById, - data: { title: document.title }, ip, }); document.content = parser @@ -186,7 +183,7 @@ describe("documents.update", () => { teamId: document.teamId, actorId: document.createdById, createdAt: new Date().toISOString(), - data: { title: document.title, autosave: false, done: true }, + data: { done: true }, ip, }); const backlinks = await Relationship.findAll({ @@ -217,7 +214,7 @@ describe("documents.delete", () => { teamId: document.teamId, actorId: document.createdById, createdAt: new Date().toISOString(), - data: { title: document.title, autosave: false, done: true }, + data: { done: true }, ip, }); @@ -227,7 +224,6 @@ describe("documents.delete", () => { collectionId: document.collectionId!, teamId: document.teamId, actorId: document.createdById, - data: { title: document.title }, ip, }); const backlinks = await Relationship.findAll({ diff --git a/server/queues/processors/NotificationsProcessor.ts b/server/queues/processors/NotificationsProcessor.ts index a2acddae3f..87a3f0d1a6 100644 --- a/server/queues/processors/NotificationsProcessor.ts +++ b/server/queues/processors/NotificationsProcessor.ts @@ -63,11 +63,7 @@ export default class NotificationsProcessor extends BaseProcessor { async documentPublished(event: DocumentEvent) { // never send notifications when batch importing - if ( - "data" in event && - "source" in event.data && - event.data.source === "import" - ) { + if (event.name === "documents.publish" && event.data?.source === "import") { return; } diff --git a/server/queues/processors/RevisionsProcessor.test.ts b/server/queues/processors/RevisionsProcessor.test.ts index e4188590a2..76dca6dcb0 100644 --- a/server/queues/processors/RevisionsProcessor.test.ts +++ b/server/queues/processors/RevisionsProcessor.test.ts @@ -17,7 +17,7 @@ describe("documents.update.debounced", () => { teamId: document.teamId, actorId: document.createdById, createdAt: new Date().toISOString(), - data: { title: document.title, autosave: false, done: true }, + data: { done: true }, ip, }); const amount = await Revision.count({ @@ -44,7 +44,7 @@ describe("documents.update.debounced", () => { teamId: document.teamId, actorId: document.createdById, createdAt: new Date().toISOString(), - data: { title: document.title, autosave: false, done: true }, + data: { done: true }, ip, }); const amount = await Revision.count({ diff --git a/server/queues/processors/RevisionsProcessor.ts b/server/queues/processors/RevisionsProcessor.ts index 6fb50f79eb..acd3753bfc 100644 --- a/server/queues/processors/RevisionsProcessor.ts +++ b/server/queues/processors/RevisionsProcessor.ts @@ -17,7 +17,7 @@ export default class RevisionsProcessor extends BaseProcessor { case "documents.publish": case "documents.update.debounced": case "documents.update": { - if (event.name === "documents.update" && !event.data.done) { + if (event.name === "documents.update" && !event.data?.done) { return; } diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index dda4ee0cb0..f643797540 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -53,7 +53,7 @@ export default class WebsocketsProcessor { } if ( event.name === "documents.create" && - event.data.source === "import" + event.data?.source === "import" ) { return; } @@ -94,18 +94,25 @@ export default class WebsocketsProcessor { const channels = await this.getDocumentEventChannels(event, document); // We need to add the collection channel to let the members update the doc structure. - channels.push(`collection-${event.collectionId}`); + // In case draft is detached from a collection, fallback to previous attribute to get the right one. + const collectionId = + event.collectionId ?? event.changes?.previous.collectionId; + + channels.push(`collection-${collectionId}`); return socketio.to(channels).emit(event.name, { document: documentToPresent, - collectionId: event.collectionId, + collectionId, }); } case "documents.unarchive": { + const srcCollectionId = + event.changes?.previous.collectionId ?? event.collectionId; + const [document, srcCollection] = await Promise.all([ Document.findByPk(event.documentId, { paranoid: false }), - Collection.findByPk(event.data.sourceCollectionId, { + Collection.findByPk(srcCollectionId, { paranoid: false, }), ]); diff --git a/server/queues/tasks/DetachDraftsFromCollectionTask.ts b/server/queues/tasks/DetachDraftsFromCollectionTask.ts index b6b720ebfb..c23a5763ef 100644 --- a/server/queues/tasks/DetachDraftsFromCollectionTask.ts +++ b/server/queues/tasks/DetachDraftsFromCollectionTask.ts @@ -3,6 +3,7 @@ import documentMover from "@server/commands/documentMover"; import { Collection, Document, User } from "@server/models"; import { sequelize } from "@server/storage/database"; import BaseTask from "./BaseTask"; +import { createContext } from "@server/context"; type Props = { collectionId: string; @@ -39,13 +40,16 @@ export default class DetachDraftsFromCollectionTask extends BaseTask { }); return sequelize.transaction(async (transaction) => { + const ctx = createContext({ + user: actor, + ip: props.ip, + transaction, + }); + for (const document of documents) { - await documentMover({ + await documentMover(ctx, { document, - user: actor, - ip: props.ip, collectionId: null, - transaction, }); } }); diff --git a/server/queues/tasks/DocumentImportTask.ts b/server/queues/tasks/DocumentImportTask.ts index ec84028b60..a0339190fc 100644 --- a/server/queues/tasks/DocumentImportTask.ts +++ b/server/queues/tasks/DocumentImportTask.ts @@ -44,15 +44,17 @@ export default class DocumentImportTask extends BaseTask { transaction, }); + const ctx = createContext({ user, transaction, ip }); + const { text, state, title, icon } = await documentImporter({ user, fileName: sourceMetadata.fileName, mimeType: sourceMetadata.mimeType, content, - ctx: createContext({ user, transaction, ip }), + ctx, }); - return documentCreator({ + return documentCreator(ctx, { sourceMetadata, title, icon, @@ -61,8 +63,6 @@ export default class DocumentImportTask extends BaseTask { publish, collectionId, parentDocumentId, - user, - ctx: createContext({ user, transaction, ip }), }); }); return { documentId: document.id }; diff --git a/server/queues/tasks/DocumentPublishedNotificationsTask.test.ts b/server/queues/tasks/DocumentPublishedNotificationsTask.test.ts index 17a513ee60..406aa7f313 100644 --- a/server/queues/tasks/DocumentPublishedNotificationsTask.test.ts +++ b/server/queues/tasks/DocumentPublishedNotificationsTask.test.ts @@ -31,9 +31,6 @@ describe("documents.publish", () => { collectionId: document.collectionId!, teamId: document.teamId, actorId: document.createdById, - data: { - title: document.title, - }, ip, }); expect(spy).not.toHaveBeenCalled(); @@ -55,9 +52,6 @@ describe("documents.publish", () => { collectionId: document.collectionId!, teamId: document.teamId, actorId: document.createdById, - data: { - title: document.title, - }, ip, }); expect(spy).toHaveBeenCalled(); @@ -95,9 +89,6 @@ describe("documents.publish", () => { collectionId: document.collectionId!, teamId: document.teamId, actorId: document.createdById, - data: { - title: document.title, - }, ip, }); expect(spy).toHaveBeenCalledTimes(1); @@ -124,9 +115,6 @@ describe("documents.publish", () => { collectionId: document.collectionId!, teamId: document.teamId, actorId: document.createdById, - data: { - title: document.title, - }, ip, }); expect(spy).not.toHaveBeenCalled(); diff --git a/server/queues/tasks/ImportTask.ts b/server/queues/tasks/ImportTask.ts index 96351f4c95..9d3ecbcd2d 100644 --- a/server/queues/tasks/ImportTask.ts +++ b/server/queues/tasks/ImportTask.ts @@ -435,7 +435,7 @@ export default abstract class ImportTask extends BaseTask { ); } - const document = await documentCreator({ + const document = await documentCreator(ctx, { sourceMetadata: { fileName: path.basename(item.path), mimeType: item.mimeType, @@ -455,8 +455,6 @@ export default abstract class ImportTask extends BaseTask { publishedAt: item.updatedAt ?? item.createdAt ?? new Date(), parentDocumentId: item.parentDocumentId, importId: fileOperation.id, - user, - ctx: createContext({ user, transaction }), }); documents.set(item.id, document); diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index 1dcd71967f..0c0f8406ff 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -33,7 +33,7 @@ import { buildGroup, buildAdmin, } from "@server/test/factories"; -import { getTestServer } from "@server/test/support"; +import { getTestServer, withAPIContext } from "@server/test/support"; const server = getTestServer(); @@ -91,7 +91,7 @@ describe("#documents.info", () => { userId: user.id, teamId: user.teamId, }); - await document.archive(user); + await withAPIContext(user, (ctx) => document.archiveWithCtx(ctx)); const res = await server.post("/api/documents.info", { body: { token: user.getJwtToken(), @@ -379,7 +379,7 @@ describe("#documents.info", () => { teamId: document.teamId, userId: user.id, }); - await document.archive(user); + await withAPIContext(user, (ctx) => document.archiveWithCtx(ctx)); const res = await server.post("/api/documents.info", { body: { shareId: share.id, @@ -553,7 +553,7 @@ describe("#documents.export", () => { userId: user.id, teamId: user.teamId, }); - await document.archive(user); + await withAPIContext(user, (ctx) => document.archiveWithCtx(ctx)); const res = await server.post("/api/documents.export", { body: { token: user.getJwtToken(), @@ -774,7 +774,7 @@ describe("#documents.list", () => { collectionId: collection.id, }), ]); - await docs[0].archive(user); + await withAPIContext(user, (ctx) => docs[0].archiveWithCtx(ctx)); const res = await server.post("/api/documents.list", { body: { token: user.getJwtToken(), @@ -815,7 +815,10 @@ describe("#documents.list", () => { collectionId: collections[1].id, }), ]); - await Promise.all([docs[0].archive(user), docs[1].archive(user)]); + await Promise.all([ + withAPIContext(user, (ctx) => docs[0].archiveWithCtx(ctx)), + withAPIContext(user, (ctx) => docs[1].archiveWithCtx(ctx)), + ]); const res = await server.post("/api/documents.list", { body: { token: user.getJwtToken(), @@ -1664,7 +1667,7 @@ describe("#documents.search", () => { text: "search term", teamId: user.teamId, }); - await document.archive(user); + await withAPIContext(user, (ctx) => document.archiveWithCtx(ctx)); const res = await server.post("/api/documents.search", { body: { token: user.getJwtToken(), @@ -1684,7 +1687,7 @@ describe("#documents.search", () => { text: "search term", teamId: user.teamId, }); - await document.archive(user); + await withAPIContext(user, (ctx) => document.archiveWithCtx(ctx)); const res = await server.post("/api/documents.search", { body: { token: user.getJwtToken(), @@ -2091,7 +2094,7 @@ describe("#documents.archived", () => { userId: user.id, teamId: user.teamId, }); - await document.archive(user); + await withAPIContext(user, (ctx) => document.archiveWithCtx(ctx)); const res = await server.post("/api/documents.archived", { body: { token: user.getJwtToken(), @@ -2127,8 +2130,12 @@ describe("#documents.archived", () => { ]); await Promise.all([ - documentInFirstCollection.archive(user), - documentInSecondCollection.archive(user), + withAPIContext(user, (ctx) => + documentInFirstCollection.archiveWithCtx(ctx) + ), + withAPIContext(user, (ctx) => + documentInSecondCollection.archiveWithCtx(ctx) + ), ]); const res = await server.post("/api/documents.archived", { @@ -2166,8 +2173,12 @@ describe("#documents.archived", () => { ]); await Promise.all([ - documentInFirstCollection.archive(user), - documentInSecondCollection.archive(user), + withAPIContext(user, (ctx) => + documentInFirstCollection.archiveWithCtx(ctx) + ), + withAPIContext(user, (ctx) => + documentInSecondCollection.archiveWithCtx(ctx) + ), ]); const res = await server.post("/api/documents.archived", { @@ -2186,7 +2197,7 @@ describe("#documents.archived", () => { userId: user.id, teamId: user.teamId, }); - await document.archive(user); + await withAPIContext(user, (ctx) => document.archiveWithCtx(ctx)); const res = await server.post("/api/documents.archived", { body: { token: user.getJwtToken(), @@ -2223,7 +2234,7 @@ describe("#documents.archived", () => { teamId: user.teamId, collectionId: collection.id, }); - await document.archive(user); + await withAPIContext(user, (ctx) => document.archiveWithCtx(ctx)); const res = await server.post("/api/documents.archived", { body: { token: user.getJwtToken(), @@ -3125,7 +3136,7 @@ describe("#documents.restore", () => { userId: user.id, teamId: user.teamId, }); - await document.archive(user); + await withAPIContext(user, (ctx) => document.archiveWithCtx(ctx)); const res = await server.post("/api/documents.restore", { body: { token: user.getJwtToken(), @@ -3149,8 +3160,8 @@ describe("#documents.restore", () => { collectionId: document.collectionId, parentDocumentId: document.id, }); - await childDocument.archive(user); - await document.archive(user); + await withAPIContext(user, (ctx) => childDocument.archiveWithCtx(ctx)); + await withAPIContext(user, (ctx) => document.archiveWithCtx(ctx)); const res = await server.post("/api/documents.restore", { body: { token: user.getJwtToken(), @@ -3979,7 +3990,7 @@ describe("#documents.update", () => { userId: user.id, teamId: user.teamId, }); - await document.archive(user); + await withAPIContext(user, (ctx) => document.archiveWithCtx(ctx)); const res = await server.post("/api/documents.update", { body: { token: user.getJwtToken(), @@ -4544,7 +4555,7 @@ describe("#documents.unpublish", () => { teamId: user.teamId, parentDocumentId: document.id, }); - await child.archive(user); + await withAPIContext(user, (ctx) => child.archiveWithCtx(ctx)); const res = await server.post("/api/documents.unpublish", { body: { token: user.getJwtToken(), @@ -4622,7 +4633,7 @@ describe("#documents.unpublish", () => { userId: user.id, teamId: user.teamId, }); - await document.archive(user); + await withAPIContext(user, (ctx) => document.archiveWithCtx(ctx)); const res = await server.post("/api/documents.unpublish", { body: { token: user.getJwtToken(), diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index a4cff61fad..376e449d5e 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -912,45 +912,19 @@ router.post( if (document.deletedAt && document.isWorkspaceTemplate) { authorize(user, "restore", document); - - await document.restore({ transaction }); - await Event.createFromContext(ctx, { - name: "documents.restore", - documentId: document.id, - collectionId: document.collectionId, - data: { - title: document.title, - }, - }); + await document.restoreWithCtx(ctx, { name: "restore" }); } else if (document.deletedAt) { authorize(user, "restore", document); authorize(user, "updateDocument", destCollection); // restore a previously deleted document - await document.restoreTo(destCollectionId!, { transaction, user }); // destCollectionId is guaranteed to be defined here - await Event.createFromContext(ctx, { - name: "documents.restore", - documentId: document.id, - collectionId: document.collectionId, - data: { - title: document.title, - }, - }); + await document.restoreTo(ctx, { collectionId: destCollectionId! }); // destCollectionId is guaranteed to be defined here } else if (document.archivedAt) { authorize(user, "unarchive", document); authorize(user, "updateDocument", destCollection); // restore a previously archived document - await document.restoreTo(destCollectionId!, { transaction, user }); // destCollectionId is guaranteed to be defined here - await Event.createFromContext(ctx, { - name: "documents.unarchive", - documentId: document.id, - collectionId: document.collectionId, - data: { - title: document.title, - sourceCollectionId, - }, - }); + await document.restoreTo(ctx, { collectionId: destCollectionId! }); // destCollectionId is guaranteed to be defined here } else if (revisionId) { // restore a document to a specific revision authorize(user, "update", document); @@ -958,16 +932,7 @@ router.post( authorize(document, "restore", revision); document.restoreFromRevision(revision); - await document.save({ transaction }); - - await Event.createFromContext(ctx, { - name: "documents.restore", - documentId: document.id, - collectionId: document.collectionId, - data: { - title: document.title, - }, - }); + await document.saveWithCtx(ctx, undefined, { name: "restore" }); } else { assertPresent(revisionId, "revisionId is required"); } @@ -1204,33 +1169,19 @@ router.post( authorize(user, "createTemplate", user.team); } - const document = await Document.create( - { - editorVersion: original.editorVersion, - collectionId, - teamId: user.teamId, - publishedAt: publish ? new Date() : null, - lastModifiedById: user.id, - createdById: user.id, - template: true, - icon: original.icon, - color: original.color, - title: original.title, - text: original.text, - content: original.content, - }, - { - transaction, - } - ); - await Event.createFromContext(ctx, { - name: "documents.create", - documentId: document.id, - collectionId: document.collectionId, - data: { - title: document.title, - template: true, - }, + const document = await Document.createWithCtx(ctx, { + editorVersion: original.editorVersion, + collectionId, + teamId: user.teamId, + publishedAt: publish ? new Date() : null, + lastModifiedById: user.id, + createdById: user.id, + template: true, + icon: original.icon, + color: original.color, + title: original.title, + text: original.text, + content: original.content, }); // reload to get all of the data needed to present (user, collection etc) @@ -1307,7 +1258,6 @@ router.post( document = await documentUpdater(ctx, { document, - user, ...input, publish, collectionId, @@ -1364,15 +1314,13 @@ router.post( } } - const response = await documentDuplicator({ - user, + const response = await documentDuplicator(ctx, { collection, document, title, publish, recursive, parentDocumentId, - ctx, }); ctx.body = { @@ -1427,14 +1375,11 @@ router.post( } } - const { documents, collectionChanged } = await documentMover({ - user, + const { documents, collectionChanged } = await documentMover(ctx, { document, collectionId: collectionId ?? null, parentDocumentId, index, - ip: ctx.request.ip, - transaction, }); ctx.body = { @@ -1467,15 +1412,7 @@ router.post( }); authorize(user, "archive", document); - await document.archive(user, { transaction }); - await Event.createFromContext(ctx, { - name: "documents.archive", - documentId: document.id, - collectionId: document.collectionId, - data: { - title: document.title, - }, - }); + await document.archiveWithCtx(ctx); ctx.body = { data: await presentDocument(ctx, document), @@ -1516,14 +1453,6 @@ router.post( authorize(user, "delete", document); await document.delete(user); - await Event.createFromContext(ctx, { - name: "documents.delete", - documentId: document.id, - collectionId: document.collectionId, - data: { - title: document.title, - }, - }); } ctx.body = { @@ -1536,35 +1465,32 @@ router.post( "documents.unpublish", auth(), validate(T.DocumentsUnpublishSchema), + transaction(), async (ctx: APIContext) => { const { id, detach } = ctx.input.body; const { user } = ctx.state.auth; + const { transaction } = ctx.state; const document = await Document.findByPk(id, { userId: user.id, }); authorize(user, "unpublish", document); - const childDocumentIds = await document.findAllChildDocumentIds({ - archivedAt: { - [Op.eq]: null, + const childDocumentIds = await document.findAllChildDocumentIds( + { + archivedAt: { + [Op.eq]: null, + }, }, - }); + { transaction } + ); if (childDocumentIds.length > 0) { throw InvalidRequestError( "Cannot unpublish document with child documents" ); } - // detaching would unset collectionId from document, so save a ref to the affected collectionId. - const collectionId = document.collectionId; - - await document.unpublish(user, { detach }); - await Event.createFromContext(ctx, { - name: "documents.unpublish", - documentId: document.id, - collectionId, - }); + await document.unpublishWithCtx(ctx, { detach }); ctx.body = { data: await presentDocument(ctx, document), @@ -1713,7 +1639,7 @@ router.post( authorize(user, "read", templateDocument); } - const document = await documentCreator({ + const document = await documentCreator(ctx, { id, title, text: !isNil(text) @@ -1728,9 +1654,7 @@ router.post( templateDocument, template, fullWidth, - user, editorVersion, - ctx, }); if (collection) { diff --git a/server/routes/api/groups/schema.ts b/server/routes/api/groups/schema.ts index aee4c07762..319693fcef 100644 --- a/server/routes/api/groups/schema.ts +++ b/server/routes/api/groups/schema.ts @@ -51,7 +51,10 @@ export const GroupsCreateSchema = z.object({ /** Group name */ name: z.string(), /** Group description */ - description: z.string().max(GroupValidation.maxDescriptionLength).optional(), + description: z + .string() + .max(GroupValidation.maxDescriptionLength) + .optional(), /** Optionally link this group to an external source. */ externalId: z.string().optional(), /** Whether mentions are disabled for this group */ @@ -66,7 +69,10 @@ export const GroupsUpdateSchema = z.object({ /** Group name */ name: z.string().optional(), /** Group description */ - description: z.string().max(GroupValidation.maxDescriptionLength).optional(), + description: z + .string() + .max(GroupValidation.maxDescriptionLength) + .optional(), /** Optionally link this group to an external source. */ externalId: z.string().optional(), /** Whether mentions are disabled for this group */ diff --git a/server/routes/app.ts b/server/routes/app.ts index 91592c54aa..867cdb1797 100644 --- a/server/routes/app.ts +++ b/server/routes/app.ts @@ -195,7 +195,8 @@ export const renderShare = async (ctx: Context, next: Next) => { } // Allow shares to be embedded in iframes on other websites unless prevented by team preference - const preventEmbedding = team?.getPreference(TeamPreference.PreventDocumentEmbedding) ?? false; + const preventEmbedding = + team?.getPreference(TeamPreference.PreventDocumentEmbedding) ?? false; if (!preventEmbedding) { ctx.remove("X-Frame-Options"); } diff --git a/server/types.ts b/server/types.ts index 21c98fdd27..f34315b468 100644 --- a/server/types.ts +++ b/server/types.ts @@ -202,25 +202,19 @@ export type DocumentEvent = BaseEvent & | "documents.restore"; documentId: string; collectionId: string; - data: { - title: string; + data?: { source?: "import"; }; } | { name: "documents.unpublish"; documentId: string; - collectionId: string; + collectionId?: string; } | { name: "documents.unarchive"; documentId: string; collectionId: string; - data: { - title: string; - /** Id of collection from which the document is unarchived */ - sourceCollectionId: string; - }; } | { name: @@ -230,9 +224,7 @@ export type DocumentEvent = BaseEvent & documentId: string; collectionId: string; createdAt: string; - data: { - title: string; - autosave: boolean; + data?: { done: boolean; }; } @@ -241,10 +233,6 @@ export type DocumentEvent = BaseEvent & documentId: string; collectionId: string; createdAt: string; - data: { - title: string; - previousTitle: string; - }; } | DocumentMovedEvent ); diff --git a/server/utils/oauth.test.ts b/server/utils/oauth.test.ts index 8a16d66bb7..03ac31fc7e 100644 --- a/server/utils/oauth.test.ts +++ b/server/utils/oauth.test.ts @@ -3,9 +3,9 @@ import OAuthClient from "./oauth"; class MinimalOAuthClient extends OAuthClient { endpoints = { - authorize: 'http://example.com/authorize', - token: 'http://example.com/token', - userinfo: 'http://example.com/userinfo', + authorize: "http://example.com/authorize", + token: "http://example.com/token", + userinfo: "http://example.com/userinfo", }; } @@ -15,17 +15,17 @@ beforeEach(() => { describe("userInfo", () => { it("should work with empty-body 401 Unauthorized responses", async () => { - fetchMock.mockResponseOnce('', { + fetchMock.mockResponseOnce("", { status: 401, - statusText: 'unauthorized', + statusText: "unauthorized", }); - const client = new MinimalOAuthClient('clientid', 'clientsecret'); + const client = new MinimalOAuthClient("clientid", "clientsecret"); try { expect.assertions(1); - await client.userInfo('token'); + await client.userInfo("token"); } catch (e) { - expect(e.id).toBe('authentication_required'); + expect(e.id).toBe("authentication_required"); } }); }); diff --git a/server/validation.test.ts b/server/validation.test.ts index aab1d68f79..fe92ac28e5 100644 --- a/server/validation.test.ts +++ b/server/validation.test.ts @@ -8,14 +8,16 @@ describe("#ValidateKey.isValid", () => { ValidateKey.isValid(`${Buckets.uploads}/${randomUUID()}/${randomUUID()}`) ).toBe(false); expect( - ValidateKey.isValid(`${Buckets.uploads}/${randomUUID()}/${randomUUID()}/foo/bar`) + ValidateKey.isValid( + `${Buckets.uploads}/${randomUUID()}/${randomUUID()}/foo/bar` + ) ).toBe(false); }); it("should return false if the first key component is not a valid bucket", () => { - expect(ValidateKey.isValid(`foo/${randomUUID()}/${randomUUID()}/bar.png`)).toBe( - false - ); + expect( + ValidateKey.isValid(`foo/${randomUUID()}/${randomUUID()}/bar.png`) + ).toBe(false); }); it("should return false if second and third key components are not UUID", () => { @@ -29,10 +31,14 @@ describe("#ValidateKey.isValid", () => { it("should return true successfully validating key", () => { expect( - ValidateKey.isValid(`${Buckets.public}/${randomUUID()}/${randomUUID()}/foo.png`) + ValidateKey.isValid( + `${Buckets.public}/${randomUUID()}/${randomUUID()}/foo.png` + ) ).toBe(true); expect( - ValidateKey.isValid(`${Buckets.uploads}/${randomUUID()}/${randomUUID()}/foo.png`) + ValidateKey.isValid( + `${Buckets.uploads}/${randomUUID()}/${randomUUID()}/foo.png` + ) ).toBe(true); expect( ValidateKey.isValid(`${Buckets.avatars}/${randomUUID()}/${randomUUID()}`)