server/models/Document.ts
Rama main · réplica de lectura del repositorio original.
import { compact, isNil, uniq } from "es-toolkit/compat";
import type {
Identifier,
InferAttributes,
InferCreationAttributes,
NonNullFindOptions,
SaveOptions,
ScopeOptions,
FindOptions,
ProjectionAlias,
WhereOptions,
} from "sequelize";
import {
Transaction,
Op,
EmptyResultError,
Sequelize,
QueryTypes,
} from "sequelize";
import {
ForeignKey,
BelongsTo,
Column,
Default,
Table,
BeforeValidate,
BeforeCreate,
BeforeUpdate,
HasMany,
BeforeSave,
DefaultScope,
AfterCreate,
Scopes,
DataType,
Length as SimpleLength,
IsNumeric,
IsDate,
AllowNull,
BelongsToMany,
Unique,
AfterUpdate,
IsFloat,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type {
DocumentPermission,
DocumentPreference,
DocumentPreferences,
ImportableIntegrationService,
NavigationNode,
ProsemirrorData,
SourceMetadata,
} from "@shared/types";
import { DocumentPreferenceDefaults } from "@shared/constants";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { UrlHelper } from "@shared/utils/UrlHelper";
import slugify from "@server/utils/slugify";
import { DeprecationValidation, DocumentValidation } from "@shared/validations";
import { InvalidRequestError, ValidationError } from "@server/errors";
import { afterCommit } from "@server/storage/transaction";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import { generateUrlId } from "@server/utils/url";
import Collection from "./Collection";
import Comment from "./Comment";
import FileOperation from "./FileOperation";
import Group from "./Group";
import GroupMembership from "./GroupMembership";
import GroupUser from "./GroupUser";
import Import from "./Import";
import Relationship from "./Relationship";
import Revision from "./Revision";
import Share from "./Share";
import Star from "./Star";
import Team from "./Team";
import User from "./User";
import UserMembership from "./UserMembership";
import View from "./View";
import ArchivableModel from "./base/ArchivableModel";
import { CounterCache } from "./decorators/CounterCache";
import { DocumentHelper } from "./helpers/DocumentHelper";
import IsHexColor from "./validators/IsHexColor";
import Length from "./validators/Length";
import type { APIContext } from "@server/types";
import { APIUpdateExtension } from "@server/collaboration/APIUpdateExtension";
import { SkipChangeset } from "./decorators/Changeset";
import type { EventOverrideOptions, HookContext } from "./base/Model";
import Template from "./Template";
export const DOCUMENT_VERSION = 2;
// If content (JSON) is null then we still need to return the state column (BINARY)
// as it's used as a fallback for content deserialization for older documents.
// This can be removed if content is 100% backfilled.
const stateIfContentEmpty: ProjectionAlias = [
Sequelize.literal(
`CASE WHEN document.content IS NULL THEN document.state ELSE NULL END`
),
"state",
];
// The markdown text column is only needed to deserialize documents that have
// neither content nor state.
const textIfContentAndStateEmpty: ProjectionAlias = [
Sequelize.literal(
`CASE WHEN document.content IS NULL AND document.state IS NULL THEN document.text ELSE NULL END`
),
"text",
];
type AdditionalFindOptions = {
/** The user ID to load associated permissions for. */
userId?: string;
/** Whether to include the state column in the attributes. */
includeState?: boolean;
/**
* Whether to include the content columns in the attributes (default: true).
* Pass false when the document is only needed for authorization.
*/
includeContent?: boolean;
/** Whether to include views (default: true). */
includeViews?: boolean;
/** Whether to reject the query if no document is found. */
rejectOnEmpty?: boolean | Error;
};
/** Sequelize types the query generator as unknown; this narrows to the single
* method used to build a raw SQL filter fragment. */
interface QueryGeneratorWithWhere {
getWhereConditions(
where: WhereOptions<Document>,
tableName: string,
factory: typeof Document
): string;
}
// Documents that are visible by default: published, not a template, and not
// created as part of a trial.
const publishedWhere: WhereOptions<Document> = {
publishedAt: {
[Op.ne]: null,
},
sourceMetadata: {
trial: {
[Op.is]: null,
},
},
template: false,
};
@DefaultScope(() => ({
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
paranoid: false,
},
],
where: publishedWhere,
attributes: {
exclude: ["state"],
include: [stateIfContentEmpty],
},
}))
@Scopes(() => ({
published: {
where: publishedWhere,
},
withoutState: {
attributes: {
exclude: ["state"],
include: [stateIfContentEmpty],
},
},
withoutStateOrText: {
attributes: {
exclude: ["state", "text"],
include: [stateIfContentEmpty, textIfContentAndStateEmpty],
},
},
withoutContent: {
attributes: {
exclude: ["state", "content", "text"],
},
},
withCollection: {
include: [
{
model: Collection,
as: "collection",
},
],
},
withState: {
attributes: {
// resets to include the state column
include: [],
},
},
withDrafts: {
include: [
{
association: "createdBy",
paranoid: false,
},
{
association: "updatedBy",
paranoid: false,
},
],
},
withViews: (userId: string) => {
if (!userId) {
return {};
}
return {
include: [
{
model: View,
as: "views",
where: {
userId,
},
required: false,
},
],
};
},
withMembership: (userId: string, paranoid = true) => {
if (!userId) {
return {};
}
return {
include: [
{
model: Collection.scope([
"defaultScope",
{
method: ["withMembership", userId],
},
]),
as: "collection",
paranoid,
},
{
association: "memberships",
where: {
userId,
},
required: false,
},
{
association: "groupMemberships",
required: false,
// use of "separate" property: sequelize breaks when there are
// nested "includes" with alternating values for "required"
// see https://github.com/sequelize/sequelize/issues/9869
separate: true,
// include for groups that are members of this document,
// of which userId is a member of, resulting in:
// GroupMembership [inner join] Group [inner join] GroupUser [where] userId
include: [
{
model: Group,
as: "group",
required: true,
include: [
{
model: GroupUser,
as: "groupUsers",
required: true,
where: {
userId,
},
},
],
},
],
},
],
};
},
withAllMemberships: {
include: [
{
association: "memberships",
required: false,
},
{
model: GroupMembership,
as: "groupMemberships",
required: false,
// use of "separate" property: sequelize breaks when there are
// nested "includes" with alternating values for "required"
// see https://github.com/sequelize/sequelize/issues/9869
separate: true,
// include for groups that are members of this collection,
// of which userId is a member of, resulting in:
// CollectionGroup [inner join] Group [inner join] GroupUser [where] userId
include: [
{
model: Group,
as: "group",
required: true,
include: [
{
model: GroupUser,
as: "groupUsers",
required: true,
},
],
},
],
},
],
},
}))
@Table({ tableName: "documents", modelName: "document" })
class Document extends ArchivableModel<
InferAttributes<Document>,
Partial<InferCreationAttributes<Document>>
> {
/** Seconds for which a user's document membership IDs are cached. */
static membershipDocumentIdsCacheTTL = 10;
@SimpleLength({
min: 10,
max: 10,
msg: `urlId must be 10 characters`,
})
@Unique
@Column(DataType.STRING)
urlId: string;
@Length({
max: DocumentValidation.maxTitleLength,
msg: `Document title must be ${DocumentValidation.maxTitleLength} characters or less`,
})
@Column(DataType.STRING)
title: string;
@Length({
max: DocumentValidation.maxSummaryLength,
msg: `Document summary must be ${DocumentValidation.maxSummaryLength} characters or less`,
})
@Column(DataType.STRING)
@SkipChangeset
summary: string;
/** The reason this document is archived or deleted. */
@Length({ max: DeprecationValidation.maxReasonLength })
@Column(DataType.TEXT)
deprecatedReason: string | null;
@Column(DataType.ARRAY(DataType.STRING))
previousTitles: string[];
@IsNumeric
@Column(DataType.SMALLINT)
version?: number | null;
@Default(false)
@Column(DataType.BOOLEAN)
fullWidth: boolean;
/** Display preferences for the document. */
@AllowNull
@Column(DataType.JSONB)
preferences: DocumentPreferences | null;
@Default(false)
@Column(DataType.BOOLEAN)
template: boolean;
@Column(DataType.BOOLEAN)
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(DataType.STRING)
editorVersion: string | null;
/** An icon to use as the document icon. */
@Column(DataType.STRING)
icon: string | null;
/** The color of the icon. */
@IsHexColor
@Column(DataType.STRING)
color: 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.
*/
@SimpleLength({
max: DocumentValidation.maxLength,
msg: `Document text content must be ${DocumentValidation.maxLength} characters or less`,
})
@Column(DataType.TEXT)
@SkipChangeset
text: string;
/** The likely language of the content, in ISO 639-1 format. */
@AllowNull
@Length({ max: 2, msg: "language must be an ISO 639-1 code" })
@Column(DataType.STRING(2))
language: string | null;
/**
* 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;
/**
* 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)
@SkipChangeset
state?: Uint8Array | null;
/** Whether this document is part of onboarding. */
@Default(false)
@Column(DataType.BOOLEAN)
isWelcome: boolean;
/** How many versions there are in the history of this document. */
@IsNumeric
@Default(0)
@Column(DataType.INTEGER)
revisionCount: number;
/** A score representing the popularity of this document based on views and engagement. */
@IsFloat
@Default(0)
@Column(DataType.FLOAT)
@SkipChangeset
popularityScore: number;
/** Whether the document is published, and if so when. */
@IsDate
@Column(DataType.DATE)
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() {
return Document.getPath({
title: this.title,
urlId: this.urlId,
});
}
get tasks() {
return ProsemirrorHelper.getTasksSummary(
DocumentHelper.toProsemirror(this)
);
}
/**
* Returns the key used to store the collaborators of a document in Redis.
* @param documentId The ID of the document.
* @returns Redis key for collaborators
*/
static getCollaboratorKey(documentId: string) {
return `collaborators:v2:${documentId}`;
}
static getPath({ title, urlId }: { title: string; urlId: string }) {
if (!title.length) {
return `/doc/untitled-${urlId}`;
}
const slugifiedTitle = slugify(title);
// If the slugified title is empty (e.g., title only had special characters),
// use "untitled" as a fallback to prevent empty URL segments
if (!slugifiedTitle) {
return `/doc/untitled-${urlId}`;
}
return `/doc/${slugifiedTitle}-${urlId}`;
}
// hooks
@BeforeSave
static async updateCollectionStructure(
model: Document,
{ transaction }: SaveOptions<InferAttributes<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.publishedAt ||
!(
model.changed("title") ||
model.changed("icon") ||
model.changed("color")
) ||
!model.collectionId
) {
return;
}
const collection = await Collection.findByPk(model.collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.NO_KEY_UPDATE,
});
if (!collection) {
return;
}
await collection.updateDocument(model, { transaction });
// Keep a loaded collection so that its membership data stays available.
if (model.collection?.id === collection.id) {
model.collection.documentStructure = collection.documentStructure;
} else {
model.collection = collection;
}
}
@AfterCreate
static async addDocumentToCollectionStructure(model: Document) {
if (model.archivedAt || !model.publishedAt || !model.collectionId) {
return;
}
return this.sequelize!.transaction(async (transaction: Transaction) => {
const collection = await Collection.findByPk(model.collectionId!, {
includeDocumentStructure: true,
transaction,
lock: transaction.LOCK.NO_KEY_UPDATE,
});
if (!collection) {
return;
}
await collection.addDocumentToStructure(model, 0, { transaction });
model.collection = collection;
});
}
@BeforeValidate
static createUrlId(model: Document) {
return (model.urlId = model.urlId || generateUrlId());
}
@BeforeCreate
static setDocumentVersion(model: Document) {
if (model.version === undefined) {
model.version = DOCUMENT_VERSION;
}
return this.processUpdate(model);
}
@BeforeUpdate
static async 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 = await 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"
);
}
}
@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);
}
}
@AfterUpdate
static notifyCollaborationServer(model: Document, ctx: HookContext) {
if (model.changed("state") && ctx.auth?.user?.id) {
const actorId = ctx.auth.user.id;
const notify = async () => {
await APIUpdateExtension.notifyUpdate(model.id, actorId);
};
void afterCommit(ctx.transaction, notify);
}
}
// associations
@BelongsTo(() => FileOperation, "importId")
import: FileOperation | null;
@ForeignKey(() => FileOperation)
@Column(DataType.UUID)
importId: string | null;
@BelongsTo(() => Import, "apiImportId")
apiImport: Import<ImportableIntegrationService> | null;
@ForeignKey(() => Import)
@Column(DataType.UUID)
apiImportId: 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(() => User, "deletedById")
deletedBy: User | null;
/** The user that deleted this document, set automatically on delete. */
@ForeignKey(() => User)
@Column(DataType.UUID)
deletedById: string | null;
@ForeignKey(() => Template)
@Column(DataType.UUID)
templateId: string;
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => Collection, "collectionId")
collection: Collection | null;
@BelongsToMany(() => User, () => UserMembership)
users: User[];
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId?: string | null;
@HasMany(() => UserMembership)
memberships: UserMembership[];
@HasMany(() => GroupMembership, "documentId")
groupMemberships: GroupMembership[];
@HasMany(() => Revision)
revisions: Revision[];
@HasMany(() => Relationship)
relationships: Relationship[];
@HasMany(() => Star)
starred: Star[];
@HasMany(() => View)
views: View[];
@CounterCache(() => Comment, {
as: "unresolvedComments",
foreignKey: "documentId",
where: { resolvedAt: { [Op.is]: null } },
})
commentCount: Promise<number>;
/**
* Returns an array of unique userIds that are members of a document
* either via group or direct membership.
*
* @param documentId
* @param permission optional permission filter
*
* @returns userIds
*/
static async membershipUserIds(
documentId: string,
permission?: DocumentPermission
) {
const document = await this.scope("withAllMemberships").findOne({
where: { id: documentId },
attributes: ["id"],
include: [
{
association: "memberships",
required: false,
...(permission ? { where: { permission } } : {}),
separate: true,
},
{
association: "groupMemberships",
required: false,
...(permission ? { where: { permission } } : {}),
separate: true,
},
],
});
if (!document) {
return [];
}
const groupMemberships = document.groupMemberships
.map((gm) => gm.group.groupUsers)
.flat();
const membershipUserIds = [
...groupMemberships,
...document.memberships,
].map((membership) => membership.userId);
return uniq(membershipUserIds);
}
/**
* Returns an array of unique document IDs that the user is a member of,
* either via direct membership or through a group membership.
*
* The result is cached briefly, mirroring `User.collectionIds`, as it is
* resolved on every search request.
*
* @param userId The user ID to find document memberships for.
* @param options Set `skipCache` to always read through to the database.
* @returns A promise resolving to an array of document IDs.
*/
static async membershipDocumentIds(
userId: string,
options: { skipCache?: boolean } = {}
): Promise<string[]> {
const fetchDocumentIds = async () => {
const [memberships, groupMemberships] = await Promise.all([
UserMembership.findAll({
attributes: ["documentId"],
where: {
userId,
documentId: {
[Op.ne]: null,
},
},
}),
GroupMembership.findAll({
attributes: ["documentId"],
where: {
documentId: {
[Op.ne]: null,
},
},
include: [
{
model: Group,
as: "group",
attributes: [],
required: true,
include: [
{
model: GroupUser,
as: "groupUsers",
attributes: [],
required: true,
where: {
userId,
},
},
],
},
],
}),
]);
return uniq(
[...memberships, ...groupMemberships]
.map((membership) => membership.documentId)
.filter((id): id is string => !isNil(id))
);
};
if (options.skipCache) {
return fetchDocumentIds();
}
return (
(await CacheHelper.getDataOrSet<string[]>(
RedisPrefixHelper.getUserMembershipDocumentIdsKey(userId),
fetchDocumentIds,
Document.membershipDocumentIdsCacheTTL
)) ?? []
);
}
/**
* Invalidates the cached result of `membershipDocumentIds` so a permission
* change takes effect immediately rather than at the end of the cache TTL.
*
* @param userIds The users whose document memberships changed.
*/
static async invalidateMembershipDocumentIds(userIds: string[]) {
await Promise.all(
uniq(userIds).map((userId) =>
CacheHelper.removeData(
RedisPrefixHelper.getUserMembershipDocumentIdsKey(userId)
)
)
);
}
static withMembershipScope(
userId: string,
options?: FindOptions<Document> & { includeDrafts?: boolean }
) {
return this.scope([
options?.includeDrafts ? "withDrafts" : "defaultScope",
"withoutStateOrText",
{
method: ["withViews", userId],
},
{
method: ["withMembership", userId, options?.paranoid],
},
]);
}
/**
* Overrides the standard findByPk behavior to allow also querying by urlId
* and loading memberships for a user passed in by `userId`
*
* @param id uuid or urlId
* @param options FindOptions
* @returns A promise resolving to a document 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") {
if (options.rejectOnEmpty) {
throw options.rejectOnEmpty instanceof Error
? options.rejectOnEmpty
: new EmptyResultError(
`Document doesn't exist with id: ${String(id)}`
);
}
return null;
}
const {
includeViews = true,
includeState = false,
includeContent = true,
userId,
...rest
} = options;
let contentScope = includeState ? "withState" : "withoutState";
if (!includeContent) {
contentScope = "withoutContent";
}
// 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([
"withDrafts",
contentScope,
...((includeViews
? [
{
method: ["withViews", userId],
},
]
: []) as ScopeOptions[]),
{
method: ["withMembership", userId, rest.paranoid],
},
]);
let document: Document | null = null;
const match = id.match(UrlHelper.SLUG_URL_REGEX);
if (isUUID(id)) {
document = await scope.findOne({
...rest,
where: {
id,
template: false,
},
rejectOnEmpty: false,
});
} else if (match) {
document = await scope.findOne({
...rest,
where: {
urlId: match[1],
template: false,
},
rejectOnEmpty: false,
});
}
if (!document && rest.rejectOnEmpty) {
throw rest.rejectOnEmpty instanceof Error
? rest.rejectOnEmpty
: new EmptyResultError(`Document doesn't exist with id: ${id}`);
}
return document;
}
/**
* Find many documents by their id, supports filtering by user memberships when `userId`
* is specified in the options.
*
* @param ids An array of document ids
* @param options FindOptions
* @returns A promise resolving to the list of documents
*/
static async findByIds(
ids: string[],
options: Omit<FindOptions<Document>, "where"> &
Omit<AdditionalFindOptions, "rejectOnEmpty"> = {}
): Promise<Document[]> {
const { userId, includeViews = true, includeState, ...rest } = options;
const user = userId ? await User.findByPk(userId) : null;
const documents = await this.scope([
"withDrafts",
includeState ? "withState" : "withoutState",
...((includeViews
? [
{
method: ["withViews", userId],
},
]
: []) as ScopeOptions[]),
{
method: ["withMembership", userId],
},
]).findAll({
where: {
...(user && { teamId: user.teamId }),
id: ids,
},
...rest,
});
if (!userId) {
return documents;
}
return documents.filter((doc) => {
if (doc.memberships.length > 0 || doc.groupMemberships.length > 0) {
return true;
}
// A document without a collection is either an unfiled draft or lives in
// a collection the user cannot see – access is limited to the creator.
if (!doc.collection) {
return doc.createdById === userId;
}
return (
(!doc.collection.isPrivate && !user?.isGuest) ||
doc.collection.memberships.length > 0 ||
doc.collection.groupMemberships.length > 0
);
});
}
// 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;
}
/**
* Sets the value of the given display preference.
*
* @param preference The document preference to set
* @param value Sets the preference value
* @returns The current document preferences
*/
public setPreference = <T extends keyof DocumentPreferences>(
preference: T,
value: DocumentPreferences[T]
) => {
this.preferences = {
...this.preferences,
[preference]: value,
};
return this.preferences;
};
/**
* Returns the value of the given display preference.
*
* @param preference The document preference to retrieve
* @returns The preference value if set, else the default value
*/
public getPreference = <T extends DocumentPreference>(preference: T) =>
this.preferences?.[preference] ?? DocumentPreferenceDefaults[preference];
/**
* 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 displayTitle(): 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 = async (revision: Revision) => {
if (revision.documentId !== this.id) {
throw new Error("Revision does not belong to this document");
}
this.content = revision.content;
this.text = await DocumentHelper.toMarkdown(revision, {
includeTitle: false,
});
this.title = revision.title;
this.icon = revision.icon;
this.color = revision.color;
};
/**
* 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[]> =>
await User.findAll({
...options,
where: Object.assign({}, options?.where, { id: this.collaboratorIds }),
});
/**
* Find all of the child documents for this document
*
* @param where Omit<WhereOptions<Document>, "parentDocumentId">
* @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 model = this.constructor as typeof Document;
const queryGenerator = this.sequelize!.getQueryInterface()
.queryGenerator as QueryGeneratorWithWhere;
const paranoid = options?.paranoid ?? true;
const whereConditions = queryGenerator.getWhereConditions(
{ ...(paranoid ? { deletedAt: null } : {}), ...where },
"documents",
model
);
const anchorFilter = whereConditions ? `AND (${whereConditions})` : "";
const recursiveFilter = whereConditions ? `WHERE (${whereConditions})` : "";
// A single recursive CTE walks the entire subtree in one round-trip rather
// than issuing one query per level of nesting (N+1). Rows are ordered by
// depth to ensure breadth-first result ordering. UNION ALL is safe as the
// tree is acyclic, so each descendant id is reached exactly once.
const rows = await this.sequelize!.query<{ id: string }>(
`
WITH RECURSIVE children AS (
SELECT documents.id, 1 AS depth
FROM documents
WHERE documents."parentDocumentId" = :parentDocumentId
${anchorFilter}
UNION ALL
SELECT documents.id, children.depth + 1
FROM documents
INNER JOIN children ON documents."parentDocumentId" = children.id
${recursiveFilter}
)
SELECT id FROM children ORDER BY depth
`,
{
replacements: { parentDocumentId: this.id },
transaction: options?.transaction,
type: QueryTypes.SELECT,
}
);
return rows.map((row) => row.id);
};
/**
* Calculate all parent document ids for this document by recursively
* following parentDocumentId references in one query.
*
* @param options the query options.
* @returns a promise that resolves to parent document ids, nearest first.
*/
findAllParentDocumentIds = async (
options?: FindOptions<Document>
): Promise<string[]> => {
if (!this.parentDocumentId) {
return [];
}
const paranoid = options?.paranoid ?? true;
const rows = await this.sequelize!.query<{ id: string }>(
`
WITH RECURSIVE parents AS (
SELECT documents.id, documents."parentDocumentId", 1 AS depth
FROM documents
WHERE documents.id = :parentDocumentId
${paranoid ? 'AND documents."deletedAt" IS NULL' : ""}
UNION ALL
SELECT documents.id, documents."parentDocumentId", parents.depth + 1
FROM documents
INNER JOIN parents ON documents.id = parents."parentDocumentId"
${paranoid ? 'WHERE documents."deletedAt" IS NULL' : ""}
)
SELECT id FROM parents ORDER BY depth
`,
{
replacements: { parentDocumentId: this.parentDocumentId },
transaction: options?.transaction,
type: QueryTypes.SELECT,
}
);
return rows.map((row) => row.id);
};
publish = async (
ctx: APIContext,
{
index = 0,
collectionId,
silent = false,
event = true,
data,
}: {
index?: number;
collectionId: string | null | undefined;
silent?: boolean;
event?: boolean;
data?: Record<string, unknown>;
}
): Promise<this> => {
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
this.lastModifiedById = user.id;
this.updatedBy = user;
// If the document is already published then calling publish should act like
// a regular save
if (this.publishedAt) {
if (event) {
return this.saveWithCtx(ctx, { silent }, { name: "publish", data });
} else {
return this.save({ silent, transaction });
}
}
if (!this.collectionId) {
this.collectionId = collectionId;
}
// A parent outside the destination collection is stale, so publish at the root.
if (this.parentDocumentId && this.collectionId) {
const parentDocument = await Document.unscoped().findByPk(
this.parentDocumentId,
{ attributes: ["collectionId"], transaction }
);
if (parentDocument?.collectionId !== this.collectionId) {
this.parentDocumentId = null;
}
}
if (this.collectionId) {
const collection = await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.NO_KEY_UPDATE,
});
if (collection) {
await collection.addDocumentToStructure(this, index, { transaction });
if (this.collection) {
this.collection.documentStructure = collection.documentStructure;
}
}
}
// Copy the group and user memberships from the parent document, if any
if (this.parentDocumentId) {
await GroupMembership.copy(
{
documentId: this.parentDocumentId,
},
this,
{ transaction }
);
await UserMembership.copy(
{
documentId: this.parentDocumentId,
},
this,
{ transaction }
);
}
this.publishedAt = new Date();
if (event) {
return this.saveWithCtx(ctx, { silent }, { name: "publish", data });
} else {
return this.save({ silent, 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;
};
/**
* Unpublishes a published document, converting it back to a draft.
*
* @param ctx the API context.
* @param options.detach whether to detach the document from the containing collection.
* @returns updated document.
* @throws if the document has child documents.
*/
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();
}
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const childDocumentIds = await this.findAllChildDocumentIds(
{ archivedAt: { [Op.eq]: null } },
{ transaction }
);
if (childDocumentIds.length > 0) {
throw InvalidRequestError(
"Cannot unpublish document with child documents"
);
}
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction?.LOCK.NO_KEY_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
this.createdById = user.id;
this.lastModifiedById = user.id;
this.createdBy = user;
this.updatedBy = user;
this.publishedAt = null;
if (options.detach) {
this.collectionId = null;
this.parentDocumentId = null;
}
// Withdrawing the document also disables its public share links.
const shares = await Share.findAll({
where: {
documentId: this.id,
published: true,
revokedAt: { [Op.is]: null },
},
transaction,
});
for (const share of shares) {
await share.updateWithCtx(ctx, { published: false });
}
// Drafts are excluded from the default scope, so the title cannot be read
// from the document association when listing this event.
return this.saveWithCtx(ctx, undefined, {
name: "unpublish",
data: { title: this.title },
});
};
// Moves a document from being visible to the team within a collection
// to the archived area, where it can be subsequently restored.
archiveWithCtx = async (ctx: APIContext) => {
const { transaction } = ctx.state;
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction?.LOCK.NO_KEY_UPDATE,
})
: undefined;
if (collection) {
await collection.removeDocumentInStructure(this, { transaction });
if (this.collection) {
this.collection.documentStructure = collection.documentStructure;
}
}
await this.archiveWithChildren(ctx);
return this;
};
// Restore an archived document back to being visible to the team
restoreTo = async (
ctx: APIContext,
{ collectionId }: { collectionId: string }
) => {
const { transaction } = ctx.state;
const collection = collectionId
? await Collection.findByPk(collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction?.LOCK.NO_KEY_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,
},
transaction,
});
if (parent?.isDraft || !parent?.isActive) {
this.parentDocumentId = null;
}
}
if (this.publishedAt && collection?.isActive) {
await collection.addDocumentToStructure(this, undefined, {
includeArchived: true,
transaction,
});
}
if (this.deletedAt) {
await this.restore({ transaction });
this.deprecatedReason = null;
this.changed("deprecatedReason", true);
this.collectionId = collectionId;
await this.saveWithCtx(ctx, undefined, { name: "restore" });
}
if (this.archivedAt) {
await this.restoreArchivedWithChildren(ctx, { collectionId });
}
if (this.collection && collection) {
// updating the document structure in memory just in case it's later accessed somewhere
this.collection.documentStructure = collection.documentStructure;
}
return this;
};
// Delete a document, archived or otherwise.
destroyWithCtx = async (
ctx: APIContext,
eventOpts?: EventOverrideOptions
): Promise<void> => {
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
let deleted = false;
if (this.collectionId) {
const collection = await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction?.LOCK.NO_KEY_UPDATE,
paranoid: false,
});
if (!this.archivedAt || (this.archivedAt && collection?.archivedAt)) {
await collection?.deleteDocument(ctx, this);
deleted = true;
}
}
if (!deleted) {
await this.destroy(ctx.context);
}
this.lastModifiedById = user.id;
this.updatedBy = user;
await this.saveWithCtx(ctx, undefined, { name: "delete", ...eventOpts });
};
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> & { includeArchived?: boolean }
): Promise<NavigationNode> => {
const root = this.toShallowNavigationNode();
// Checking if the record is new is a performance optimization – new docs cannot have children
if (this.isNewRecord) {
return root;
}
// Load the subtree one level at a time, selecting only the columns that
// the navigation node needs.
const visited = new Set([this.id]);
let parents = new Map([[this.id, root]]);
while (parents.size > 0) {
const childDocuments = await (this.constructor as typeof Document)
.unscoped()
.findAll({
attributes: [
"id",
"title",
"urlId",
"icon",
"color",
"parentDocumentId",
],
where: {
teamId: this.teamId,
parentDocumentId: Array.from(parents.keys()),
publishedAt: {
[Op.ne]: null,
},
...(options?.includeArchived
? {}
: {
archivedAt: {
[Op.is]: null,
},
}),
},
transaction: options?.transaction,
});
const nextParents = new Map<string, NavigationNode>();
for (const child of childDocuments) {
if (visited.has(child.id) || !child.parentDocumentId) {
continue;
}
visited.add(child.id);
const node = child.toShallowNavigationNode();
parents.get(child.parentDocumentId)?.children.push(node);
nextParents.set(child.id, node);
}
parents = nextParents;
}
return root;
};
/**
* Returns a NavigationNode for this document without loading its children.
*
* @param children the child nodes to include.
* @returns the NavigationNode.
*/
toShallowNavigationNode = (
children: NavigationNode[] = []
): NavigationNode => ({
id: this.id,
title: this.title,
url: this.url,
icon: isNil(this.icon) ? undefined : this.icon,
color: isNil(this.color) ? undefined : this.color,
children,
});
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
).findAll({
where: {
parentDocumentId,
},
transaction,
});
for (const child of childDocuments) {
await restoreChildren(child.id);
child.archivedAt = null;
child.deprecatedReason = null;
child.changed("deprecatedReason", true);
child.lastModifiedById = user.id;
child.updatedBy = user;
child.collectionId = collectionId;
await child.save({ transaction });
}
};
await restoreChildren(this.id);
this.archivedAt = null;
this.deprecatedReason = null;
this.changed("deprecatedReason", true);
this.lastModifiedById = user.id;
this.updatedBy = user;
this.collectionId = collectionId;
return this.saveWithCtx(ctx, undefined, { name: "unarchive" });
};
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
const archiveChildren = async (parentDocumentId: string) => {
const childDocuments = await (
this.constructor as typeof Document
).findAll({
where: {
parentDocumentId,
},
transaction,
});
for (const child of childDocuments) {
await archiveChildren(child.id);
child.archivedAt = archivedAt;
child.lastModifiedById = user.id;
child.updatedBy = user;
await child.save({ transaction });
}
};
await archiveChildren(this.id);
this.archivedAt = archivedAt;
this.lastModifiedById = user.id;
this.updatedBy = user;
return this.saveWithCtx(ctx, undefined, { name: "archive" });
};
}
export default Document;