From cf64da1050ea21a62411164b7fb25f7797168fe2 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 6 Dec 2023 08:37:46 -0500 Subject: [PATCH] Add score column to `search_queries` (#6253) * Add score column to search_queries * Allow user to record search score --- .../20231206041706-search-query-score.js | 13 +++++ server/models/SearchQuery.ts | 15 ++++++ server/presenters/searchQuery.ts | 1 + server/routes/api/searches/schema.ts | 9 ++++ server/routes/api/searches/searches.test.ts | 51 +++++++++++++++++++ server/routes/api/searches/searches.ts | 31 ++++++++++- 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 server/migrations/20231206041706-search-query-score.js diff --git a/server/migrations/20231206041706-search-query-score.js b/server/migrations/20231206041706-search-query-score.js new file mode 100644 index 000000000..f1a962923 --- /dev/null +++ b/server/migrations/20231206041706-search-query-score.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("search_queries", "score", { + type: Sequelize.INTEGER, + allowNull: true, + }); + }, + async down(queryInterface) { + await queryInterface.removeColumn("search_queries", "score"); + }, +}; diff --git a/server/models/SearchQuery.ts b/server/models/SearchQuery.ts index 5ada3547b..6e465eff6 100644 --- a/server/models/SearchQuery.ts +++ b/server/models/SearchQuery.ts @@ -30,12 +30,27 @@ class SearchQuery extends Model { @CreatedAt createdAt: Date; + /** + * Where the query originated. + */ @Column(DataType.ENUM("slack", "app", "api")) source: string; + /** + * The number of results returned for this query. + */ @Column results: number; + /** + * User score for the results for this query, -1 for negative, 1 for positive, null for neutral. + */ + @Column + score: number; + + /** + * The query string, automatically truncated to 255 characters. + */ @Column(DataType.STRING) set query(value: string) { this.setDataValue("query", value.substring(0, 255)); diff --git a/server/presenters/searchQuery.ts b/server/presenters/searchQuery.ts index ef696569b..0cdc08cbe 100644 --- a/server/presenters/searchQuery.ts +++ b/server/presenters/searchQuery.ts @@ -5,5 +5,6 @@ export default function presentSearchQuery(searchQuery: SearchQuery) { id: searchQuery.id, query: searchQuery.query, createdAt: searchQuery.createdAt, + score: searchQuery.score, }; } diff --git a/server/routes/api/searches/schema.ts b/server/routes/api/searches/schema.ts index a8e184e33..fa5deaa16 100644 --- a/server/routes/api/searches/schema.ts +++ b/server/routes/api/searches/schema.ts @@ -12,3 +12,12 @@ export const SearchesDeleteSchema = BaseSchema.extend({ }); export type SearchesDeleteReq = z.infer; + +export const SearchesUpdateSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + score: z.number().min(-1).max(1), + }), +}); + +export type SearchesUpdateReq = z.infer; diff --git a/server/routes/api/searches/searches.test.ts b/server/routes/api/searches/searches.test.ts index 37d8a88b0..b2abdd4ef 100644 --- a/server/routes/api/searches/searches.test.ts +++ b/server/routes/api/searches/searches.test.ts @@ -44,6 +44,57 @@ describe("#searches.list", () => { }); }); +describe("#searches.update", () => { + let user: User; + let searchQuery: SearchQuery; + + beforeEach(async () => { + user = await buildUser(); + + searchQuery = await buildSearchQuery({ + userId: user.id, + teamId: user.teamId, + }); + }); + + it("should fail with status 400 bad request when an invalid id is provided", async () => { + const res = await server.post("/api/searches.update", { + body: { + token: user.getJwtToken(), + id: "id", + score: 1, + }, + }); + expect(res.status).toEqual(400); + }); + + it("should fail with status 400 bad request when an invalid score is provided", async () => { + const res = await server.post("/api/searches.update", { + body: { + token: user.getJwtToken(), + id: searchQuery.id, + score: 2, + }, + }); + expect(res.status).toEqual(400); + }); + + it("should succeed with status 200 ok and successfully update the query", async () => { + const res = await server.post("/api/searches.update", { + body: { + token: user.getJwtToken(), + id: searchQuery.id, + score: 1, + }, + }); + + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.score).toEqual(1); + }); +}); + describe("#searches.delete", () => { let user: User; let searchQuery: SearchQuery; diff --git a/server/routes/api/searches/searches.ts b/server/routes/api/searches/searches.ts index 1126ffd2f..f7aa7d8a5 100644 --- a/server/routes/api/searches/searches.ts +++ b/server/routes/api/searches/searches.ts @@ -1,5 +1,6 @@ import Router from "koa-router"; import auth from "@server/middlewares/authentication"; +import { transaction } from "@server/middlewares/transaction"; import validate from "@server/middlewares/validate"; import { SearchQuery } from "@server/models"; import { presentSearchQuery } from "@server/presenters"; @@ -27,13 +28,41 @@ router.post("searches.list", auth(), pagination(), async (ctx: APIContext) => { }; }); +router.post( + "searches.update", + auth(), + validate(T.SearchesUpdateSchema), + transaction(), + async (ctx: APIContext) => { + const { id, score } = ctx.input.body; + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + + const search = await SearchQuery.findOne({ + where: { + id, + userId: user.id, + }, + lock: transaction.LOCK.UPDATE, + rejectOnEmpty: true, + transaction, + }); + + search.score = score; + await search.save({ transaction }); + + ctx.body = { + data: presentSearchQuery(search), + }; + } +); + router.post( "searches.delete", auth(), validate(T.SearchesDeleteSchema), async (ctx: APIContext) => { const { id, query } = ctx.input.body; - const { user } = ctx.state.auth; await SearchQuery.destroy({