Add additional filters to search_titles endpoint (#4563)

* Add additional filters to search_titles endpoint

* tests, fixes for drafts

* fix: dateFilter results in 500

* fix: Draft documents returned in collection-only search
This commit is contained in:
Tom Moor
2022-12-24 03:44:22 -08:00
committed by GitHub
parent 504693c68d
commit 82c565f1d4
6 changed files with 383 additions and 49 deletions

View File

@@ -333,3 +333,139 @@ describe("#searchForUser", () => {
expect(totalCount).toBe("0"); 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);
});
});

View File

@@ -2,7 +2,7 @@ import removeMarkdown from "@tommoor/remove-markdown";
import invariant from "invariant"; import invariant from "invariant";
import { find, map } from "lodash"; import { find, map } from "lodash";
import queryParser from "pg-tsquery"; import queryParser from "pg-tsquery";
import { Op, QueryTypes } from "sequelize"; import { Op, QueryTypes, WhereOptions } from "sequelize";
import { DateFilter } from "@shared/types"; import { DateFilter } from "@shared/types";
import unescape from "@shared/utils/unescape"; import unescape from "@shared/utils/unescape";
import { sequelize } from "@server/database/sequelize"; import { sequelize } from "@server/database/sequelize";
@@ -170,6 +170,128 @@ export default class SearchHelper {
return SearchHelper.buildResponse(results, documents, count); return SearchHelper.buildResponse(results, documents, count);
} }
public static async searchTitlesForUser(
user: User,
query: string,
options: SearchOptions = {}
): Promise<Document[]> {
const { limit = 15, offset = 0 } = options;
let where: WhereOptions<Document> = {
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( public static async searchForUser(
user: User, user: User,
query: string, query: string,

View File

@@ -1,3 +1,4 @@
import { subDays } from "date-fns";
import { CollectionPermission } from "@shared/types"; import { CollectionPermission } from "@shared/types";
import { import {
Document, Document,
@@ -1048,6 +1049,88 @@ describe("#documents.search_titles", () => {
expect(body.data[0].id).toEqual(document.id); 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 () => { it("should not include archived or deleted documents", async () => {
const user = await buildUser(); const user = await buildUser();
await buildDocument({ await buildDocument({

View File

@@ -588,43 +588,37 @@ router.post(
"documents.search_titles", "documents.search_titles",
auth(), auth(),
pagination(), pagination(),
validate(T.DocumentsSearchTitlesSchema), validate(T.DocumentsSearchSchema),
async (ctx: APIContext<T.DocumentsSearchTitlesReq>) => { async (ctx: APIContext<T.DocumentsSearchReq>) => {
const { query } = ctx.input; const {
query,
includeArchived,
includeDrafts,
dateFilter,
collectionId,
userId,
} = ctx.input;
const { offset, limit } = ctx.state.pagination; const { offset, limit } = ctx.state.pagination;
const { user } = ctx.state; const { user } = ctx.state;
let collaboratorIds = undefined;
const collectionIds = await user.collectionIds(); if (collectionId) {
const documents = await Document.scope([ const collection = await Collection.scope({
{ method: ["withMembership", user.id],
method: ["withViews", user.id], }).findByPk(collectionId);
}, authorize(user, "read", collection);
{ }
method: ["withCollectionPermissions", user.id],
}, if (userId) {
]).findAll({ collaboratorIds = [userId];
where: { }
title: {
[Op.iLike]: `%${query}%`, const documents = await SearchHelper.searchTitlesForUser(user, query, {
}, includeArchived,
collectionId: collectionIds, includeDrafts,
archivedAt: { dateFilter,
[Op.is]: null, collectionId,
}, collaboratorIds,
},
order: [["updatedAt", "DESC"]],
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
paranoid: false,
},
],
offset, offset,
limit, limit,
}); });

View File

@@ -138,12 +138,6 @@ export const DocumentsRestoreSchema = BaseIdSchema.extend({
export type DocumentsRestoreReq = z.infer<typeof DocumentsRestoreSchema>; export type DocumentsRestoreReq = z.infer<typeof DocumentsRestoreSchema>;
export const DocumentsSearchTitlesSchema = SearchQuerySchema.extend({});
export type DocumentsSearchTitlesReq = z.infer<
typeof DocumentsSearchTitlesSchema
>;
export const DocumentsSearchSchema = SearchQuerySchema.merge( export const DocumentsSearchSchema = SearchQuerySchema.merge(
DateFilterSchema DateFilterSchema
).extend({ ).extend({

View File

@@ -365,14 +365,19 @@ export async function buildDocument(
} }
count++; count++;
return Document.create({ return Document.create(
title: `Document ${count}`, {
text: "This is the text in an example document", title: `Document ${count}`,
publishedAt: isNull(overrides.collectionId) ? null : new Date(), text: "This is the text in an example document",
lastModifiedById: overrides.userId, publishedAt: isNull(overrides.collectionId) ? null : new Date(),
createdById: overrides.userId, lastModifiedById: overrides.userId,
...overrides, createdById: overrides.userId,
}); ...overrides,
},
{
silent: overrides.createdAt || overrides.updatedAt ? true : false,
}
);
} }
export async function buildFileOperation( export async function buildFileOperation(