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 <tom@getoutline.com>
This commit is contained in:
Hemachandar
2025-11-24 01:10:45 +05:30
committed by GitHub
parent f4d9b6b257
commit 142985c6d7
31 changed files with 428 additions and 554 deletions
+56 -20
View File
@@ -1,4 +1,4 @@
import { Trans } from 'react-i18next';
import { Trans } from "react-i18next";
export const Translations = () => (
<>
@@ -6,7 +6,9 @@ export const Translations = () => (
<Trans defaults={`Paper size`} />
<Trans defaults={`Ask AI "{{question}}"`} />
<Trans defaults={`Are you sure you want to delete?`} />
<Trans defaults={`Deleting this version of the document will permanently and irrevocably remove it from the history.`} />
<Trans
defaults={`Deleting this version of the document will permanently and irrevocably remove it from the history.`}
/>
<Trans defaults={`Format`} />
<Trans defaults={`Add option`} />
<Trans defaults={`Optional`} />
@@ -21,35 +23,59 @@ export const Translations = () => (
<Trans defaults={`Yes`} />
<Trans defaults={`No`} />
<Trans defaults={`Search or ask a question`} />
<Trans defaults={`Invited {{roleName}} will not receive access to any collections or documents unless explicitly shared.`} />
<Trans
defaults={`Invited {{roleName}} will not receive access to any collections or documents unless explicitly shared.`}
/>
<Trans defaults={`Can view only what is explicitly shared`} />
<Trans defaults={`SAML assertion was invalid or missing fields, please check your configuration`} />
<Trans defaults={`AI generated answer based on related documents in your workspace`} />
<Trans
defaults={`SAML assertion was invalid or missing fields, please check your configuration`}
/>
<Trans
defaults={`AI generated answer based on related documents in your workspace`}
/>
<Trans defaults={`References`} />
<Trans defaults={`Enable AI answers to get direct answers to searched questions.`} />
<Trans
defaults={`Enable AI answers to get direct answers to searched questions.`}
/>
<Trans defaults={`Go to settings`} />
<Trans defaults={`Where do I find the file?`} />
<Trans defaults={`In a Confluence space, navigate to <em>Space Settings -> Manage space -> Export space</em> and choose to export as HTML with the "Normal Export" option.`} />
<Trans defaults={`Drag and drop the zip file from Confluence's HTML export option, or click to upload`} />
<Trans
defaults={`In a Confluence space, navigate to <em>Space Settings -> Manage space -> Export space</em> and choose to export as HTML with the "Normal Export" option.`}
/>
<Trans
defaults={`Drag and drop the zip file from Confluence's HTML export option, or click to upload`}
/>
<Trans defaults={`Guests`} />
<Trans defaults={`New Attribute`} />
<Trans defaults={`Attributes allow you to define data to be stored with your documents. They can be used to store custom properties, metadata, or any other structured information that is common across documents.`} />
<Trans
defaults={`Attributes allow you to define data to be stored with your documents. They can be used to store custom properties, metadata, or any other structured information that is common across documents.`}
/>
<Trans defaults={`Custom domain`} />
<Trans defaults={`AI answers`} />
<Trans defaults={`Use AI to directly answer searched questions using content in your workspace.`} />
<Trans
defaults={`Use AI to directly answer searched questions using content in your workspace.`}
/>
<Trans defaults={`API access`} />
<Trans defaults={`Allow members to create API keys for programmatic access`} />
<Trans
defaults={`Allow members to create API keys for programmatic access`}
/>
<Trans defaults={`Public document embedding`} />
<Trans defaults={`When enabled, publicly shared documents can be embedded in third-party websites`} />
<Trans
defaults={`When enabled, publicly shared documents can be embedded in third-party websites`}
/>
<Trans defaults={`Include previews in emails`} />
<Trans defaults={`When enabled, email notifications will include content previews`} />
<Trans
defaults={`When enabled, email notifications will include content previews`}
/>
<Trans defaults={`Boolean`} />
<Trans defaults={`Number`} />
<Trans defaults={`Text`} />
<Trans defaults={`List`} />
<Trans defaults={`Could not load events`} />
<Trans defaults={`Audit Log`} />
<Trans defaults={`The audit log details the history of security related and other events across your knowledge base.`} />
<Trans
defaults={`The audit log details the history of security related and other events across your knowledge base.`}
/>
<Trans defaults={`IP address`} />
<Trans defaults={`Actor`} />
<Trans defaults={`Event`} />
@@ -62,15 +88,25 @@ export const Translations = () => (
<Trans defaults={`Sharing enabled`} />
<Trans defaults={`Date archived`} />
<Trans defaults={`Could not load collections`} />
<Trans defaults={`Manage the permissions and settings of all collections in the knowledge base. As a workspace admin you can also administer private collections.`} />
<Trans defaults={`Automatically index and search document content from {{appName}} inside <4>Glean</4> in realtime.`} />
<Trans
defaults={`Manage the permissions and settings of all collections in the knowledge base. As a workspace admin you can also administer private collections.`}
/>
<Trans
defaults={`Automatically index and search document content from {{appName}} inside <4>Glean</4> in realtime.`}
/>
<Trans defaults={`API Endpoint`} />
<Trans defaults={`API Secret`} />
<Trans defaults={`Datasource`} />
<Trans defaults={`Details of the current {{appName}} license. To arrange contract renewal as expiry or seat limits approach or increase licensed seats please contact your account manager or email <4>priority@getoutline.com</4>.`} />
<Trans defaults={`Sorry, an answer could not be found in the collection, try widening your search.`} />
<Trans defaults={`Sorry, an answer could not be found in the workspace, try widening your search.`} />
<Trans
defaults={`Details of the current {{appName}} license. To arrange contract renewal as expiry or seat limits approach or increase licensed seats please contact your account manager or email <4>priority@getoutline.com</4>.`}
/>
<Trans
defaults={`Sorry, an answer could not be found in the collection, try widening your search.`}
/>
<Trans
defaults={`Sorry, an answer could not be found in the workspace, try widening your search.`}
/>
<Trans defaults={`Looking for answers`} />
<Trans defaults={`Answer to "{{ query }}"`} />
</>
)
);
@@ -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([
+3 -2
View File
@@ -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,
});
}
});
+14 -42
View File
@@ -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,
})
);
+42 -70
View File
@@ -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<Document> {
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<Document> {
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)
+11 -22
View File
@@ -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 }),
})
);
+7 -19
View File
@@ -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<Document[]> {
export default async function documentDuplicator(
ctx: APIContext,
{ document, collection, parentDocumentId, title, publish, recursive }: Props
): Promise<Document[]> {
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,
+35 -43
View File
@@ -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,
})
);
+22 -43
View File
@@ -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<Result> {
async function documentMover(
ctx: APIContext,
{
document,
collectionId = null,
parentDocumentId = null,
// convert undefined to null so parentId comparison treats them as equal
index,
}: Props
): Promise<Result> {
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;
-3
View File
@@ -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,
})
);
+6 -33
View File
@@ -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<Document> {
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,
+3 -1
View File
@@ -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
+91 -48
View File
@@ -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<string, unknown>;
}
): Promise<this> => {
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<Document>
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<Document>
) => {
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" });
};
}
+5 -1
View File
@@ -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;
+3 -1
View File
@@ -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<string, number>();
documentStructure.forEach((node, index) => {
@@ -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({
@@ -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;
}
@@ -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({
@@ -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;
}
@@ -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,
}),
]);
@@ -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<Props> {
});
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,
});
}
});
+4 -4
View File
@@ -44,15 +44,17 @@ export default class DocumentImportTask extends BaseTask<Props> {
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<Props> {
publish,
collectionId,
parentDocumentId,
user,
ctx: createContext({ user, transaction, ip }),
});
});
return { documentId: document.id };
@@ -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();
+1 -3
View File
@@ -435,7 +435,7 @@ export default abstract class ImportTask extends BaseTask<Props> {
);
}
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<Props> {
publishedAt: item.updatedAt ?? item.createdAt ?? new Date(),
parentDocumentId: item.parentDocumentId,
importId: fileOperation.id,
user,
ctx: createContext({ user, transaction }),
});
documents.set(item.id, document);
+32 -21
View File
@@ -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(),
+31 -107
View File
@@ -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<T.DocumentsUnpublishReq>) => {
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) {
+8 -2
View File
@@ -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 */
+2 -1
View File
@@ -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");
}
+3 -15
View File
@@ -202,25 +202,19 @@ export type DocumentEvent = BaseEvent<Document> &
| "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<Document> &
documentId: string;
collectionId: string;
createdAt: string;
data: {
title: string;
autosave: boolean;
data?: {
done: boolean;
};
}
@@ -241,10 +233,6 @@ export type DocumentEvent = BaseEvent<Document> &
documentId: string;
collectionId: string;
createdAt: string;
data: {
title: string;
previousTitle: string;
};
}
| DocumentMovedEvent
);
+8 -8
View File
@@ -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");
}
});
});
+12 -6
View File
@@ -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()}`)