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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user