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:
Apoorv Mishra
2024-01-31 07:18:22 +05:30
committed by GitHub
parent 717c9b5d64
commit 1490c3a14b
91 changed files with 4004 additions and 1166 deletions

View File

@@ -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)

View File

@@ -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<

View File

@@ -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

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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,
},

View File

@@ -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);
});
});
});

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

View File

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

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -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";