Ctrl K
ComunidadGuías…

server/models/Collection.ts

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

Archivo original ↗Preguntar a KiBot
import fractionalIndex from "fractional-index";
import { find, findIndex, isNil, keyBy, remove, uniq } from "es-toolkit/compat";
import type {
  Identifier,
  Transaction,
  DestroyOptions,
  FindOptions,
  NonNullFindOptions,
  InferAttributes,
  InferCreationAttributes,
} from "sequelize";
import {
  EmptyResultError,
  type CreateOptions,
  type UpdateOptions,
  type ScopeOptions,
  type SaveOptions,
  Op,
} from "sequelize";
import {
  Sequelize,
  Table,
  Column,
  Unique,
  IsIn,
  Default,
  BeforeValidate,
  BeforeSave,
  AfterCreate,
  HasMany,
  BelongsToMany,
  BelongsTo,
  ForeignKey,
  Scopes,
  DataType,
  Length as SimpleLength,
  BeforeDestroy,
  IsDate,
  AllowNull,
  BeforeCreate,
  BeforeUpdate,
  DefaultScope,
  AfterSave,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type {
  CollectionSort,
  ImportableIntegrationService,
  ProsemirrorData,
  SourceMetadata,
  NavigationNode,
} from "@shared/types";
import { CollectionPermission, NavigationNodeType } from "@shared/types";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { sortNavigationNodes } from "@shared/utils/collections";
import slugify from "@server/utils/slugify";
import {
  CollectionValidation,
  DeprecationValidation,
} from "@shared/validations";
import { parser } from "@server/editor";
import { ValidationError } from "@server/errors";
import type { APIContext } from "@server/types";
import { LockHelper } from "@server/storage/LockHelper";
import { afterCommit } from "@server/storage/transaction";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import removeIndexCollision from "@server/utils/removeIndexCollision";
import { generateUrlId } from "@server/utils/url";
import { ValidateIndex } from "@server/validation";
import Document from "./Document";
import FileOperation from "./FileOperation";
import Group from "./Group";
import GroupMembership from "./GroupMembership";
import GroupUser from "./GroupUser";
import Import from "./Import";
import Team from "./Team";
import User from "./User";
import UserMembership from "./UserMembership";
import ParanoidModel from "./base/ParanoidModel";
import { DocumentHelper } from "./helpers/DocumentHelper";
import IsHexColor from "./validators/IsHexColor";
import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";

type AdditionalFindOptions = {
  userId?: string;
  includeDocumentStructure?: boolean;
  includeOwner?: boolean;
  includeArchivedBy?: boolean;
  rejectOnEmpty?: boolean | Error;
};

@DefaultScope(() => ({
  attributes: {
    exclude: ["documentStructure"],
  },
}))
@Scopes(() => ({
  withAllMemberships: {
    include: [
      {
        model: UserMembership,
        as: "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:
        // GroupMembership [inner join] Group [inner join] GroupUser [where] userId
        include: [
          {
            model: Group,
            as: "group",
            required: true,
            include: [
              {
                model: GroupUser,
                as: "groupUsers",
                required: true,
              },
            ],
          },
        ],
      },
    ],
  },
  withUser: () => ({
    include: [
      {
        model: User,
        required: true,
        as: "user",
      },
    ],
  }),
  withArchivedBy: () => ({
    include: [
      {
        association: "archivedBy",
      },
    ],
  }),
  withDocumentStructure: () => ({
    attributes: {
      // resets to include the documentStructure column
      exclude: [],
    },
  }),
  withMembership: (userId: string) => {
    if (!userId) {
      return {};
    }

    return {
      include: [
        {
          association: "memberships",
          where: {
            userId,
          },
          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,
                  where: {
                    userId,
                  },
                },
              ],
            },
          ],
        },
      ],
    };
  },
}))
@Table({ tableName: "collections", modelName: "collection" })
class Collection extends ParanoidModel<
  InferAttributes<Collection>,
  Partial<InferCreationAttributes<Collection>>
> {
  @SimpleLength({
    min: 10,
    max: 10,
    msg: `urlId must be 10 characters`,
  })
  @Unique
  @Column(DataType.STRING)
  urlId: string;

  @NotContainsUrl
  @Length({
    max: CollectionValidation.maxNameLength,
    msg: `name must be ${CollectionValidation.maxNameLength} characters or less`,
  })
  @Column(DataType.STRING)
  name: string;

  /**
   * The content of the collection as Markdown.
   *
   * @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if exporting lossy markdown.
   * This column will be removed in a future migration.
   */
  @Length({
    max: CollectionValidation.maxDescriptionLength,
    msg: `description must be ${CollectionValidation.maxDescriptionLength} characters or less`,
  })
  @Column(DataType.STRING)
  description: string | null;

  /** The reason this collection is archived. */
  @Length({ max: DeprecationValidation.maxReasonLength })
  @Column(DataType.TEXT)
  deprecatedReason: string | null;

  /**
   * The content of the collection as JSON, this is a snapshot at the last time the state was saved.
   */
  @Column(DataType.JSONB)
  content: ProsemirrorData | null;

  /** An icon (or) emoji to use as the collection icon. */
  @Column(DataType.STRING)
  icon: string | null;

  /** The color of the icon. */
  @IsHexColor
  @Column(DataType.STRING)
  color: string | null;

  @Length({
    max: ValidateIndex.maxLength,
    msg: `index must be ${ValidateIndex.maxLength} characters or less`,
  })
  @Column(DataType.STRING)
  index: string | null;

  @IsIn([Object.values(CollectionPermission)])
  @Column(DataType.STRING)
  permission: CollectionPermission | null;

  @Default(false)
  @Column(DataType.BOOLEAN)
  maintainerApprovalRequired: boolean;

  @Default(null)
  @Column(DataType.JSONB)
  documentStructure: NavigationNode[] | null;

  @Default(true)
  @Column(DataType.BOOLEAN)
  sharing: boolean;

  @Default({ field: "title", direction: "asc" })
  @Column({
    type: DataType.JSONB,
    validate: {
      isSort(value: CollectionSort) {
        if (
          typeof value !== "object" ||
          !value.direction ||
          !value.field ||
          Object.keys(value).length !== 2
        ) {
          throw new Error("Sort must be an object with field,direction");
        }

        if (!["asc", "desc"].includes(value.direction)) {
          throw new Error("Sort direction must be one of asc,desc");
        }

        if (!["title", "index"].includes(value.field)) {
          throw new Error("Sort field must be one of title,index");
        }
      },
    },
  })
  sort: CollectionSort;

  /** Whether the collection is archived, and if so when. */
  @IsDate
  @Column(DataType.DATE)
  archivedAt: Date | null;

  /** The minimum permission level required to manage templates in this collection. */
  @IsIn([[CollectionPermission.Admin, CollectionPermission.ReadWrite]])
  @Default(CollectionPermission.Admin)
  @Column(DataType.STRING)
  templateManagement: CollectionPermission;

  /** Allows the configuration of commenting per collection. */
  @AllowNull(true)
  @Default(null)
  @Column(DataType.BOOLEAN)
  commenting: boolean | null;

  @AllowNull
  @Column(DataType.JSONB)
  sourceMetadata: SourceMetadata | null;

  // getters

  /** The frontend path to this collection. */
  get path(): string {
    return Collection.getPath({
      name: this.name,
      urlId: this.urlId,
    });
  }

  /**
   * Returns the frontend path for a collection.
   *
   * @param name The collection name.
   * @param urlId The collection URL ID.
   * @returns the frontend path for the collection.
   */
  static getPath({ name, urlId }: { name: string; urlId: string }): string {
    if (!name) {
      return `/collection/untitled-${urlId}`;
    }
    const slugifiedName = slugify(name);
    if (!slugifiedName) {
      return `/collection/untitled-${urlId}`;
    }
    return `/collection/${slugifiedName}-${urlId}`;
  }

  /**
   * Whether this collection is considered active or not. A collection is active if
   * it has not been archived or deleted.
   *
   * @returns boolean
   */
  get isActive(): boolean {
    return !this.archivedAt && !this.deletedAt;
  }

  // hooks

  @BeforeValidate
  static async onBeforeValidate(model: Collection) {
    model.urlId = model.urlId || generateUrlId();
  }

  @BeforeSave
  static async onBeforeSave(model: Collection) {
    const descriptionChanged = model.changed("description");
    const contentChanged = model.changed("content");

    if (descriptionChanged && !contentChanged) {
      model.content = model.description
        ? (parser.parse(model.description)?.toJSON() ?? null)
        : null;
    } else if (contentChanged && !descriptionChanged) {
      model.description = model.content
        ? await DocumentHelper.toMarkdown(model, { includeTitle: false })
        : null;
    } else if (!model.content) {
      model.content = await DocumentHelper.toJSON(model);
    }
  }

  @AfterSave
  static async cacheDocumentStructure(
    model: Collection,
    options: SaveOptions<Collection>
  ) {
    if (model.changed("documentStructure")) {
      const setData = () =>
        CacheHelper.setData(
          RedisPrefixHelper.getCollectionDocumentsKey(model.id),
          model.documentStructure,
          Collection.DOCUMENT_STRUCTURE_CACHE_TTL
        );

      await afterCommit(options.transaction, setData);
    }
  }

  @BeforeDestroy
  static async checkLastCollection(model: Collection, options: DestroyOptions) {
    await LockHelper.acquire(
      model.sequelize,
      `collections:${model.teamId}`,
      options.transaction
    );

    const total = await this.count({
      where: {
        teamId: model.teamId,
        ownerId: null,
      },
      transaction: options.transaction,
    });
    if (total === 1) {
      throw ValidationError("Cannot delete last collection");
    }
  }

  @BeforeDestroy
  static async deleteDocuments(model: Collection, ctx: APIContext["context"]) {
    // A bulk update rather than a destroy per document, so `deletedById` is
    // written here instead of by the hook on ParanoidModel.
    await Document.update(
      {
        lastModifiedById: ctx.auth.user.id,
        deletedById: ctx.auth.user.id,
        deletedAt: new Date(),
      },
      {
        transaction: ctx.transaction,
        where: {
          teamId: model.teamId,
          collectionId: model.id,
          archivedAt: {
            [Op.is]: null,
          },
        },
      }
    );
  }

  @BeforeCreate
  static async setIndex(model: Collection, options: CreateOptions<Collection>) {
    if (model.index) {
      model.index = await removeIndexCollision(model.teamId, model.index, {
        transaction: options.transaction,
      });
      return;
    }

    const firstCollectionForTeam = await this.findOne({
      where: {
        teamId: model.teamId,
      },
      order: [
        // using LC_COLLATE:"C" because we need byte order to drive the sorting
        Sequelize.literal('"collection"."index" collate "C"'),
        ["updatedAt", "DESC"],
      ],
      transaction: options.transaction,
    });

    model.index = fractionalIndex(null, firstCollectionForTeam?.index ?? null);
  }

  @AfterCreate
  static async onAfterCreate(
    model: Collection,
    options: { transaction: Transaction }
  ) {
    const existing = await UserMembership.findOne({
      where: {
        collectionId: model.id,
        userId: model.createdById,
      },
      transaction: options.transaction,
    });

    if (!existing) {
      return UserMembership.create(
        {
          collectionId: model.id,
          userId: model.createdById,
          permission: CollectionPermission.Admin,
          createdById: model.createdById,
        },
        {
          transaction: options.transaction,
          hooks: false,
        }
      );
    }

    return existing;
  }

  @BeforeUpdate
  static checkPersonal(model: Collection) {
    if (!model.ownerId) {
      return;
    }
    if (model.changed("name")) {
      throw ValidationError("A personal collection cannot be renamed");
    }
    if (model.changed("permission") && model.permission) {
      throw ValidationError("A personal collection must stay private");
    }
  }

  @BeforeUpdate
  static async checkIndex(
    model: Collection,
    options: UpdateOptions<Collection>
  ) {
    if (
      (model.index && model.changed("index")) ||
      (!model.archivedAt && model.changed("archivedAt"))
    ) {
      model.index = await removeIndexCollision(model.teamId, model.index!, {
        transaction: options.transaction,
      });
    }
  }

  @BeforeUpdate
  static async publishPermissionChangedEvent(
    model: Collection,
    ctx: APIContext["context"]
  ) {
    const privacyChanged = model.previous("permission") !== model.permission;
    const sharingChanged = model.previous("sharing") !== model.sharing;

    if (privacyChanged || sharingChanged) {
      await this.insertEvent("permission_changed", model, {
        ...ctx,
        event: { publish: true },
      });
    }
  }

  // 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;

  @BelongsTo(() => User, "archivedById")
  archivedBy?: User | null;

  @AllowNull
  @ForeignKey(() => User)
  @Column(DataType.UUID)
  archivedById?: string | null;

  /** The user that owns this collection, if it is a personal collection. */
  @BelongsTo(() => User, "ownerId")
  owner?: User | null;

  @AllowNull
  @ForeignKey(() => User)
  @Column(DataType.UUID)
  ownerId: string | null;

  @HasMany(() => Document, "collectionId")
  documents: Document[];

  @HasMany(() => UserMembership, "collectionId")
  memberships: UserMembership[];

  @HasMany(() => GroupMembership, "collectionId")
  groupMemberships: GroupMembership[];

  @BelongsToMany(() => User, () => UserMembership)
  users: User[];

  @BelongsToMany(() => Group, () => GroupMembership)
  groups: Group[];

  @BelongsTo(() => User, "createdById")
  user: User;

  @ForeignKey(() => User)
  @Column(DataType.UUID)
  createdById: string;

  @BelongsTo(() => Team, "teamId")
  team: Team;

  @ForeignKey(() => Team)
  @Column(DataType.UUID)
  teamId: string;

  static DEFAULT_SORT: { field: "title" | "index"; direction: "asc" | "desc" } =
    {
      field: "index",
      direction: "asc",
    };

  static DOCUMENT_STRUCTURE_CACHE_TTL = 60;

  /**
   * Returns an array of unique userIds that are members of a collection,
   * either via group or direct membership.
   *
   * @param collectionId
   * @param permission optional permission filter
   *
   * @returns userIds
   */
  static async membershipUserIds(
    collectionId: string,
    permission?: CollectionPermission
  ) {
    const collection = await this.scope("withAllMemberships").findOne({
      where: { id: collectionId },
      attributes: ["id"],
      include: [
        {
          association: "memberships",
          required: false,
          ...(permission ? { where: { permission } } : {}),
          separate: true,
        },
        {
          association: "groupMemberships",
          required: false,
          ...(permission ? { where: { permission } } : {}),
          separate: true,
        },
      ],
    });

    if (!collection) {
      return [];
    }

    const groupMemberships = collection.groupMemberships
      .map((gm) => gm.group.groupUsers)
      .flat();
    const membershipUserIds = [
      ...groupMemberships,
      ...collection.memberships,
    ].map((membership) => membership.userId);
    return uniq(membershipUserIds);
  }

  /**
   * 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 collection instance or null
   */
  static async findByPk(
    id: Identifier,
    options?: NonNullFindOptions<Collection> & AdditionalFindOptions
  ): Promise<Collection>;
  static async findByPk(
    id: Identifier,
    options?: FindOptions<Collection> & AdditionalFindOptions
  ): Promise<Collection | null>;
  static async findByPk(
    id: Identifier,
    options: FindOptions<Collection> & AdditionalFindOptions = {}
  ): Promise<Collection | null> {
    if (typeof id !== "string") {
      if (options.rejectOnEmpty) {
        throw options.rejectOnEmpty instanceof Error
          ? options.rejectOnEmpty
          : new EmptyResultError(
              `Collection doesn't exist with id: ${String(id)}`
            );
      }
      return null;
    }

    const {
      includeDocumentStructure,
      includeOwner,
      includeArchivedBy,
      userId,
      ...rest
    } = options;

    const scopes: (string | ScopeOptions)[] = [
      includeDocumentStructure ? "withDocumentStructure" : "defaultScope",
      {
        method: ["withMembership", userId],
      },
    ];

    if (includeOwner) {
      scopes.push("withUser");
    }
    if (includeArchivedBy) {
      scopes.push("withArchivedBy");
    }

    const scope = this.scope(scopes);

    let collection: Collection | null = null;
    const match = id.match(UrlHelper.SLUG_URL_REGEX);

    if (isUUID(id)) {
      collection = await scope.findOne({
        ...rest,
        where: {
          id,
        },
        rejectOnEmpty: false,
      });
    } else if (match) {
      collection = await scope.findOne({
        ...rest,
        where: {
          urlId: match[1],
        },
        rejectOnEmpty: false,
      });
    }

    if (!collection && rest.rejectOnEmpty) {
      throw rest.rejectOnEmpty instanceof Error
        ? rest.rejectOnEmpty
        : new EmptyResultError(`Collection doesn't exist with id: ${id}`);
    }

    return collection;
  }

  /**
   * Find the first collection that the specified user has access to.
   *
   * @param user User to find the collection for
   * @param options Additional options for the query
   * @returns collection First collection in the sidebar order
   */
  static async findFirstCollectionForUser(
    user: User,
    options: FindOptions = {}
  ) {
    const id = await user.collectionIds({ transaction: options.transaction });
    return this.findOne({
      where: {
        teamId: user.teamId,
        ownerId: null,
        id,
      },
      order: [
        // using LC_COLLATE:"C" because we need byte order to drive the sorting
        Sequelize.literal('"collection"."index" collate "C"'),
        ["updatedAt", "DESC"],
      ],
      ...options,
    });
  }

  /**
   * Find the personal collection of a user, with the memberships of the user
   * loaded.
   *
   * @param user The user that owns the collection.
   * @param options Additional options for the query.
   * @returns the personal collection, or null if the user has none.
   */
  static async findPersonal(
    user: User,
    options: Omit<FindOptions<Collection>, "where"> = {}
  ) {
    return this.scope([
      "defaultScope",
      { method: ["withMembership", user.id] },
    ]).findOne({
      ...options,
      where: {
        teamId: user.teamId,
        ownerId: user.id,
      },
    });
  }

  /**
   * Find the personal collection of the user in the context, and create it if
   * the user does not have one yet. The caller must authorize the creation.
   *
   * @param ctx The request context with the acting user and a transaction.
   * @returns the personal collection of the user.
   */
  static async findOrCreatePersonal(ctx: APIContext): Promise<Collection> {
    const { user } = ctx.state.auth;
    const { transaction } = ctx.state;

    const existing = await this.findPersonal(user, { transaction });
    if (existing) {
      return existing;
    }

    await LockHelper.acquire(
      this.sequelize!,
      `collections:personal:${user.teamId}:${user.id}`,
      transaction
    );

    const locked = await this.findPersonal(user, { transaction });
    if (locked) {
      return locked;
    }

    const collection = await this.createWithCtx(ctx, {
      name: "Personal",
      teamId: user.teamId,
      createdById: user.id,
      ownerId: user.id,
      permission: null,
    });

    // The owner membership is created without hooks, so clear the cache here.
    await afterCommit(transaction, () =>
      CacheHelper.removeData(RedisPrefixHelper.getUserCollectionIdsKey(user.id))
    );

    return this.findByPk(collection.id, {
      userId: user.id,
      transaction,
      rejectOnEmpty: true,
    });
  }

  /**
   * Convenience method to return if a collection is considered private.
   * This means that a membership is required to view it rather than just being
   * a workspace member.
   *
   * @returns boolean
   */
  get isPrivate() {
    return !this.permission;
  }

  /**
   * Whether this collection is the personal collection of a user. A personal
   * collection is private and only its owner can see or manage it.
   *
   * @returns boolean
   */
  get isPersonal() {
    return !!this.ownerId;
  }

  /**
   * Returns the collection's documentStructure via cache, populating it on
   * miss. The cache is kept fresh by this model's save hooks, so callers
   * should prefer this over re-fetching the column directly.
   *
   * @returns the cached documentStructure, or null when the collection has none.
   */
  getCachedDocumentStructure = async (): Promise<NavigationNode[] | null> => {
    const result = await CacheHelper.getDataOrSet<NavigationNode[] | null>(
      RedisPrefixHelper.getCollectionDocumentsKey(this.id),
      async () =>
        (
          await (this.constructor as typeof Collection).findByPk(this.id, {
            attributes: ["documentStructure"],
            includeDocumentStructure: true,
            rejectOnEmpty: true,
          })
        ).documentStructure,
      Collection.DOCUMENT_STRUCTURE_CACHE_TTL
    );
    return result ?? null;
  };

  getDocumentTree = (documentId: string): NavigationNode | null => {
    if (!this.documentStructure) {
      return null;
    }

    let result!: NavigationNode | undefined;

    const loopChildren = (documents: NavigationNode[]) => {
      if (result) {
        return;
      }

      documents.forEach((document) => {
        if (result) {
          return;
        }

        if (document.id === documentId) {
          result = document;
        } else {
          loopChildren(document.children);
        }
      });
    };

    // Technically, sorting the children is presenter-layer work...
    // but the only place it's used passes straight into an API response
    // so the extra indirection is not worthwhile
    loopChildren(this.documentStructure);

    // if the document is a draft loopChildren will not find it in the structure
    if (!result) {
      return null;
    }

    return {
      ...result,
      children: sortNavigationNodes(result.children, this.sort),
    };
  };

  /**
   * Archives the collection and all of its non-archived documents. The
   * collection's `archivedAt` and `archivedBy` are set to now and the acting
   * user respectively, and child documents inherit the same `archivedAt`.
   *
   * @param ctx - the API context, including the acting user and optional transaction.
   * @returns the updated collection.
   */
  archiveWithCtx = async (ctx: APIContext) => {
    const { transaction } = ctx.state;
    const { user } = ctx.state.auth;

    this.archivedAt = new Date();
    this.archivedById = user.id;
    this.archivedBy = user;

    await this.saveWithCtx(ctx, undefined, { name: "archive" });

    await Document.update(
      {
        lastModifiedById: user.id,
        archivedAt: this.archivedAt,
      },
      {
        where: {
          teamId: this.teamId,
          collectionId: this.id,
          archivedAt: { [Op.is]: null },
        },
        transaction,
      }
    );

    return this;
  };

  /**
   * Updates the reason for archiving the collection.
   *
   * @param ctx the API context, including the acting user and transaction.
   * @param reason the reason to save, or null to clear it.
   * @returns the updated collection.
   * @throws ValidationError if the collection is no longer archived.
   */
  updateDeprecatedReason = async (ctx: APIContext, reason: string | null) => {
    const { transaction } = ctx.state;

    if (transaction) {
      await Collection.unscoped().findOne({
        attributes: ["id"],
        where: { id: this.id },
        transaction,
        lock: transaction.LOCK.UPDATE,
        rejectOnEmpty: true,
      });
    }

    await this.reload({ transaction });
    if (!this.archivedAt || this.deletedAt) {
      throw ValidationError("The collection must be archived");
    }

    this.deprecatedReason = reason?.trim() || null;
    if (this.changed("deprecatedReason")) {
      await this.saveWithCtx(ctx, { silent: true });
    }
    return this;
  };

  /**
   * Removes a document from this collection's structure and soft deletes it
   * along with all of its descendants.
   *
   * @param ctx the API context, which attributes the deletion to the acting user.
   * @param document the document to delete.
   */
  deleteDocument = async (ctx: APIContext, document: Document) => {
    const { transaction } = ctx.context;

    await this.removeDocumentInStructure(document, { transaction });

    // IDs come back breadth-first so reversing them destroys the deepest
    // descendants first.
    const childDocumentIds = (
      await document.findAllChildDocumentIds(undefined, { transaction })
    ).reverse();

    if (childDocumentIds.length) {
      const childDocuments = await Document.findAll({
        transaction,
        where: {
          id: childDocumentIds,
        },
      });
      const childDocumentsById = keyBy(childDocuments, (child) => child.id);

      // Destroyed one at a time to ensure model hooks run for each document.
      for (const childDocumentId of childDocumentIds) {
        await childDocumentsById[childDocumentId]?.destroy(ctx.context);
      }
    }

    await document.destroy(ctx.context);
  };

  removeDocumentInStructure = async (
    document: Document,
    options?: FindOptions & {
      save?: boolean;
    }
  ) => {
    if (!this.documentStructure) {
      return;
    }

    let result: [NavigationNode, number] | undefined;

    const removeFromChildren = async (
      children: NavigationNode[],
      id: string
    ) => {
      children = await Promise.all(
        children.map(async (childDocument) => ({
          ...childDocument,
          children: await removeFromChildren(childDocument.children, id),
        }))
      );
      const match = find(children, {
        id,
      });

      if (match) {
        if (!result) {
          result = [
            match,
            findIndex(children, {
              id,
            }),
          ];
        }

        remove(children, {
          id,
        });
      }

      return children;
    };

    this.documentStructure = await removeFromChildren(
      this.documentStructure,
      document.id
    );

    // Sequelize doesn't seem to set the value with splice on JSONB field
    // https://github.com/sequelize/sequelize/blob/e1446837196c07b8ff0c23359b958d68af40fd6d/src/model.js#L3937
    this.changed("documentStructure", true);

    if (options?.save !== false) {
      await this.save({
        ...options,
        fields: ["documentStructure"],
      });
    }

    return result;
  };

  getDocumentParents = (documentId: string): string[] | void => {
    let result!: string[];

    const loopChildren = (documents: NavigationNode[], path: string[] = []) => {
      if (result) {
        return;
      }

      documents.forEach((document) => {
        if (document.id === documentId) {
          result = path;
        } else {
          loopChildren(document.children, [...path, document.id]);
        }
      });
    };

    if (this.documentStructure) {
      loopChildren(this.documentStructure);
    }

    return result;
  };

  /**
   * Update document's title and url in the documentStructure
   */
  updateDocument = async (
    updatedDocument: Document,
    options?: { transaction?: Transaction | null | undefined }
  ) => {
    if (!this.documentStructure) {
      return;
    }

    const { id } = updatedDocument;

    const updateChildren = (documents: NavigationNode[]): NavigationNode[] =>
      documents.map((document) => {
        if (document.id === id) {
          return updatedDocument.toShallowNavigationNode(document.children);
        }

        document.children = updateChildren(document.children);
        return document;
      });

    this.documentStructure = updateChildren(this.documentStructure);
    // Sequelize doesn't seem to set the value with splice on JSONB field
    // https://github.com/sequelize/sequelize/blob/e1446837196c07b8ff0c23359b958d68af40fd6d/src/model.js#L3937
    this.changed("documentStructure", true);
    await this.save({
      fields: ["documentStructure"],
      ...options,
    });

    return this;
  };

  addDocumentToStructure = async (
    document: Document,
    index?: number,
    options: FindOptions & {
      save?: boolean;
      silent?: boolean;
      documentJson?: NavigationNode;
      includeArchived?: boolean;
      insertOrder?: "prepend" | "append";
    } = {}
  ) => {
    if (!this.documentStructure) {
      this.documentStructure = [];
    }

    if (this.getDocumentTree(document.id)) {
      return this;
    }

    // If moving existing document with children, use existing structure
    const documentJson =
      options.documentJson ?? (await document.toNavigationNode(options));

    // Determine the insertion index based on order parameter or explicit index
    let insertionIndex: number;

    if (index !== undefined) {
      // Explicit index takes precedence
      insertionIndex = index;
    } else if (options.insertOrder === "prepend") {
      // Prepend to the beginning
      insertionIndex = 0;
    } else {
      // Default behavior: append to the end (maintains backward compatibility)
      insertionIndex = this.documentStructure.length;
    }

    if (!document.parentDocumentId) {
      // Note: Index is supported on DB level but it's being ignored
      // by the API presentation until we build product support for it.
      this.documentStructure.splice(insertionIndex, 0, documentJson);
    } else {
      // Recursively place document
      const placeDocument = (documentList: NavigationNode[]) =>
        documentList.map((childDocument) => {
          if (document.parentDocumentId === childDocument.id) {
            const childInsertionIndex =
              index !== undefined
                ? index
                : options.insertOrder === "prepend"
                  ? 0
                  : childDocument.children.length;
            childDocument.children.splice(childInsertionIndex, 0, documentJson);
          } else {
            childDocument.children = placeDocument(childDocument.children);
          }

          return childDocument;
        });

      this.documentStructure = placeDocument(this.documentStructure);
    }

    // Sequelize doesn't seem to set the value with splice on JSONB field
    // https://github.com/sequelize/sequelize/blob/e1446837196c07b8ff0c23359b958d68af40fd6d/src/model.js#L3937
    this.changed("documentStructure", true);

    if (options?.save !== false) {
      await this.save({
        ...options,
        fields: ["documentStructure"],
      });
    }

    return this;
  };

  /**
   * Get all of the document ids that are in this collection by
   * recursively iterating through `documentStructure`.
   *
   * @returns list of document ids
   */
  getAllDocumentIds = (): string[] => {
    if (!this.documentStructure) {
      return [];
    }

    const allDocumentIds: string[] = [];

    const loopChildren = (node: NavigationNode) => {
      allDocumentIds.push(node.id);
      (node.children ?? []).forEach((childNode) => {
        loopChildren(childNode);
      });
    };

    this.documentStructure.forEach(loopChildren);
    return allDocumentIds;
  };

  /**
   * Returns a JSON representation of this collection suitable for use in the frontend navigation.
   *
   * @returns NavigationNode
   */
  toNavigationNode = (): NavigationNode => ({
    id: this.id,
    title: this.name,
    url: this.path,
    type: NavigationNodeType.Collection,
    icon: isNil(this.icon) ? undefined : this.icon,
    color: isNil(this.color) ? undefined : this.color,
    children: sortNavigationNodes(this.documentStructure ?? [], this.sort),
  });
}

export default Collection;