diff --git a/server/models/helpers/SearchHelper.test.ts b/server/models/helpers/SearchHelper.test.ts index 7f1ddfc05..f72cb5e01 100644 --- a/server/models/helpers/SearchHelper.test.ts +++ b/server/models/helpers/SearchHelper.test.ts @@ -333,3 +333,139 @@ describe("#searchForUser", () => { expect(totalCount).toBe("0"); }); }); + +describe("#searchTitlesForUser", () => { + test("should return search results from collections", async () => { + const team = await buildTeam(); + const user = await buildUser({ + teamId: team.id, + }); + const collection = await buildCollection({ + userId: user.id, + teamId: team.id, + }); + const document = await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection.id, + title: "test", + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test"); + expect(documents.length).toBe(1); + expect(documents[0]?.id).toBe(document.id); + }); + + test("should filter to specific collection", async () => { + const team = await buildTeam(); + const user = await buildUser({ + teamId: team.id, + }); + const collection = await buildCollection({ + userId: user.id, + teamId: team.id, + }); + const collection1 = await buildCollection({ + userId: user.id, + teamId: team.id, + }); + const document = await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection.id, + title: "test", + }); + await buildDraftDocument({ + teamId: team.id, + userId: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection1.id, + title: "test", + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + collectionId: collection.id, + }); + expect(documents.length).toBe(1); + expect(documents[0]?.id).toBe(document.id); + }); + + test("should handle no collections", async () => { + const team = await buildTeam(); + const user = await buildUser({ + teamId: team.id, + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test"); + expect(documents.length).toBe(0); + }); + + test("should search only drafts created by user", async () => { + const user = await buildUser(); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + includeDrafts: true, + }); + expect(documents.length).toBe(1); + }); + + test("should not include drafts", async () => { + const user = await buildUser(); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + includeDrafts: false, + }); + expect(documents.length).toBe(0); + }); + + test("should include results from drafts as well", async () => { + const user = await buildUser(); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "not test", + }); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + includeDrafts: true, + }); + expect(documents.length).toBe(2); + }); + + test("should not include results from drafts", async () => { + const user = await buildUser(); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "not test", + }); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + includeDrafts: false, + }); + expect(documents.length).toBe(1); + }); +}); diff --git a/server/models/helpers/SearchHelper.ts b/server/models/helpers/SearchHelper.ts index 1e57bbdbd..ba19a6021 100644 --- a/server/models/helpers/SearchHelper.ts +++ b/server/models/helpers/SearchHelper.ts @@ -2,7 +2,7 @@ import removeMarkdown from "@tommoor/remove-markdown"; import invariant from "invariant"; import { find, map } from "lodash"; import queryParser from "pg-tsquery"; -import { Op, QueryTypes } from "sequelize"; +import { Op, QueryTypes, WhereOptions } from "sequelize"; import { DateFilter } from "@shared/types"; import unescape from "@shared/utils/unescape"; import { sequelize } from "@server/database/sequelize"; @@ -170,6 +170,128 @@ export default class SearchHelper { return SearchHelper.buildResponse(results, documents, count); } + public static async searchTitlesForUser( + user: User, + query: string, + options: SearchOptions = {} + ): Promise { + const { limit = 15, offset = 0 } = options; + + let where: WhereOptions = { + title: { + [Op.iLike]: `%${query}%`, + }, + }; + + // 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 = { + ...where, + collectionId: options.collectionId, + }; + } else { + // @ts-expect-error doesn't like OR null + where = { + ...where, + [Op.or]: [ + { + collectionId: { + [Op.in]: await user.collectionIds(), + }, + }, + { + collectionId: { + [Op.is]: null, + }, + createdById: user.id, + }, + ], + }; + } + + if (options.dateFilter) { + where = { + ...where, + updatedAt: { + [Op.gt]: sequelize.literal( + `now() - interval '1 ${options.dateFilter}'` + ), + }, + }; + } + + if (!options.includeArchived) { + where = { + ...where, + archivedAt: { + [Op.is]: null, + }, + }; + } + + if (options.includeDrafts) { + where = { + ...where, + [Op.or]: [ + { + publishedAt: { + [Op.ne]: null, + }, + }, + { + createdById: user.id, + }, + ], + }; + } else { + where = { + ...where, + publishedAt: { + [Op.ne]: null, + }, + }; + } + + if (options.collaboratorIds) { + where = { + ...where, + collaboratorIds: { + [Op.contains]: options.collaboratorIds, + }, + }; + } + + return await Document.scope([ + "withoutState", + "withDrafts", + { + method: ["withViews", user.id], + }, + { + method: ["withCollectionPermissions", user.id], + }, + ]).findAll({ + where, + order: [["updatedAt", "DESC"]], + include: [ + { + model: User, + as: "createdBy", + paranoid: false, + }, + { + model: User, + as: "updatedBy", + paranoid: false, + }, + ], + offset, + limit, + }); + } + public static async searchForUser( user: User, query: string, diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index fbdb43c13..bde267871 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -1,3 +1,4 @@ +import { subDays } from "date-fns"; import { CollectionPermission } from "@shared/types"; import { Document, @@ -1048,6 +1049,88 @@ describe("#documents.search_titles", () => { expect(body.data[0].id).toEqual(document.id); }); + it("should allow filtering of results by date", async () => { + const user = await buildUser(); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + title: "Super secret", + createdAt: subDays(new Date(), 365), + updatedAt: subDays(new Date(), 365), + }); + const res = await server.post("/api/documents.search_titles", { + body: { + token: user.getJwtToken(), + query: "SECRET", + dateFilter: "day", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + + it("should allow filtering to include archived", async () => { + const user = await buildUser(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + title: "Super secret", + archivedAt: new Date(), + }); + const res = await server.post("/api/documents.search_titles", { + body: { + token: user.getJwtToken(), + query: "SECRET", + includeArchived: true, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(document.id); + }); + + it("should allow filtering to include drafts", async () => { + const user = await buildUser(); + const document = await buildDraftDocument({ + userId: user.id, + teamId: user.teamId, + title: "Super secret", + }); + const res = await server.post("/api/documents.search_titles", { + body: { + token: user.getJwtToken(), + query: "SECRET", + includeDrafts: true, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(document.id); + }); + + it("should allow filtering to include a user", async () => { + const user = await buildUser(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + title: "Super secret", + }); + const res = await server.post("/api/documents.search_titles", { + body: { + token: user.getJwtToken(), + query: "SECRET", + userId: user.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(document.id); + }); + it("should not include archived or deleted documents", async () => { const user = await buildUser(); await buildDocument({ diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 7430a3b2a..9e5626ca6 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -588,43 +588,37 @@ router.post( "documents.search_titles", auth(), pagination(), - validate(T.DocumentsSearchTitlesSchema), - async (ctx: APIContext) => { - const { query } = ctx.input; + validate(T.DocumentsSearchSchema), + async (ctx: APIContext) => { + const { + query, + includeArchived, + includeDrafts, + dateFilter, + collectionId, + userId, + } = ctx.input; const { offset, limit } = ctx.state.pagination; const { user } = ctx.state; + let collaboratorIds = undefined; - const collectionIds = await user.collectionIds(); - const documents = await Document.scope([ - { - method: ["withViews", user.id], - }, - { - method: ["withCollectionPermissions", user.id], - }, - ]).findAll({ - where: { - title: { - [Op.iLike]: `%${query}%`, - }, - collectionId: collectionIds, - archivedAt: { - [Op.is]: null, - }, - }, - order: [["updatedAt", "DESC"]], - include: [ - { - model: User, - as: "createdBy", - paranoid: false, - }, - { - model: User, - as: "updatedBy", - paranoid: false, - }, - ], + if (collectionId) { + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collectionId); + authorize(user, "read", collection); + } + + if (userId) { + collaboratorIds = [userId]; + } + + const documents = await SearchHelper.searchTitlesForUser(user, query, { + includeArchived, + includeDrafts, + dateFilter, + collectionId, + collaboratorIds, offset, limit, }); diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index 565684f14..192542697 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -138,12 +138,6 @@ export const DocumentsRestoreSchema = BaseIdSchema.extend({ export type DocumentsRestoreReq = z.infer; -export const DocumentsSearchTitlesSchema = SearchQuerySchema.extend({}); - -export type DocumentsSearchTitlesReq = z.infer< - typeof DocumentsSearchTitlesSchema ->; - export const DocumentsSearchSchema = SearchQuerySchema.merge( DateFilterSchema ).extend({ diff --git a/server/test/factories.ts b/server/test/factories.ts index e6f8c18b0..f5cfd1cb6 100644 --- a/server/test/factories.ts +++ b/server/test/factories.ts @@ -365,14 +365,19 @@ export async function buildDocument( } count++; - return Document.create({ - title: `Document ${count}`, - text: "This is the text in an example document", - publishedAt: isNull(overrides.collectionId) ? null : new Date(), - lastModifiedById: overrides.userId, - createdById: overrides.userId, - ...overrides, - }); + return Document.create( + { + title: `Document ${count}`, + text: "This is the text in an example document", + publishedAt: isNull(overrides.collectionId) ? null : new Date(), + lastModifiedById: overrides.userId, + createdById: overrides.userId, + ...overrides, + }, + { + silent: overrides.createdAt || overrides.updatedAt ? true : false, + } + ); } export async function buildFileOperation(