Files
outline/server/models/Collection.ts
2023-09-10 12:46:12 -07:00

665 lines
16 KiB
TypeScript

import find from "lodash/find";
import findIndex from "lodash/findIndex";
import remove from "lodash/remove";
import uniq from "lodash/uniq";
import randomstring from "randomstring";
import { Identifier, Transaction, Op, FindOptions } from "sequelize";
import {
Sequelize,
Table,
Column,
Unique,
IsIn,
Default,
BeforeValidate,
BeforeSave,
AfterDestroy,
AfterCreate,
HasMany,
BelongsToMany,
BelongsTo,
ForeignKey,
Scopes,
DataType,
Length as SimpleLength,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type { CollectionSort } from "@shared/types";
import { CollectionPermission, NavigationNode } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections";
import slugify from "@shared/utils/slugify";
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { CollectionValidation } from "@shared/validations";
import CollectionGroup from "./CollectionGroup";
import CollectionUser from "./CollectionUser";
import Document from "./Document";
import FileOperation from "./FileOperation";
import Group from "./Group";
import GroupUser from "./GroupUser";
import Team from "./Team";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
import IsHexColor from "./validators/IsHexColor";
import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";
@Scopes(() => ({
withAllMemberships: {
include: [
{
model: CollectionUser,
as: "memberships",
required: false,
},
{
model: CollectionGroup,
as: "collectionGroupMemberships",
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: "groupMemberships",
required: true,
},
],
},
],
},
],
},
withUser: () => ({
include: [
{
model: User,
required: true,
as: "user",
},
],
}),
withMembership: (userId: string) => ({
include: [
{
model: CollectionUser,
as: "memberships",
where: {
userId,
},
required: false,
},
{
model: CollectionGroup,
as: "collectionGroupMemberships",
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: "groupMemberships",
required: true,
where: {
userId,
},
},
],
},
],
},
],
}),
}))
@Table({ tableName: "collections", modelName: "collection" })
@Fix
class Collection extends ParanoidModel {
@SimpleLength({
min: 10,
max: 10,
msg: `urlId must be 10 characters`,
})
@Unique
@Column
urlId: string;
@NotContainsUrl
@Length({
max: CollectionValidation.maxNameLength,
msg: `name must be ${CollectionValidation.maxNameLength} characters or less`,
})
@Column
name: string;
@Length({
max: CollectionValidation.maxDescriptionLength,
msg: `description must be ${CollectionValidation.maxDescriptionLength} characters or less`,
})
@Column
description: string | null;
@Length({
max: 50,
msg: `icon must be 50 characters or less`,
})
@Column
icon: string | null;
@IsHexColor
@Column
color: string | null;
@Length({
max: 100,
msg: `index must be 100 characters or less`,
})
@Column
index: string | null;
@IsIn([Object.values(CollectionPermission)])
@Column(DataType.STRING)
permission: CollectionPermission | null;
@Default(false)
@Column
maintainerApprovalRequired: boolean;
@Column(DataType.JSONB)
documentStructure: NavigationNode[] | null;
@Default(true)
@Column
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;
// getters
get url(): string {
if (!this.name) {
return `/collection/untitled-${this.urlId}`;
}
return `/collection/${slugify(this.name)}-${this.urlId}`;
}
// hooks
@BeforeValidate
static async onBeforeValidate(model: Collection) {
model.urlId = model.urlId || randomstring.generate(10);
}
@BeforeSave
static async onBeforeSave(model: Collection) {
if (model.icon === "collection") {
model.icon = null;
}
}
@AfterDestroy
static async onAfterDestroy(model: Collection) {
await Document.destroy({
where: {
collectionId: model.id,
archivedAt: {
[Op.is]: null,
},
},
});
}
@AfterCreate
static async onAfterCreate(
model: Collection,
options: { transaction: Transaction }
) {
return CollectionUser.findOrCreate({
where: {
collectionId: model.id,
userId: model.createdById,
},
defaults: {
permission: CollectionPermission.Admin,
createdById: model.createdById,
},
transaction: options.transaction,
});
}
// associations
@BelongsTo(() => FileOperation, "importId")
import: FileOperation | null;
@ForeignKey(() => FileOperation)
@Column(DataType.UUID)
importId: string | null;
@HasMany(() => Document, "collectionId")
documents: Document[];
@HasMany(() => CollectionUser, "collectionId")
memberships: CollectionUser[];
@HasMany(() => CollectionGroup, "collectionId")
collectionGroupMemberships: CollectionGroup[];
@BelongsToMany(() => User, () => CollectionUser)
users: User[];
@BelongsToMany(() => Group, () => CollectionGroup)
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",
};
/**
* Returns an array of unique userIds that are members of a collection,
* either via group or direct membership
*
* @param collectionId
* @returns userIds
*/
static async membershipUserIds(collectionId: string) {
const collection = await this.scope("withAllMemberships").findByPk(
collectionId
);
if (!collection) {
return [];
}
const groupMemberships = collection.collectionGroupMemberships
.map((cgm) => cgm.group.groupMemberships)
.flat();
const membershipUserIds = [
...groupMemberships,
...collection.memberships,
].map((membership) => membership.userId);
return uniq(membershipUserIds);
}
/**
* Overrides the standard findByPk behavior to allow also querying by urlId
*
* @param id uuid or urlId
* @returns collection instance
*/
static async findByPk(
id: Identifier,
options: FindOptions<Collection> = {}
): Promise<Collection | null> {
if (typeof id !== "string") {
return null;
}
if (isUUID(id)) {
return this.findOne({
where: {
id,
},
...options,
});
}
const match = id.match(SLUG_URL_REGEX);
if (match) {
return this.findOne({
where: {
urlId: match[1],
},
...options,
});
}
return null;
}
/**
* Find the first collection that the specified user has access to.
*
* @param user User object
* @returns collection First collection in the sidebar order
*/
static async findFirstCollectionForUser(user: User) {
const id = await user.collectionIds();
return this.findOne({
where: {
id,
},
order: [
// using LC_COLLATE:"C" because we need byte order to drive the sorting
Sequelize.literal('"collection"."index" collate "C"'),
["updatedAt", "DESC"],
],
});
}
/**
* 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;
}
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),
};
};
deleteDocument = async function (document: Document, options?: FindOptions) {
await this.removeDocumentInStructure(document, options);
// Helper to destroy all child documents for a document
const loopChildren = async (
documentId: string,
opts?: FindOptions<Document>
) => {
const childDocuments = await Document.findAll({
where: {
parentDocumentId: documentId,
},
});
for (const child of childDocuments) {
await loopChildren(child.id, opts);
await child.destroy(opts);
}
};
await loopChildren(document.id, options);
await document.destroy(options);
};
removeDocumentInStructure = async function (
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 = function (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 function (
updatedDocument: Document,
options?: { transaction?: Transaction | null | undefined }
) {
if (!this.documentStructure) {
return;
}
const { id } = updatedDocument;
const updateChildren = (documents: NavigationNode[]) =>
Promise.all(
documents.map(async (document) => {
if (document.id === id) {
document = {
...(await updatedDocument.toNavigationNode(options)),
children: document.children,
};
} else {
document.children = await updateChildren(document.children);
}
return document;
})
);
this.documentStructure = await 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 function (
document: Document,
index?: number,
options: FindOptions & {
save?: boolean;
documentJson?: NavigationNode;
} = {}
) {
if (!this.documentStructure) {
this.documentStructure = [];
}
// If moving existing document with children, use existing structure
const documentJson = {
...(await document.toNavigationNode(options)),
...options.documentJson,
};
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(
index !== undefined ? index : this.documentStructure.length,
0,
documentJson
);
} else {
// Recursively place document
const placeDocument = (documentList: NavigationNode[]) =>
documentList.map((childDocument) => {
if (document.parentDocumentId === childDocument.id) {
childDocument.children.splice(
index !== undefined ? index : childDocument.children.length,
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;
};
}
export default Collection;