Ctrl K
ComunidadGuías…

server/models/Document.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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;