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