Individual document sharing with permissions (#5814)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
@@ -7,7 +7,6 @@ import randomstring from "randomstring";
|
||||
import {
|
||||
Identifier,
|
||||
Transaction,
|
||||
Op,
|
||||
FindOptions,
|
||||
NonNullFindOptions,
|
||||
InferAttributes,
|
||||
@@ -47,7 +46,7 @@ import GroupPermission from "./GroupPermission";
|
||||
import GroupUser from "./GroupUser";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import UserPermission from "./UserPermission";
|
||||
import UserMembership from "./UserMembership";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
import IsHexColor from "./validators/IsHexColor";
|
||||
@@ -58,23 +57,13 @@ import NotContainsUrl from "./validators/NotContainsUrl";
|
||||
withAllMemberships: {
|
||||
include: [
|
||||
{
|
||||
model: UserPermission,
|
||||
model: UserMembership,
|
||||
as: "memberships",
|
||||
where: {
|
||||
collectionId: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: GroupPermission,
|
||||
as: "collectionGroupMemberships",
|
||||
where: {
|
||||
collectionId: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
// use of "separate" property: sequelize breaks when there are
|
||||
// nested "includes" with alternating values for "required"
|
||||
@@ -112,24 +101,16 @@ import NotContainsUrl from "./validators/NotContainsUrl";
|
||||
withMembership: (userId: string) => ({
|
||||
include: [
|
||||
{
|
||||
model: UserPermission,
|
||||
model: UserMembership,
|
||||
as: "memberships",
|
||||
where: {
|
||||
userId,
|
||||
collectionId: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: GroupPermission,
|
||||
as: "collectionGroupMemberships",
|
||||
where: {
|
||||
collectionId: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
// use of "separate" property: sequelize breaks when there are
|
||||
// nested "includes" with alternating values for "required"
|
||||
@@ -288,7 +269,7 @@ class Collection extends ParanoidModel<
|
||||
model: Collection,
|
||||
options: { transaction: Transaction }
|
||||
) {
|
||||
return UserPermission.findOrCreate({
|
||||
return UserMembership.findOrCreate({
|
||||
where: {
|
||||
collectionId: model.id,
|
||||
userId: model.createdById,
|
||||
@@ -313,13 +294,13 @@ class Collection extends ParanoidModel<
|
||||
@HasMany(() => Document, "collectionId")
|
||||
documents: Document[];
|
||||
|
||||
@HasMany(() => UserPermission, "collectionId")
|
||||
memberships: UserPermission[];
|
||||
@HasMany(() => UserMembership, "collectionId")
|
||||
memberships: UserMembership[];
|
||||
|
||||
@HasMany(() => GroupPermission, "collectionId")
|
||||
collectionGroupMemberships: GroupPermission[];
|
||||
|
||||
@BelongsToMany(() => User, () => UserPermission)
|
||||
@BelongsToMany(() => User, () => UserMembership)
|
||||
users: User[];
|
||||
|
||||
@BelongsToMany(() => Group, () => GroupPermission)
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ForeignKey,
|
||||
Column,
|
||||
Table,
|
||||
Scopes,
|
||||
Length,
|
||||
DefaultScope,
|
||||
} from "sequelize-typescript";
|
||||
@@ -26,17 +25,6 @@ import TextLength from "./validators/TextLength";
|
||||
},
|
||||
],
|
||||
}))
|
||||
@Scopes(() => ({
|
||||
withDocument: {
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
as: "document",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
@Table({ tableName: "comments", modelName: "comment" })
|
||||
@Fix
|
||||
class Comment extends ParanoidModel<
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
IsNumeric,
|
||||
IsDate,
|
||||
AllowNull,
|
||||
BelongsToMany,
|
||||
} from "sequelize-typescript";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import type {
|
||||
@@ -58,6 +59,7 @@ import Revision from "./Revision";
|
||||
import Star from "./Star";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import UserMembership from "./UserMembership";
|
||||
import View from "./View";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
@@ -100,33 +102,20 @@ type AdditionalFindOptions = {
|
||||
},
|
||||
}))
|
||||
@Scopes(() => ({
|
||||
withCollectionPermissions: (userId: string, paranoid = true) => {
|
||||
if (userId) {
|
||||
return {
|
||||
include: [
|
||||
{
|
||||
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
|
||||
model: Collection.scope({
|
||||
withCollectionPermissions: (userId: string, paranoid = true) => ({
|
||||
include: [
|
||||
{
|
||||
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
|
||||
model: userId
|
||||
? Collection.scope({
|
||||
method: ["withMembership", userId],
|
||||
}),
|
||||
as: "collection",
|
||||
paranoid,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
include: [
|
||||
{
|
||||
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
|
||||
model: Collection,
|
||||
as: "collection",
|
||||
paranoid,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
})
|
||||
: Collection,
|
||||
as: "collection",
|
||||
paranoid,
|
||||
},
|
||||
],
|
||||
}),
|
||||
withoutState: {
|
||||
attributes: {
|
||||
exclude: ["state"],
|
||||
@@ -189,6 +178,22 @@ type AdditionalFindOptions = {
|
||||
],
|
||||
};
|
||||
},
|
||||
withMembership: (userId: string) => {
|
||||
if (!userId) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
include: [
|
||||
{
|
||||
association: "memberships",
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
}))
|
||||
@Table({ tableName: "documents", modelName: "document" })
|
||||
@Fix
|
||||
@@ -501,10 +506,16 @@ class Document extends ParanoidModel<
|
||||
@BelongsTo(() => Collection, "collectionId")
|
||||
collection: Collection | null | undefined;
|
||||
|
||||
@BelongsToMany(() => User, () => UserMembership)
|
||||
users: User[];
|
||||
|
||||
@ForeignKey(() => Collection)
|
||||
@Column(DataType.UUID)
|
||||
collectionId?: string | null;
|
||||
|
||||
@HasMany(() => UserMembership)
|
||||
memberships: UserMembership[];
|
||||
|
||||
@HasMany(() => Revision)
|
||||
revisions: Revision[];
|
||||
|
||||
@@ -524,7 +535,15 @@ class Document extends ParanoidModel<
|
||||
const viewScope: Readonly<ScopeOptions> = {
|
||||
method: ["withViews", userId],
|
||||
};
|
||||
return this.scope(["defaultScope", collectionScope, viewScope]);
|
||||
const membershipScope: Readonly<ScopeOptions> = {
|
||||
method: ["withMembership", userId],
|
||||
};
|
||||
return this.scope([
|
||||
"defaultScope",
|
||||
collectionScope,
|
||||
viewScope,
|
||||
membershipScope,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -564,6 +583,9 @@ class Document extends ParanoidModel<
|
||||
{
|
||||
method: ["withViews", userId],
|
||||
},
|
||||
{
|
||||
method: ["withMembership", userId],
|
||||
},
|
||||
]);
|
||||
|
||||
if (isUUID(id)) {
|
||||
@@ -788,11 +810,53 @@ class Document extends ParanoidModel<
|
||||
}
|
||||
}
|
||||
|
||||
const parentDocumentPermissions = this.parentDocumentId
|
||||
? await UserMembership.findAll({
|
||||
where: {
|
||||
documentId: this.parentDocumentId,
|
||||
},
|
||||
transaction,
|
||||
})
|
||||
: [];
|
||||
|
||||
await Promise.all(
|
||||
parentDocumentPermissions.map((permission) =>
|
||||
UserMembership.create(
|
||||
{
|
||||
documentId: this.id,
|
||||
userId: permission.userId,
|
||||
sourceId: permission.sourceId ?? permission.id,
|
||||
permission: permission.permission,
|
||||
createdById: permission.createdById,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
this.lastModifiedById = userId;
|
||||
this.publishedAt = new Date();
|
||||
return this.save({ 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;
|
||||
};
|
||||
|
||||
unpublish = async (userId: string) => {
|
||||
// If the document is already a draft then calling unpublish should act like
|
||||
// a regular save
|
||||
|
||||
@@ -145,9 +145,12 @@ class Event extends IdModel<
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
"documents.restore",
|
||||
"documents.add_user",
|
||||
"documents.remove_user",
|
||||
"revisions.create",
|
||||
"users.create",
|
||||
"users.demote",
|
||||
"userMemberships.update",
|
||||
];
|
||||
|
||||
static AUDIT_EVENTS: TEvent["name"][] = [
|
||||
@@ -172,6 +175,8 @@ class Event extends IdModel<
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
"documents.restore",
|
||||
"documents.add_user",
|
||||
"documents.remove_user",
|
||||
"groups.create",
|
||||
"groups.update",
|
||||
"groups.delete",
|
||||
|
||||
@@ -295,7 +295,7 @@ class Team extends ParanoidModel<
|
||||
});
|
||||
};
|
||||
|
||||
public collectionIds = async function (this: Team, paranoid = true) {
|
||||
public collectionIds = async function (paranoid = true) {
|
||||
const models = await Collection.findAll({
|
||||
attributes: ["id"],
|
||||
where: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { buildUser, buildTeam, buildCollection } from "@server/test/factories";
|
||||
import UserPermission from "./UserPermission";
|
||||
import UserMembership from "./UserMembership";
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2018-01-02T00:00:00.000Z"));
|
||||
@@ -113,7 +113,7 @@ describe("user model", () => {
|
||||
teamId: team.id,
|
||||
permission: null,
|
||||
});
|
||||
await UserPermission.create({
|
||||
await UserMembership.create({
|
||||
createdById: user.id,
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
NotificationEventType,
|
||||
NotificationEventDefaults,
|
||||
UserRole,
|
||||
DocumentPermission,
|
||||
} from "@shared/types";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import env from "@server/env";
|
||||
@@ -52,7 +53,7 @@ import AuthenticationProvider from "./AuthenticationProvider";
|
||||
import Collection from "./Collection";
|
||||
import Team from "./Team";
|
||||
import UserAuthentication from "./UserAuthentication";
|
||||
import UserPermission from "./UserPermission";
|
||||
import UserMembership from "./UserMembership";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Encrypted, {
|
||||
setEncryptedColumn,
|
||||
@@ -255,6 +256,12 @@ class User extends ParanoidModel<
|
||||
: CollectionPermission.ReadWrite;
|
||||
}
|
||||
|
||||
get defaultDocumentPermission(): DocumentPermission {
|
||||
return this.isViewer
|
||||
? DocumentPermission.Read
|
||||
: DocumentPermission.ReadWrite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a code that can be used to delete this user account. The code will
|
||||
* be rotated when the user signs out.
|
||||
@@ -559,7 +566,7 @@ class User extends ParanoidModel<
|
||||
},
|
||||
options
|
||||
);
|
||||
await UserPermission.update(
|
||||
await UserMembership.update(
|
||||
{
|
||||
permission: CollectionPermission.Read,
|
||||
},
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { buildCollection, buildUser } from "@server/test/factories";
|
||||
import UserPermission from "./UserPermission";
|
||||
import UserMembership from "./UserMembership";
|
||||
|
||||
describe("UserPermission", () => {
|
||||
describe("UserMembership", () => {
|
||||
describe("withCollection scope", () => {
|
||||
it("should return the collection", async () => {
|
||||
const collection = await buildCollection();
|
||||
const user = await buildUser({ teamId: collection.teamId });
|
||||
|
||||
await UserPermission.create({
|
||||
await UserMembership.create({
|
||||
createdById: user.id,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const permission = await UserPermission.scope("withCollection").findOne({
|
||||
const membership = await UserMembership.scope("withCollection").findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(permission).toBeDefined();
|
||||
expect(permission?.collection).toBeDefined();
|
||||
expect(permission?.collection?.id).toEqual(collection.id);
|
||||
expect(membership).toBeDefined();
|
||||
expect(membership?.collection).toBeDefined();
|
||||
expect(membership?.collection?.id).toEqual(collection.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
252
server/models/UserMembership.ts
Normal file
252
server/models/UserMembership.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
Op,
|
||||
type SaveOptions,
|
||||
type FindOptions,
|
||||
} from "sequelize";
|
||||
import {
|
||||
Column,
|
||||
ForeignKey,
|
||||
BelongsTo,
|
||||
Default,
|
||||
IsIn,
|
||||
Table,
|
||||
DataType,
|
||||
Scopes,
|
||||
AllowNull,
|
||||
AfterCreate,
|
||||
AfterUpdate,
|
||||
} from "sequelize-typescript";
|
||||
import { CollectionPermission, DocumentPermission } from "@shared/types";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
@Scopes(() => ({
|
||||
withUser: {
|
||||
include: [
|
||||
{
|
||||
association: "user",
|
||||
},
|
||||
],
|
||||
},
|
||||
withCollection: {
|
||||
where: {
|
||||
collectionId: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
include: [
|
||||
{
|
||||
association: "collection",
|
||||
},
|
||||
],
|
||||
},
|
||||
withDocument: {
|
||||
where: {
|
||||
documentId: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
include: [
|
||||
{
|
||||
association: "document",
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
@Table({ tableName: "user_permissions", modelName: "user_permission" })
|
||||
@Fix
|
||||
class UserMembership extends IdModel<
|
||||
InferAttributes<UserMembership>,
|
||||
Partial<InferCreationAttributes<UserMembership>>
|
||||
> {
|
||||
@Default(CollectionPermission.ReadWrite)
|
||||
@IsIn([Object.values(CollectionPermission)])
|
||||
@Column(DataType.STRING)
|
||||
permission: CollectionPermission | DocumentPermission;
|
||||
|
||||
@AllowNull
|
||||
@Column
|
||||
index: string | null;
|
||||
|
||||
// associations
|
||||
|
||||
/** The collection that this permission grants the user access to. */
|
||||
@BelongsTo(() => Collection, "collectionId")
|
||||
collection?: Collection | null;
|
||||
|
||||
/** The collection ID that this permission grants the user access to. */
|
||||
@ForeignKey(() => Collection)
|
||||
@Column(DataType.UUID)
|
||||
collectionId?: string | null;
|
||||
|
||||
/** The document that this permission grants the user access to. */
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document?: Document | null;
|
||||
|
||||
/** The document ID that this permission grants the user access to. */
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
documentId?: string | null;
|
||||
|
||||
/** If this represents the permission on a child then this points to the permission on the root */
|
||||
@BelongsTo(() => UserMembership, "sourceId")
|
||||
source?: UserMembership | null;
|
||||
|
||||
/** If this represents the permission on a child then this points to the permission on the root */
|
||||
@ForeignKey(() => UserMembership)
|
||||
@Column(DataType.UUID)
|
||||
sourceId?: string | null;
|
||||
|
||||
/** The user that this permission is granted to. */
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
/** The user ID that this permission is granted to. */
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
/** The user that created this permission. */
|
||||
@BelongsTo(() => User, "createdById")
|
||||
createdBy: User;
|
||||
|
||||
/** The user ID that created this permission. */
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
|
||||
/**
|
||||
* Find the root membership for a document and (optionally) user.
|
||||
*
|
||||
* @param documentId The document ID to find the membership for.
|
||||
* @param userId The user ID to find the membership for.
|
||||
* @param options Additional options to pass to the query.
|
||||
* @returns A promise that resolves to the root memberships for the document and user, or null.
|
||||
*/
|
||||
static async findRootMembershipsForDocument(
|
||||
documentId: string,
|
||||
userId?: string,
|
||||
options?: FindOptions<UserMembership>
|
||||
): Promise<UserMembership[]> {
|
||||
const memberships = await this.findAll({
|
||||
where: {
|
||||
documentId,
|
||||
...(userId ? { userId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const rootMemberships = await Promise.all(
|
||||
memberships.map((membership) =>
|
||||
membership?.sourceId
|
||||
? this.findByPk(membership.sourceId, options)
|
||||
: membership
|
||||
)
|
||||
);
|
||||
|
||||
return rootMemberships.filter(Boolean) as UserMembership[];
|
||||
}
|
||||
|
||||
@AfterUpdate
|
||||
static async updateSourcedMemberships(
|
||||
model: UserMembership,
|
||||
options: SaveOptions<UserMembership>
|
||||
) {
|
||||
if (model.sourceId || !model.documentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { transaction } = options;
|
||||
|
||||
if (model.changed("permission")) {
|
||||
await this.update(
|
||||
{
|
||||
permission: model.permission,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
sourceId: model.id,
|
||||
},
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterCreate
|
||||
static async createSourcedMemberships(
|
||||
model: UserMembership,
|
||||
options: SaveOptions<UserMembership>
|
||||
) {
|
||||
if (model.sourceId || !model.documentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.recreateSourcedMemberships(model, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate all sourced permissions for a given permission.
|
||||
*/
|
||||
static async recreateSourcedMemberships(
|
||||
model: UserMembership,
|
||||
options: SaveOptions<UserMembership>
|
||||
) {
|
||||
if (!model.documentId) {
|
||||
return;
|
||||
}
|
||||
const { transaction } = options;
|
||||
|
||||
await this.destroy({
|
||||
where: {
|
||||
sourceId: model.id,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
const document = await Document.unscoped().findOne({
|
||||
attributes: ["id"],
|
||||
where: {
|
||||
id: model.documentId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const childDocumentIds = await document.findAllChildDocumentIds(
|
||||
{
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
|
||||
for (const childDocumentId of childDocumentIds) {
|
||||
await this.create(
|
||||
{
|
||||
documentId: childDocumentId,
|
||||
userId: model.userId,
|
||||
permission: model.permission,
|
||||
sourceId: model.id,
|
||||
createdById: model.createdById,
|
||||
createdAt: model.createdAt,
|
||||
updatedAt: model.updatedAt,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default UserMembership;
|
||||
@@ -1,82 +0,0 @@
|
||||
import { InferAttributes, InferCreationAttributes, Op } from "sequelize";
|
||||
import {
|
||||
Column,
|
||||
ForeignKey,
|
||||
BelongsTo,
|
||||
Default,
|
||||
IsIn,
|
||||
Table,
|
||||
DataType,
|
||||
Scopes,
|
||||
} from "sequelize-typescript";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
@Scopes(() => ({
|
||||
withUser: {
|
||||
include: [
|
||||
{
|
||||
association: "user",
|
||||
},
|
||||
],
|
||||
},
|
||||
withCollection: {
|
||||
where: {
|
||||
collectionId: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
include: [
|
||||
{
|
||||
association: "collection",
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
@Table({ tableName: "user_permissions", modelName: "user_permission" })
|
||||
@Fix
|
||||
class UserPermission extends IdModel<
|
||||
InferAttributes<UserPermission>,
|
||||
Partial<InferCreationAttributes<UserPermission>>
|
||||
> {
|
||||
@Default(CollectionPermission.ReadWrite)
|
||||
@IsIn([Object.values(CollectionPermission)])
|
||||
@Column(DataType.STRING)
|
||||
permission: CollectionPermission;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => Collection, "collectionId")
|
||||
collection?: Collection | null;
|
||||
|
||||
@ForeignKey(() => Collection)
|
||||
@Column(DataType.UUID)
|
||||
collectionId?: string | null;
|
||||
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document?: Document | null;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
documentId?: string | null;
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
userId: string;
|
||||
|
||||
@BelongsTo(() => User, "createdById")
|
||||
createdBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
}
|
||||
|
||||
export default UserPermission;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import SearchHelper from "@server/models/helpers/SearchHelper";
|
||||
import {
|
||||
buildDocument,
|
||||
@@ -7,9 +8,11 @@ import {
|
||||
buildUser,
|
||||
buildShare,
|
||||
} from "@server/test/factories";
|
||||
import UserMembership from "../UserMembership";
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
await buildDocument();
|
||||
});
|
||||
|
||||
describe("SearchHelper", () => {
|
||||
@@ -118,7 +121,7 @@ describe("SearchHelper", () => {
|
||||
title: "test number 2",
|
||||
});
|
||||
const { totalCount } = await SearchHelper.searchForTeam(team, "test");
|
||||
expect(totalCount).toBe("2");
|
||||
expect(totalCount).toBe(2);
|
||||
});
|
||||
|
||||
test("should return the document when searched with their previous titles", async () => {
|
||||
@@ -137,7 +140,7 @@ describe("SearchHelper", () => {
|
||||
team,
|
||||
"test number"
|
||||
);
|
||||
expect(totalCount).toBe("1");
|
||||
expect(totalCount).toBe(1);
|
||||
});
|
||||
|
||||
test("should not return the document when searched with neither the titles nor the previous titles", async () => {
|
||||
@@ -156,7 +159,7 @@ describe("SearchHelper", () => {
|
||||
team,
|
||||
"title doesn't exist"
|
||||
);
|
||||
expect(totalCount).toBe("0");
|
||||
expect(totalCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,6 +177,13 @@ describe("SearchHelper", () => {
|
||||
collectionId: collection.id,
|
||||
title: "test",
|
||||
});
|
||||
await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
collectionId: collection.id,
|
||||
deletedAt: new Date(),
|
||||
title: "test",
|
||||
});
|
||||
const { results } = await SearchHelper.searchForUser(user, "test");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].document?.id).toBe(document.id);
|
||||
@@ -217,6 +227,27 @@ describe("SearchHelper", () => {
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
test("should not include drafts with user permission", async () => {
|
||||
const user = await buildUser();
|
||||
const draft = await buildDraftDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
createdById: user.id,
|
||||
title: "test",
|
||||
});
|
||||
await UserMembership.create({
|
||||
createdById: user.id,
|
||||
documentId: draft.id,
|
||||
userId: user.id,
|
||||
permission: DocumentPermission.Read,
|
||||
});
|
||||
|
||||
const { results } = await SearchHelper.searchForUser(user, "test", {
|
||||
includeDrafts: false,
|
||||
});
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
|
||||
test("should include results from drafts as well", async () => {
|
||||
const user = await buildUser();
|
||||
await buildDocument({
|
||||
@@ -277,7 +308,7 @@ describe("SearchHelper", () => {
|
||||
title: "test number 2",
|
||||
});
|
||||
const { totalCount } = await SearchHelper.searchForUser(user, "test");
|
||||
expect(totalCount).toBe("2");
|
||||
expect(totalCount).toBe(2);
|
||||
});
|
||||
|
||||
test("should return the document when searched with their previous titles", async () => {
|
||||
@@ -299,7 +330,7 @@ describe("SearchHelper", () => {
|
||||
user,
|
||||
"test number"
|
||||
);
|
||||
expect(totalCount).toBe("1");
|
||||
expect(totalCount).toBe(1);
|
||||
});
|
||||
|
||||
test("should not return the document when searched with neither the titles nor the previous titles", async () => {
|
||||
@@ -321,7 +352,7 @@ describe("SearchHelper", () => {
|
||||
user,
|
||||
"title doesn't exist"
|
||||
);
|
||||
expect(totalCount).toBe("0");
|
||||
expect(totalCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import map from "lodash/map";
|
||||
import queryParser from "pg-tsquery";
|
||||
import { Op, QueryTypes, WhereOptions } from "sequelize";
|
||||
import { Op, Sequelize, WhereOptions } from "sequelize";
|
||||
import { DateFilter } from "@shared/types";
|
||||
import Collection from "@server/models/Collection";
|
||||
import Document from "@server/models/Document";
|
||||
@@ -48,7 +48,7 @@ type SearchOptions = {
|
||||
snippetMaxWords?: number;
|
||||
};
|
||||
|
||||
type Results = {
|
||||
type RankedDocument = Document & {
|
||||
searchRanking: number;
|
||||
searchContext: string;
|
||||
id: string;
|
||||
@@ -72,25 +72,7 @@ export default class SearchHelper {
|
||||
offset = 0,
|
||||
} = options;
|
||||
|
||||
// restrict to specific collection if provided
|
||||
// enables search in private collections if specified
|
||||
let collectionIds: string[];
|
||||
if (options.collectionId) {
|
||||
collectionIds = [options.collectionId];
|
||||
} else {
|
||||
collectionIds = await team.collectionIds();
|
||||
}
|
||||
|
||||
// short circuit if no relevant collections
|
||||
if (!collectionIds.length) {
|
||||
return {
|
||||
results: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// restrict to documents in the tree of a shared document when one is provided
|
||||
let documentIds: string[] | undefined;
|
||||
const where = await this.buildWhere(team, options);
|
||||
|
||||
if (options.share?.includeChildDocuments) {
|
||||
const sharedDocument = await options.share.$get("document");
|
||||
@@ -101,57 +83,57 @@ export default class SearchHelper {
|
||||
[Op.is]: null,
|
||||
},
|
||||
});
|
||||
documentIds = [sharedDocument.id, ...childDocumentIds];
|
||||
|
||||
where[Op.and].push({
|
||||
id: [sharedDocument.id, ...childDocumentIds],
|
||||
});
|
||||
}
|
||||
|
||||
const documentClause = documentIds ? `"id" IN(:documentIds) AND` : "";
|
||||
where[Op.and].push(
|
||||
Sequelize.fn(
|
||||
`"searchVector" @@ to_tsquery`,
|
||||
"english",
|
||||
Sequelize.literal(":query")
|
||||
)
|
||||
);
|
||||
|
||||
// Build the SQL query to get result documentIds, ranking, and search term context
|
||||
const whereClause = `
|
||||
"searchVector" @@ to_tsquery('english', :query) AND
|
||||
"teamId" = :teamId AND
|
||||
"collectionId" IN(:collectionIds) AND
|
||||
${documentClause}
|
||||
"deletedAt" IS NULL AND
|
||||
"publishedAt" IS NOT NULL
|
||||
`;
|
||||
const selectSql = `
|
||||
SELECT
|
||||
id,
|
||||
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
|
||||
ts_headline('english', "text", to_tsquery('english', :query), :headlineOptions) as "searchContext"
|
||||
FROM documents
|
||||
WHERE ${whereClause}
|
||||
ORDER BY
|
||||
"searchRanking" DESC,
|
||||
"updatedAt" DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
`;
|
||||
const countSql = `
|
||||
SELECT COUNT(id)
|
||||
FROM documents
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
const queryReplacements = {
|
||||
teamId: team.id,
|
||||
query: this.webSearchQuery(query),
|
||||
collectionIds,
|
||||
documentIds,
|
||||
headlineOptions: `MaxFragments=1, MinWords=${snippetMinWords}, MaxWords=${snippetMaxWords}`,
|
||||
};
|
||||
const resultsQuery = sequelize.query<Results>(selectSql, {
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: { ...queryReplacements, limit, offset },
|
||||
});
|
||||
const countQuery = sequelize.query<{ count: number }>(countSql, {
|
||||
type: QueryTypes.SELECT,
|
||||
|
||||
const resultsQuery = Document.unscoped().findAll({
|
||||
attributes: [
|
||||
"id",
|
||||
[
|
||||
Sequelize.literal(
|
||||
`ts_rank("searchVector", to_tsquery('english', :query))`
|
||||
),
|
||||
"searchRanking",
|
||||
],
|
||||
[
|
||||
Sequelize.literal(
|
||||
`ts_headline('english', "text", to_tsquery('english', :query), :headlineOptions)`
|
||||
),
|
||||
"searchContext",
|
||||
],
|
||||
],
|
||||
replacements: queryReplacements,
|
||||
});
|
||||
const [results, [{ count }]] = await Promise.all([
|
||||
resultsQuery,
|
||||
countQuery,
|
||||
]);
|
||||
where,
|
||||
order: [
|
||||
["searchRanking", "DESC"],
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
limit,
|
||||
offset,
|
||||
}) as any as Promise<RankedDocument[]>;
|
||||
|
||||
const countQuery = Document.unscoped().count({
|
||||
// @ts-expect-error Types are incorrect for count
|
||||
replacements: queryReplacements,
|
||||
where,
|
||||
}) as any as Promise<number>;
|
||||
const [results, count] = await Promise.all([resultsQuery, countQuery]);
|
||||
|
||||
// Final query to get associated document data
|
||||
const documents = await Document.findAll({
|
||||
@@ -167,7 +149,7 @@ export default class SearchHelper {
|
||||
],
|
||||
});
|
||||
|
||||
return SearchHelper.buildResponse(results, documents, count);
|
||||
return this.buildResponse(results, documents, count);
|
||||
}
|
||||
|
||||
public static async searchTitlesForUser(
|
||||
@@ -176,88 +158,36 @@ export default class SearchHelper {
|
||||
options: SearchOptions = {}
|
||||
): Promise<Document[]> {
|
||||
const { limit = 15, offset = 0 } = options;
|
||||
const where = await this.buildWhere(user, options);
|
||||
|
||||
const where: WhereOptions<Document> = {
|
||||
teamId: user.teamId,
|
||||
where[Op.and].push({
|
||||
title: {
|
||||
[Op.iLike]: `%${query}%`,
|
||||
},
|
||||
[Op.and]: [],
|
||||
};
|
||||
});
|
||||
|
||||
// Ensure we're filtering by the users accessible collections. If
|
||||
// collectionId is passed as an option it is assumed that the authorization
|
||||
// has already been done in the router
|
||||
if (options.collectionId) {
|
||||
where[Op.and].push({
|
||||
collectionId: options.collectionId,
|
||||
});
|
||||
} else {
|
||||
where[Op.and].push({
|
||||
[Op.or]: [
|
||||
{
|
||||
collectionId: {
|
||||
[Op.in]: await user.collectionIds(),
|
||||
},
|
||||
},
|
||||
{
|
||||
collectionId: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
createdById: user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (options.dateFilter) {
|
||||
where[Op.and].push({
|
||||
updatedAt: {
|
||||
[Op.gt]: sequelize.literal(
|
||||
`now() - interval '1 ${options.dateFilter}'`
|
||||
),
|
||||
const include = [
|
||||
{
|
||||
association: "memberships",
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
required: false,
|
||||
separate: false,
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "createdBy",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "updatedBy",
|
||||
paranoid: false,
|
||||
},
|
||||
];
|
||||
|
||||
if (!options.includeArchived) {
|
||||
where[Op.and].push({
|
||||
archivedAt: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.includeDrafts) {
|
||||
where[Op.and].push({
|
||||
[Op.or]: [
|
||||
{
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
createdById: user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
where[Op.and].push({
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.collaboratorIds) {
|
||||
where[Op.and].push({
|
||||
collaboratorIds: {
|
||||
[Op.contains]: options.collaboratorIds,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await Document.scope([
|
||||
return Document.scope([
|
||||
"withoutState",
|
||||
"withDrafts",
|
||||
{
|
||||
@@ -266,21 +196,14 @@ export default class SearchHelper {
|
||||
{
|
||||
method: ["withCollectionPermissions", user.id],
|
||||
},
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
]).findAll({
|
||||
where,
|
||||
subQuery: false,
|
||||
order: [["updatedAt", "DESC"]],
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "createdBy",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "updatedBy",
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
include,
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
@@ -297,90 +220,69 @@ export default class SearchHelper {
|
||||
limit = 15,
|
||||
offset = 0,
|
||||
} = options;
|
||||
// Ensure we're filtering by the users accessible collections. If
|
||||
// collectionId is passed as an option it is assumed that the authorization
|
||||
// has already been done in the router
|
||||
let collectionIds;
|
||||
|
||||
if (options.collectionId) {
|
||||
collectionIds = [options.collectionId];
|
||||
} else {
|
||||
collectionIds = await user.collectionIds();
|
||||
}
|
||||
const where = await this.buildWhere(user, options);
|
||||
|
||||
let dateFilter;
|
||||
where[Op.and].push(
|
||||
Sequelize.fn(
|
||||
`"searchVector" @@ to_tsquery`,
|
||||
"english",
|
||||
Sequelize.literal(":query")
|
||||
)
|
||||
);
|
||||
|
||||
if (options.dateFilter) {
|
||||
dateFilter = `1 ${options.dateFilter}`;
|
||||
}
|
||||
|
||||
// Build the SQL query to get documentIds, ranking, and search term context
|
||||
const whereClause = `
|
||||
"searchVector" @@ to_tsquery('english', :query) AND
|
||||
"teamId" = :teamId AND
|
||||
${
|
||||
collectionIds.length
|
||||
? `(
|
||||
"collectionId" IN(:collectionIds) OR
|
||||
("collectionId" IS NULL AND "createdById" = :userId)
|
||||
) AND`
|
||||
: '"collectionId" IS NULL AND "createdById" = :userId AND'
|
||||
}
|
||||
${
|
||||
options.dateFilter ? '"updatedAt" > now() - interval :dateFilter AND' : ""
|
||||
}
|
||||
${
|
||||
options.collaboratorIds
|
||||
? '"collaboratorIds" @> ARRAY[:collaboratorIds]::uuid[] AND'
|
||||
: ""
|
||||
}
|
||||
${options.includeArchived ? "" : '"archivedAt" IS NULL AND'}
|
||||
"deletedAt" IS NULL AND
|
||||
${
|
||||
options.includeDrafts
|
||||
? '("publishedAt" IS NOT NULL OR "createdById" = :userId)'
|
||||
: '"publishedAt" IS NOT NULL'
|
||||
}
|
||||
`;
|
||||
const selectSql = `
|
||||
SELECT
|
||||
id,
|
||||
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
|
||||
ts_headline('english', "text", to_tsquery('english', :query), :headlineOptions) as "searchContext"
|
||||
FROM documents
|
||||
WHERE ${whereClause}
|
||||
ORDER BY
|
||||
"searchRanking" DESC,
|
||||
"updatedAt" DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
`;
|
||||
const countSql = `
|
||||
SELECT COUNT(id)
|
||||
FROM documents
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
const queryReplacements = {
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collaboratorIds: options.collaboratorIds,
|
||||
query: this.webSearchQuery(query),
|
||||
collectionIds,
|
||||
dateFilter,
|
||||
headlineOptions: `MaxFragments=1, MinWords=${snippetMinWords}, MaxWords=${snippetMaxWords}`,
|
||||
};
|
||||
const resultsQuery = sequelize.query<Results>(selectSql, {
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: { ...queryReplacements, limit, offset },
|
||||
});
|
||||
const countQuery = sequelize.query<{ count: number }>(countSql, {
|
||||
type: QueryTypes.SELECT,
|
||||
|
||||
const include = [
|
||||
{
|
||||
association: "memberships",
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
required: false,
|
||||
separate: false,
|
||||
},
|
||||
];
|
||||
|
||||
const resultsQuery = Document.unscoped().findAll({
|
||||
attributes: [
|
||||
"id",
|
||||
[
|
||||
Sequelize.literal(
|
||||
`ts_rank("searchVector", to_tsquery('english', :query))`
|
||||
),
|
||||
"searchRanking",
|
||||
],
|
||||
[
|
||||
Sequelize.literal(
|
||||
`ts_headline('english', "text", to_tsquery('english', :query), :headlineOptions)`
|
||||
),
|
||||
"searchContext",
|
||||
],
|
||||
],
|
||||
subQuery: false,
|
||||
include,
|
||||
replacements: queryReplacements,
|
||||
});
|
||||
const [results, [{ count }]] = await Promise.all([
|
||||
resultsQuery,
|
||||
countQuery,
|
||||
]);
|
||||
where,
|
||||
order: [
|
||||
["searchRanking", "DESC"],
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
limit,
|
||||
offset,
|
||||
}) as any as Promise<RankedDocument[]>;
|
||||
|
||||
const countQuery = Document.unscoped().count({
|
||||
// @ts-expect-error Types are incorrect for count
|
||||
subQuery: false,
|
||||
include,
|
||||
replacements: queryReplacements,
|
||||
where,
|
||||
}) as any as Promise<number>;
|
||||
const [results, count] = await Promise.all([resultsQuery, countQuery]);
|
||||
|
||||
// Final query to get associated document data
|
||||
const documents = await Document.scope([
|
||||
@@ -392,6 +294,9 @@ export default class SearchHelper {
|
||||
{
|
||||
method: ["withCollectionPermissions", user.id],
|
||||
},
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
]).findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
@@ -399,11 +304,91 @@ export default class SearchHelper {
|
||||
},
|
||||
});
|
||||
|
||||
return SearchHelper.buildResponse(results, documents, count);
|
||||
return this.buildResponse(results, documents, count);
|
||||
}
|
||||
|
||||
private static async buildWhere(model: User | Team, options: SearchOptions) {
|
||||
const teamId = model instanceof Team ? model.id : model.teamId;
|
||||
const where: WhereOptions<Document> = {
|
||||
teamId,
|
||||
[Op.or]: [],
|
||||
[Op.and]: [
|
||||
{
|
||||
deletedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (model instanceof User) {
|
||||
where[Op.or].push({ "$memberships.id$": { [Op.ne]: null } });
|
||||
}
|
||||
|
||||
// Ensure we're filtering by the users accessible collections. If
|
||||
// collectionId is passed as an option it is assumed that the authorization
|
||||
// has already been done in the router
|
||||
const collectionIds = options.collectionId
|
||||
? [options.collectionId]
|
||||
: await model.collectionIds();
|
||||
|
||||
if (collectionIds.length) {
|
||||
where[Op.or].push({ collectionId: collectionIds });
|
||||
}
|
||||
|
||||
if (options.dateFilter) {
|
||||
where[Op.and].push({
|
||||
updatedAt: {
|
||||
[Op.gt]: sequelize.literal(
|
||||
`now() - interval '1 ${options.dateFilter}'`
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.collaboratorIds) {
|
||||
where[Op.and].push({
|
||||
collaboratorIds: {
|
||||
[Op.contains]: options.collaboratorIds,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.includeArchived) {
|
||||
where[Op.and].push({
|
||||
archivedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.includeDrafts && model instanceof User) {
|
||||
where[Op.and].push({
|
||||
[Op.or]: [
|
||||
{
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
createdById: model.id,
|
||||
},
|
||||
{ "$memberships.id$": { [Op.ne]: null } },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
where[Op.and].push({
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
private static buildResponse(
|
||||
results: Results[],
|
||||
results: RankedDocument[],
|
||||
documents: Document[],
|
||||
count: number
|
||||
): SearchResponse {
|
||||
|
||||
@@ -10,7 +10,7 @@ export { default as Collection } from "./Collection";
|
||||
|
||||
export { default as GroupPermission } from "./GroupPermission";
|
||||
|
||||
export { default as UserPermission } from "./UserPermission";
|
||||
export { default as UserMembership } from "./UserMembership";
|
||||
|
||||
export { default as Comment } from "./Comment";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user