Files
outline/server/models/Document.ts
Apoorv Mishra fd34a6d19c Refactor and regroup urlHelpers utils (#6462)
* fix: refactor urlHelpers

* fix: move to /plugins/slack/shared

* fix: remove .babelrc

* fix: remove Outline class

* fix: Slack -> SlackUtils

* fix: UrlHelper class
2024-02-29 11:41:03 +05:30

1055 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable lines-between-class-members */
import compact from "lodash/compact";
import isNil from "lodash/isNil";
import uniq from "lodash/uniq";
import randomstring from "randomstring";
import type {
Identifier,
InferAttributes,
InferCreationAttributes,
NonNullFindOptions,
SaveOptions,
} from "sequelize";
import {
Sequelize,
Transaction,
Op,
FindOptions,
ScopeOptions,
WhereOptions,
EmptyResultError,
} from "sequelize";
import {
ForeignKey,
BelongsTo,
Column,
Default,
PrimaryKey,
Table,
BeforeValidate,
BeforeCreate,
BeforeUpdate,
HasMany,
BeforeSave,
DefaultScope,
AfterCreate,
Scopes,
DataType,
Length as SimpleLength,
IsNumeric,
IsDate,
AllowNull,
BelongsToMany,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type {
NavigationNode,
ProsemirrorData,
SourceMetadata,
} from "@shared/types";
import { UrlHelper } from "@shared/utils/UrlHelper";
import getTasks from "@shared/utils/getTasks";
import slugify from "@shared/utils/slugify";
import { DocumentValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import Backlink from "./Backlink";
import Collection from "./Collection";
import FileOperation from "./FileOperation";
import Revision from "./Revision";
import Star from "./Star";
import Team from "./Team";
import User from "./User";
import UserMembership from "./UserMembership";
import View from "./View";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
import DocumentHelper from "./helpers/DocumentHelper";
import Length from "./validators/Length";
export const DOCUMENT_VERSION = 2;
type AdditionalFindOptions = {
userId?: string;
includeState?: boolean;
rejectOnEmpty?: boolean | Error;
};
@DefaultScope(() => ({
attributes: {
exclude: ["state"],
},
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
paranoid: false,
},
],
where: {
publishedAt: {
[Op.ne]: null,
},
sourceMetadata: {
trial: {
[Op.is]: null,
},
},
},
}))
@Scopes(() => ({
withCollectionPermissions: (userId: string, paranoid = true) => ({
include: [
{
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
model: userId
? Collection.scope({
method: ["withMembership", userId],
})
: Collection,
as: "collection",
paranoid,
},
],
}),
withoutState: {
attributes: {
exclude: ["state"],
},
},
withCollection: {
include: [
{
model: Collection,
as: "collection",
},
],
},
withStateIsEmpty: {
attributes: {
exclude: ["state"],
include: [
[
Sequelize.literal(`CASE WHEN state IS NULL THEN true ELSE false END`),
"stateIsEmpty",
],
],
},
},
withState: {
attributes: {
// resets to include the state column
exclude: [],
},
},
withDrafts: {
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
paranoid: false,
},
],
},
withViews: (userId: string) => {
if (!userId) {
return {};
}
return {
include: [
{
model: View,
as: "views",
where: {
userId,
},
required: false,
separate: true,
},
],
};
},
withMembership: (userId: string) => {
if (!userId) {
return {};
}
return {
include: [
{
association: "memberships",
where: {
userId,
},
required: false,
},
],
};
},
}))
@Table({ tableName: "documents", modelName: "document" })
@Fix
class Document extends ParanoidModel<
InferAttributes<Document>,
Partial<InferCreationAttributes<Document>>
> {
@SimpleLength({
min: 10,
max: 10,
msg: `urlId must be 10 characters`,
})
@PrimaryKey
@Column
urlId: string;
@Length({
max: DocumentValidation.maxTitleLength,
msg: `Document title must be ${DocumentValidation.maxTitleLength} characters or less`,
})
@Column
title: string;
@Length({
max: DocumentValidation.maxSummaryLength,
msg: `Document summary must be ${DocumentValidation.maxSummaryLength} characters or less`,
})
@Column
summary: string;
@Column(DataType.ARRAY(DataType.STRING))
previousTitles: string[] = [];
@IsNumeric
@Column(DataType.SMALLINT)
version?: number | null;
@Default(false)
@Column
template: boolean;
@Default(false)
@Column
fullWidth: boolean;
@Column
insightsEnabled: boolean;
/** The version of the editor last used to edit this document. */
@SimpleLength({
max: 255,
msg: `editorVersion must be 255 characters or less`,
})
@Column
editorVersion: string;
/** An emoji to use as the document icon. */
@Length({
max: 1,
msg: `Emoji must be a single character`,
})
@Column
emoji: string | null;
/**
* The content of the document as Markdown.
*
* @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if exporting lossy markdown.
* This column will be removed in a future migration.
*/
@Column(DataType.TEXT)
text: string;
/**
* The content of the document as JSON, this is a snapshot at the last time the state was saved.
*/
@Column(DataType.JSONB)
content: ProsemirrorData;
/**
* The content of the document as YJS collaborative state, this column can be quite large and
* should only be selected from the DB when the `content` snapshot cannot be used.
*/
@SimpleLength({
max: DocumentValidation.maxStateLength,
msg: `Document collaborative state is too large, you must create a new document`,
})
@Column(DataType.BLOB)
state?: Uint8Array | null;
/** Whether this document is part of onboarding. */
@Default(false)
@Column
isWelcome: boolean;
/** How many versions there are in the history of this document. */
@IsNumeric
@Default(0)
@Column(DataType.INTEGER)
revisionCount: number;
/** Whether the document is archvied, and if so when. */
@IsDate
@Column
archivedAt: Date | null;
/** Whether the document is published, and if so when. */
@IsDate
@Column
publishedAt: Date | null;
/** An array of user IDs that have edited this document. */
@Column(DataType.ARRAY(DataType.UUID))
collaboratorIds: string[] = [];
// getters
/**
* The frontend path to this document.
*
* @deprecated Use `path` instead.
*/
get url() {
return this.path;
}
/** The frontend path to this document. */
get path() {
if (!this.title) {
return `/doc/untitled-${this.urlId}`;
}
const slugifiedTitle = slugify(this.title);
return `/doc/${slugifiedTitle}-${this.urlId}`;
}
get tasks() {
return getTasks(this.text || "");
}
// hooks
@BeforeSave
static async updateCollectionStructure(
model: Document,
{ transaction }: SaveOptions<Document>
) {
// templates, drafts, and archived documents don't appear in the structure
// and so never need to be updated when the title changes
if (
model.archivedAt ||
model.template ||
!model.publishedAt ||
!(model.changed("title") || model.changed("emoji")) ||
!model.collectionId
) {
return;
}
const collection = await Collection.findByPk(model.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
if (!collection) {
return;
}
await collection.updateDocument(model, { transaction });
model.collection = collection;
}
@AfterCreate
static async addDocumentToCollectionStructure(model: Document) {
if (
model.archivedAt ||
model.template ||
!model.publishedAt ||
!model.collectionId
) {
return;
}
return this.sequelize!.transaction(async (transaction: Transaction) => {
const collection = await Collection.findByPk(model.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!collection) {
return;
}
await collection.addDocumentToStructure(model, 0, { transaction });
model.collection = collection;
});
}
@BeforeValidate
static createUrlId(model: Document) {
return (model.urlId = model.urlId || randomstring.generate(10));
}
@BeforeCreate
static setDocumentVersion(model: Document) {
if (model.version === undefined) {
model.version = DOCUMENT_VERSION;
}
return this.processUpdate(model);
}
@BeforeUpdate
static processUpdate(model: Document) {
// ensure documents have a title
model.title = model.title || "";
const previousTitle = model.previous("title");
if (previousTitle && previousTitle !== model.title) {
if (!model.previousTitles) {
model.previousTitles = [];
}
model.previousTitles = uniq(model.previousTitles.concat(previousTitle));
}
// add the current user as a collaborator on this doc
if (!model.collaboratorIds) {
model.collaboratorIds = [];
}
// backfill content if it's missing
if (!model.content) {
model.content = DocumentHelper.toJSON(model);
}
// ensure the last modifying user is a collaborator
model.collaboratorIds = uniq(
model.collaboratorIds.concat(model.lastModifiedById)
);
// increment revision
model.revisionCount += 1;
}
@BeforeUpdate
static async checkParentDocument(model: Document, options: SaveOptions) {
if (
model.previous("parentDocumentId") === model.parentDocumentId ||
!model.parentDocumentId
) {
return;
}
if (model.parentDocumentId === model.id) {
throw ValidationError(
"infinite loop detected, cannot nest a document inside itself"
);
}
const childDocumentIds = await model.findAllChildDocumentIds(
undefined,
options
);
if (childDocumentIds.includes(model.parentDocumentId)) {
throw ValidationError(
"infinite loop detected, cannot nest a document inside itself"
);
}
}
// associations
@BelongsTo(() => FileOperation, "importId")
import: FileOperation | null;
@ForeignKey(() => FileOperation)
@Column(DataType.UUID)
importId: string | null;
@AllowNull
@Column(DataType.JSONB)
sourceMetadata: SourceMetadata | null;
@BelongsTo(() => Document, "parentDocumentId")
parentDocument: Document | null;
@ForeignKey(() => Document)
@Column(DataType.UUID)
parentDocumentId: string | null;
@BelongsTo(() => User, "lastModifiedById")
updatedBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
lastModifiedById: string;
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
@BelongsTo(() => Document, "templateId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
templateId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => Collection, "collectionId")
collection: Collection | null | undefined;
@BelongsToMany(() => User, () => UserMembership)
users: User[];
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId?: string | null;
@HasMany(() => UserMembership)
memberships: UserMembership[];
@HasMany(() => Revision)
revisions: Revision[];
@HasMany(() => Backlink)
backlinks: Backlink[];
@HasMany(() => Star)
starred: Star[];
@HasMany(() => View)
views: View[];
static defaultScopeWithUser(userId: string) {
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", userId],
};
const viewScope: Readonly<ScopeOptions> = {
method: ["withViews", userId],
};
const membershipScope: Readonly<ScopeOptions> = {
method: ["withMembership", userId],
};
return this.scope([
"defaultScope",
collectionScope,
viewScope,
membershipScope,
]);
}
/**
* Overrides the standard findByPk behavior to allow also querying by urlId
*
* @param id uuid or urlId
* @param options FindOptions
* @returns A promise resolving to a collection instance or null
*/
static async findByPk(
id: Identifier,
options?: NonNullFindOptions<Document> & AdditionalFindOptions
): Promise<Document>;
static async findByPk(
id: Identifier,
options?: FindOptions<Document> & AdditionalFindOptions
): Promise<Document | null>;
static async findByPk(
id: Identifier,
options: (NonNullFindOptions<Document> | FindOptions<Document>) &
AdditionalFindOptions = {}
): Promise<Document | null> {
if (typeof id !== "string") {
return null;
}
const { includeState, userId, ...rest } = options;
// allow default preloading of collection membership if `userId` is passed in find options
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([
...(includeState ? [] : ["withoutState"]),
"withDrafts",
{
method: ["withCollectionPermissions", userId, rest.paranoid],
},
{
method: ["withViews", userId],
},
{
method: ["withMembership", userId],
},
]);
if (isUUID(id)) {
const document = await scope.findOne({
where: {
id,
},
...rest,
rejectOnEmpty: false,
});
if (!document && rest.rejectOnEmpty) {
throw new EmptyResultError(`Document doesn't exist with id: ${id}`);
}
return document;
}
const match = id.match(UrlHelper.SLUG_URL_REGEX);
if (match) {
const document = await scope.findOne({
where: {
urlId: match[1],
},
...rest,
rejectOnEmpty: false,
});
if (!document && rest.rejectOnEmpty) {
throw new EmptyResultError(`Document doesn't exist with id: ${id}`);
}
return document;
}
return null;
}
// instance methods
/**
* Whether this document is considered active or not. A document is active if
* it has not been archived or deleted.
*
* @returns boolean
*/
get isActive(): boolean {
return !this.archivedAt && !this.deletedAt;
}
/**
* Convenience method that returns whether this document is a draft.
*
* @returns boolean
*/
get isDraft(): boolean {
return !this.publishedAt;
}
/**
* Returns the title of the document or a default if the document is untitled.
*
* @returns boolean
*/
get titleWithDefault(): string {
return this.title || "Untitled";
}
/**
* Whether this document was imported during a trial period.
*
* @returns boolean
*/
get isTrialImport() {
return !!(this.importId && this.sourceMetadata?.trial);
}
/**
* Revert the state of the document to match the passed revision.
*
* @param revision The revision to revert to.
*/
restoreFromRevision = (revision: Revision) => {
if (revision.documentId !== this.id) {
throw new Error("Revision does not belong to this document");
}
this.content = revision.content;
this.text = revision.text;
this.title = revision.title;
this.emoji = revision.emoji;
};
/**
* Get a list of users that have collaborated on this document
*
* @param options FindOptions
* @returns A promise that resolve to a list of users
*/
collaborators = async (options?: FindOptions<User>): Promise<User[]> => {
const users = await Promise.all(
this.collaboratorIds.map((collaboratorId) =>
User.findByPk(collaboratorId, options)
)
);
return compact(users);
};
/**
* Find all of the child documents for this document
*
* @param options FindOptions
* @returns A promise that resolve to a list of documents
*/
findChildDocuments = async (
where?: Omit<WhereOptions<Document>, "parentDocumentId">,
options?: FindOptions<Document>
): Promise<Document[]> =>
await (this.constructor as typeof Document).findAll({
where: {
parentDocumentId: this.id,
...where,
},
...options,
});
/**
* Calculate all of the document ids that are children of this document by
* recursively iterating through parentDocumentId references in the most efficient way.
*
* @param where query options to further filter the documents
* @param options FindOptions
* @returns A promise that resolves to a list of document ids
*/
findAllChildDocumentIds = async (
where?: Omit<WhereOptions<Document>, "parentDocumentId">,
options?: FindOptions<Document>
): Promise<string[]> => {
const findAllChildDocumentIds = async (
...parentDocumentId: string[]
): Promise<string[]> => {
const childDocuments = await (
this.constructor as typeof Document
).findAll({
attributes: ["id"],
where: {
parentDocumentId,
...where,
},
...options,
});
const childDocumentIds = childDocuments.map((doc) => doc.id);
if (childDocumentIds.length > 0) {
return [
...childDocumentIds,
...(await findAllChildDocumentIds(...childDocumentIds)),
];
}
return childDocumentIds;
};
return findAllChildDocumentIds(this.id);
};
archiveWithChildren = async (
userId: string,
options?: FindOptions<Document>
) => {
const archivedAt = new Date();
// Helper to archive all child documents for a document
const archiveChildren = async (parentDocumentId: string) => {
const childDocuments = await (
this.constructor as typeof Document
).findAll({
where: {
parentDocumentId,
},
});
for (const child of childDocuments) {
await archiveChildren(child.id);
child.archivedAt = archivedAt;
child.lastModifiedById = userId;
await child.save(options);
}
};
await archiveChildren(this.id);
this.archivedAt = archivedAt;
this.lastModifiedById = userId;
return this.save(options);
};
publish = async (
userId: string,
collectionId: string,
{ transaction }: SaveOptions<Document>
) => {
// If the document is already published then calling publish should act like
// a regular save
if (this.publishedAt) {
return this.save({ transaction });
}
if (!this.collectionId) {
this.collectionId = collectionId;
}
if (!this.template) {
const collection = await Collection.findByPk(this.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
if (collection) {
await collection.addDocumentToStructure(this, 0, { transaction });
this.collection = collection;
}
}
const parentDocumentPermissions = this.parentDocumentId
? await UserMembership.findAll({
where: {
documentId: this.parentDocumentId,
},
transaction,
})
: [];
await Promise.all(
parentDocumentPermissions.map((permission) =>
UserMembership.create(
{
documentId: this.id,
userId: permission.userId,
sourceId: permission.sourceId ?? permission.id,
permission: permission.permission,
createdById: permission.createdById,
},
{
transaction,
}
)
)
);
this.lastModifiedById = userId;
this.publishedAt = new Date();
return this.save({ transaction });
};
isCollectionDeleted = async () => {
if (this.deletedAt || this.archivedAt) {
if (this.collectionId) {
const collection =
this.collection ??
(await Collection.findByPk(this.collectionId, {
attributes: ["deletedAt"],
paranoid: false,
}));
return !!collection?.deletedAt;
}
}
return false;
};
unpublish = async (userId: string) => {
// If the document is already a draft then calling unpublish should act like
// a regular save
if (!this.publishedAt) {
return this.save();
}
await this.sequelize.transaction(async (transaction: Transaction) => {
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction.LOCK.UPDATE,
})
: undefined;
if (collection) {
await collection.removeDocumentInStructure(this, { transaction });
this.collection = collection;
}
});
// unpublishing a document converts the ownership to yourself, so that it
// will appear in your drafts rather than the original creators
this.createdById = userId;
this.lastModifiedById = userId;
this.publishedAt = null;
return this.save();
};
// Moves a document from being visible to the team within a collection
// to the archived area, where it can be subsequently restored.
archive = async (userId: string) => {
await this.sequelize.transaction(async (transaction: Transaction) => {
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction.LOCK.UPDATE,
})
: undefined;
if (collection) {
await collection.removeDocumentInStructure(this, { transaction });
this.collection = collection;
}
});
await this.archiveWithChildren(userId);
return this;
};
// Restore an archived document back to being visible to the team
unarchive = async (userId: string) => {
await this.sequelize.transaction(async (transaction: Transaction) => {
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction.LOCK.UPDATE,
})
: undefined;
// check to see if the documents parent hasn't been archived also
// If it has then restore the document to the collection root.
if (this.parentDocumentId) {
const parent = await (this.constructor as typeof Document).findOne({
where: {
id: this.parentDocumentId,
},
});
if (parent?.isDraft || !parent?.isActive) {
this.parentDocumentId = null;
}
}
if (!this.template && this.publishedAt && collection) {
await collection.addDocumentToStructure(this, undefined, {
transaction,
});
this.collection = collection;
}
});
if (this.deletedAt) {
await this.restore();
}
this.archivedAt = null;
this.lastModifiedById = userId;
await this.save();
return this;
};
// Delete a document, archived or otherwise.
delete = (userId: string) =>
this.sequelize.transaction(async (transaction: Transaction) => {
if (!this.archivedAt && !this.template && this.collectionId) {
// delete any children and remove from the document structure
const collection = await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
});
await collection?.deleteDocument(this, { transaction });
} else {
await this.destroy({
transaction,
});
}
await Revision.destroy({
where: {
documentId: this.id,
},
transaction,
});
await this.update(
{
lastModifiedById: userId,
},
{
transaction,
}
);
return this;
});
getTimestamp = () => Math.round(new Date(this.updatedAt).getTime() / 1000);
getSummary = () => {
if (this.summary) {
return this.summary;
}
const plainText = DocumentHelper.toPlainText(this);
const lines = compact(plainText.split("\n"));
const notEmpty = lines.length >= 1;
if (this.version) {
return notEmpty ? lines[0] : "";
}
return notEmpty ? lines[1] : "";
};
/**
* Returns a JSON representation of the document suitable for use in the
* collection documentStructure.
*
* @param options Optional transaction to use for the query
* @returns Promise resolving to a NavigationNode
*/
toNavigationNode = async (
options?: FindOptions<Document>
): Promise<NavigationNode> => {
// Checking if the record is new is a performance optimization new docs cannot have children
const childDocuments = this.isNewRecord
? []
: await (this.constructor as typeof Document)
.unscoped()
.scope("withoutState")
.findAll({
where: {
teamId: this.teamId,
parentDocumentId: this.id,
archivedAt: {
[Op.is]: null,
},
publishedAt: {
[Op.ne]: null,
},
},
transaction: options?.transaction,
});
const children = await Promise.all(
childDocuments.map((child) => child.toNavigationNode(options))
);
return {
id: this.id,
title: this.title,
url: this.url,
emoji: isNil(this.emoji) ? undefined : this.emoji,
children,
};
};
}
export default Document;