server/models/Collection.ts
Rama main · réplica de lectura del repositorio original.
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;