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");
});
});
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 { 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<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(
user: User,
query: string,

View File

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

View File

@@ -588,43 +588,37 @@ router.post(
"documents.search_titles",
auth(),
pagination(),
validate(T.DocumentsSearchTitlesSchema),
async (ctx: APIContext<T.DocumentsSearchTitlesReq>) => {
const { query } = ctx.input;
validate(T.DocumentsSearchSchema),
async (ctx: APIContext<T.DocumentsSearchReq>) => {
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,
});

View File

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

View File

@@ -365,14 +365,19 @@ export async function buildDocument(
}
count++;
return Document.create({
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(