Ctrl K
ComunidadGuías…

server/models/Revision.ts

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

Archivo original ↗Preguntar a KiBot
import type { InferAttributes, InferCreationAttributes } from "sequelize";
import { sortBy } from "es-toolkit/compat";
import { Op } from "sequelize";
import { isUUID } from "validator";
import {
  AllowNull,
  DataType,
  BelongsTo,
  Column,
  DefaultScope,
  ForeignKey,
  Table,
  IsNumeric,
  Length as SimpleLength,
  BeforeDestroy,
} from "sequelize-typescript";
import type { ProsemirrorData, SourceMetadata } from "@shared/types";
import { DocumentValidation, RevisionValidation } from "@shared/validations";
import type { APIContext } from "@server/types";
import Document from "./Document";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import IsHexColor from "./validators/IsHexColor";
import Length from "./validators/Length";
import { SkipChangeset } from "./decorators/Changeset";

@DefaultScope(() => ({
  include: [
    {
      model: User,
      as: "user",
      paranoid: false,
    },
  ],
}))
@Table({ tableName: "revisions", modelName: "revision" })
class Revision extends ParanoidModel<
  InferAttributes<Revision>,
  Partial<InferCreationAttributes<Revision>>
> {
  @IsNumeric
  @Column(DataType.SMALLINT)
  @SkipChangeset
  version?: number | null;

  /** The editor version at the time of the revision */
  @SimpleLength({
    max: 255,
    msg: `editorVersion must be 255 characters or less`,
  })
  @Column(DataType.STRING)
  @SkipChangeset
  editorVersion: string | null;

  /** The document title at the time of the revision */
  @Length({
    max: DocumentValidation.maxTitleLength,
    msg: `Revision title must be ${DocumentValidation.maxTitleLength} characters or less`,
  })
  @Column(DataType.STRING)
  @SkipChangeset
  title: string;

  /** An optional name for the revision */
  @Length({
    max: RevisionValidation.maxNameLength,
    msg: `Revision name must be ${RevisionValidation.maxNameLength} characters or less`,
  })
  @Column(DataType.STRING)
  @SkipChangeset
  name: string | null;

  /**
   * The content of the revision as Markdown.
   *
   * @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if
   * exporting lossy markdown. This column will be removed in a future migration
   * and is no longer being written.
   */
  @Column(DataType.TEXT)
  @SkipChangeset
  text: string | null;

  /** The content of the revision as JSON. */
  @Column(DataType.JSONB)
  @SkipChangeset
  content: ProsemirrorData | null;

  /** The icon at the time of the revision. */
  @Column(DataType.STRING)
  @SkipChangeset
  icon: string | null;

  /** The color at the time of the revision. */
  @IsHexColor
  @Column(DataType.STRING)
  @SkipChangeset
  color: string | null;

  /** Metadata about how the revision came to be, such as the authentication type used. */
  @AllowNull
  @Column(DataType.JSONB)
  @SkipChangeset
  sourceMetadata: SourceMetadata | null;

  // associations

  @BelongsTo(() => Document, "documentId")
  document: Document;

  @ForeignKey(() => Document)
  @Column(DataType.UUID)
  documentId: string;

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

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

  /** Array of user IDs who collaborated on this revision */
  @Column(DataType.ARRAY(DataType.UUID))
  @SkipChangeset
  collaboratorIds: string[];

  /**
   * Get the collaborators for this revision, with the author first and the
   * others in the order they were recorded.
   */
  get collaborators() {
    const otherCollaboratorIds = (this.collaboratorIds ?? []).filter(
      (id) => id !== this.userId
    );

    if (otherCollaboratorIds.length === 0) {
      return [this.user];
    }

    return User.findAll({
      where: {
        id: {
          [Op.in]: otherCollaboratorIds,
        },
      },
      paranoid: false,
    }).then((others) => [
      this.user,
      ...sortBy(others, [(user) => otherCollaboratorIds.indexOf(user.id)]),
    ]);
  }

  // hooks

  @BeforeDestroy
  static async clearData(model: Revision) {
    model.content = null;
    model.text = null;
    model.title = "";
  }

  // static methods

  /**
   * Find the latest revision for a given document
   *
   * @param documentId The document id to find the latest revision for
   * @returns A Promise that resolves to a Revision model
   */
  static findLatest(documentId: string) {
    return this.findOne({
      where: {
        documentId,
      },
      order: [["createdAt", "DESC"]],
    });
  }

  /**
   * Find a revision by id that belongs to the given document.
   *
   * @param id The id of the revision.
   * @param documentId The id of the document the revision must belong to.
   * @returns A Promise that resolves to a Revision model, or null if not found.
   */
  static async findByIdForDocument(id: string, documentId: string) {
    if (!isUUID(id)) {
      return null;
    }

    return this.findOne({
      where: {
        id,
        documentId,
      },
    });
  }

  /**
   * Find the latest revision of a document that a user authored or
   * collaborated on.
   *
   * @param documentId The id of the document.
   * @param userId The id of the user.
   * @returns A Promise that resolves to a Revision model, or null if not found.
   */
  static findLatestByUser(documentId: string, userId: string) {
    return this.findOne({
      where: {
        documentId,
        [Op.or]: [{ userId }, { collaboratorIds: { [Op.contains]: [userId] } }],
      },
      order: [["createdAt", "DESC"]],
    });
  }

  /**
   * Find the revision that shows a document as it was at a given time, the
   * latest revision created at or before that time.
   *
   * @param documentId The id of the document.
   * @param date The point in time.
   * @returns A Promise that resolves to a Revision model, or null if the
   * document had no revision at that time.
   */
  static findAt(documentId: string, date: Date) {
    return this.findOne({
      where: {
        documentId,
        createdAt: {
          [Op.lte]: date,
        },
      },
      order: [["createdAt", "DESC"]],
    });
  }

  /**
   * Build a Revision model from a Document model
   *
   * @param document The document to build from
   * @returns A Revision model
   */
  static buildFromDocument(document: Document) {
    return this.build({
      title: document.title,
      icon: document.icon,
      color: document.color,
      content: document.content,
      userId: document.lastModifiedById,
      editorVersion: document.editorVersion,
      version: document.version,
      documentId: document.id,
      // revision time is set to the last time document was touched as this
      // handler can be debounced in the case of an update
      createdAt: document.updatedAt,
    });
  }

  /**
   * Create a Revision model from a Document model and save it to the database
   *
   * @param ctx context to use for DB operations
   * @param document The document to create from
   * @param collaboratorIds Optional array of user IDs who authored this revision
   * @returns A Promise that resolves when saved
   */
  static createFromDocument(
    ctx: APIContext,
    document: Document,
    collaboratorIds?: string[]
  ) {
    const revision = this.buildFromDocument(document);
    const authType = ctx.context.auth?.type;
    revision.sourceMetadata = authType ? { authType } : null;

    if (collaboratorIds) {
      revision.collaboratorIds = collaboratorIds;
    }

    return revision.saveWithCtx(ctx);
  }

  // instance methods

  /**
   * Find the revision for the document before this one.
   *
   * @returns A Promise that resolves to a Revision, or null if this is the first revision.
   */
  before(): Promise<Revision | null> {
    return (this.constructor as typeof Revision).findOne({
      where: {
        documentId: this.documentId,
        createdAt: {
          [Op.lt]: this.createdAt,
        },
      },
      order: [["createdAt", "DESC"]],
    });
  }
}

export default Revision;