From ae903499245e156e09035f1eb45fd0a30631683f Mon Sep 17 00:00:00 2001 From: "M. E. Abdelsalam" Date: Thu, 26 Dec 2024 15:44:17 +0200 Subject: [PATCH] added: api endpoint for replacing batch of user topics with new one. --- packages/atlas/src/base.ts | 6 + packages/atlas/src/topic.ts | 4 + packages/types/src/topic.ts | 4 + services/server/src/handlers/topic.ts | 49 ++++++++ services/server/src/routes/topic.ts | 1 + services/server/tests/api/topic.test.ts | 152 +++++++++++++++++++++++- 6 files changed, 215 insertions(+), 1 deletion(-) diff --git a/packages/atlas/src/base.ts b/packages/atlas/src/base.ts index ca3219c5..8221d614 100644 --- a/packages/atlas/src/base.ts +++ b/packages/atlas/src/base.ts @@ -27,6 +27,12 @@ export class Base { .then((response) => response.data); } + async patch(attr: HTTPMethodAttr): Promise { + return this.client + .patch(attr.route, JSON.stringify(attr.payload)) + .then((response) => response.data); + } + async del(attr: HTTPMethodAttr): Promise { return this.client .delete(attr.route, { diff --git a/packages/atlas/src/topic.ts b/packages/atlas/src/topic.ts index bab9d63f..d149e803 100644 --- a/packages/atlas/src/topic.ts +++ b/packages/atlas/src/topic.ts @@ -36,4 +36,8 @@ export class Topic extends Base { async deleteUserTopics(payload: ITopic.DeleteUserTopicsApiPayload): Promise { return await this.del({ route: `/api/v1/topic/of/user`, payload }); } + + async replaceUserTopics(payload: ITopic.ReplaceUserTopicsApiPayload): Promise { + return await this.patch({ route: `/api/v1/topic/of/user`, payload }); + } } diff --git a/packages/types/src/topic.ts b/packages/types/src/topic.ts index 47cd2f06..95c91cff 100644 --- a/packages/types/src/topic.ts +++ b/packages/types/src/topic.ts @@ -67,6 +67,10 @@ export type UpdateApiPayload = UpdatePayload; export type AddUserTopicsApiPayload = { topicIds: number[] }; export type DeleteUserTopicsApiPayload = { topicIds: number[] } +export type ReplaceUserTopicsApiPayload = { + oldTopics: number[], + newTopics: number[], +} export type FindTopicsQueryFilter = IFilter.Pagination & { name?: string; diff --git a/services/server/src/handlers/topic.ts b/services/server/src/handlers/topic.ts index e0c35a70..19fc4142 100644 --- a/services/server/src/handlers/topic.ts +++ b/services/server/src/handlers/topic.ts @@ -1,5 +1,6 @@ import { apierror, bad, empty, forbidden, notfound, refused } from "@/lib/error"; import { + id, orderDirection, pageNumber, pageSize, @@ -16,6 +17,7 @@ import zod from "zod"; import { Knex } from "knex"; import { MAX_TOPICS_COUNT } from "@litespace/sol"; import { FindUserTopicsApiResponse } from "@litespace/types/dist/esm/topic"; +import { isEmpty } from "lodash"; const createTopicPayload = zod.object({ arabicName: string, @@ -31,6 +33,12 @@ const generalUserTopicsBodyParser = zod.object({ topicIds: zod.array(zod.number()), }); +const replaceUserTopicsBodyParser = zod.object({ + oldTopics: zod.array(id), + newTopics: zod.array(id), +}); + + const orderByOptions = [ "name_ar", "name_en", @@ -170,6 +178,46 @@ async function addUserTopics(req: Request, res: Response, next: NextFunction) { res.sendStatus(200); } +async function replaceUserTopics(req: Request, res: Response, next: NextFunction) { + const user = req.user; + const allowed = isStudent(user) || isTutor(user) || isTutorManager(user); + if (!allowed) return next(forbidden()); + + const { oldTopics, newTopics }: ITopic.ReplaceUserTopicsApiPayload = + replaceUserTopicsBodyParser.parse(req.body); + + if (isEmpty(oldTopics) || isEmpty(newTopics)) return next(empty()); + // to not leave a vulnerability which can make the user exceed the max number + if (oldTopics.length !== newTopics.length) return next(bad()); + + // ensure that all oldTopics exists for the user + const inDBTopics = await topics.findUserTopics({ users: [user.id] }); + const inDBTopicIds = inDBTopics.map(topic => topic.id); + for (const topicId of oldTopics) { + if (!inDBTopicIds.includes(topicId)) + return next(notfound.topic()); + } + + // verify that all topics do exist in the database + const isExists = await topics.isExistsBatch(newTopics); + if (Object.values(isExists).includes(false)) + return next(notfound.topic()); + + await knex.transaction(async (tx: Knex.Transaction) => { + await topics.deleteUserTopics({ + user: user.id, + topics: oldTopics, + tx, + }); + await topics.registerUserTopics({ + user: user.id, + topics: newTopics + }, tx); + }); + + res.sendStatus(200); +} + async function deleteUserTopics(req: Request, res: Response, next: NextFunction) { const user = req.user; const allowed = isStudent(user) || isTutor(user) || isTutorManager(user); @@ -197,6 +245,7 @@ async function deleteUserTopics(req: Request, res: Response, next: NextFunction) export default { createTopic: safeRequest(createTopic), updateTopic: safeRequest(updateTopic), + replaceUserTopics: safeRequest(replaceUserTopics), deleteTopic: safeRequest(deleteTopic), findTopics: safeRequest(findTopics), findUserTopics: safeRequest(findUserTopics), diff --git a/services/server/src/routes/topic.ts b/services/server/src/routes/topic.ts index 5e2520b9..55d6ece2 100644 --- a/services/server/src/routes/topic.ts +++ b/services/server/src/routes/topic.ts @@ -11,5 +11,6 @@ router.get("/list", topic.findTopics); router.get("/of/user", topic.findUserTopics); router.post("/of/user", topic.addUserTopics); router.delete("/of/user", topic.deleteUserTopics); +router.patch("/of/user", topic.replaceUserTopics); export default router; diff --git a/services/server/tests/api/topic.test.ts b/services/server/tests/api/topic.test.ts index d310a541..1fa64c63 100644 --- a/services/server/tests/api/topic.test.ts +++ b/services/server/tests/api/topic.test.ts @@ -1,4 +1,4 @@ -import { forbidden, notfound, refused } from "@/lib/error"; +import { bad, empty, forbidden, notfound, refused } from "@/lib/error"; import { Api } from "@fixtures/api"; import db, { faker } from "@fixtures/db"; import { topics } from "@litespace/models"; @@ -163,4 +163,154 @@ describe("/api/v1/topic/", () => { expect(myTopics).to.have.length(3); }); }); + + describe("PATCH /api/v1/topic/of/user", () => { + it("should respond with 200 and replace users list of topics with new one.", async () => { + const studentApi = await Api.forStudent(); + const student = await studentApi.findCurrentUser(); + + const mockTopicIds = await Promise.all( + range(0, 4).map( + async (i) => (await db.topic({ + name: { + ar: `${faker.animal.bear()}-${i}`, + en: `${faker.animal.bird()}-${i}`, + } + })).id) + ); + + await topics.registerUserTopics({ + user: student.user.id, + topics: mockTopicIds.slice(0,2), + }); + + const res = await safe( + async () => studentApi.atlas.topic.replaceUserTopics({ + oldTopics: mockTopicIds.slice(0,2), + newTopics: mockTopicIds.slice(2,4), + }) + ); + expect(res).to.eq("OK"); + }); + + it("should respond with 400 when the old and new list have different lengths.", async () => { + const studentApi = await Api.forStudent(); + const student = await studentApi.findCurrentUser(); + + const mockTopicIds = await Promise.all( + range(0, 4).map( + async (i) => (await db.topic({ + name: { + ar: `${faker.animal.bear()}-${i}`, + en: `${faker.animal.bird()}-${i}`, + } + })).id) + ); + + await topics.registerUserTopics({ + user: student.user.id, + topics: mockTopicIds.slice(0,2), + }); + + const res = await safe( + async () => studentApi.atlas.topic.replaceUserTopics({ + oldTopics: mockTopicIds.slice(0,2), + newTopics: mockTopicIds.slice(3,4), + }) + ); + expect(res).to.deep.eq(bad()); + }); + + it("should respond with 400 when any of the lists is empty.", async () => { + const studentApi = await Api.forStudent(); + const student = await studentApi.findCurrentUser(); + + const mockTopicIds = await Promise.all( + range(0, 4).map( + async (i) => (await db.topic({ + name: { + ar: `${faker.animal.bear()}-${i}`, + en: `${faker.animal.bird()}-${i}`, + } + })).id) + ); + + await topics.registerUserTopics({ + user: student.user.id, + topics: mockTopicIds.slice(0,2), + }); + + const res1 = await safe( + async () => studentApi.atlas.topic.replaceUserTopics({ + oldTopics: [], + newTopics: mockTopicIds.slice(2,4), + }) + ); + expect(res1).to.deep.eq(empty()); + + const res2 = await safe( + async () => studentApi.atlas.topic.replaceUserTopics({ + oldTopics: mockTopicIds.slice(0,2), + newTopics: [], + }) + ); + expect(res2).to.deep.eq(empty()); + }); + + it("should respond with 404 when any of ids of the oldTopics list doesn't exists.", async () => { + const studentApi = await Api.forStudent(); + const student = await studentApi.findCurrentUser(); + + const mockTopicIds = await Promise.all( + range(0, 4).map( + async (i) => (await db.topic({ + name: { + ar: `${faker.animal.bear()}-${i}`, + en: `${faker.animal.bird()}-${i}`, + } + })).id) + ); + + await topics.registerUserTopics({ + user: student.user.id, + topics: mockTopicIds.slice(0,2), + }); + + const res = await safe( + async () => studentApi.atlas.topic.replaceUserTopics({ + oldTopics: [...mockTopicIds.slice(0,2), 123], + newTopics: [...mockTopicIds.slice(2,4), 321], + }) + ); + expect(res).to.deep.eq(notfound.topic()); + }); + + it("should respond with 404 when any of ids of the newTopics list doesn't exists.", async () => { + const studentApi = await Api.forStudent(); + const student = await studentApi.findCurrentUser(); + + const mockTopicIds = await Promise.all( + range(0, 4).map( + async (i) => (await db.topic({ + name: { + ar: `${faker.animal.bear()}-${i}`, + en: `${faker.animal.bird()}-${i}`, + } + })).id) + ); + + await topics.registerUserTopics({ + user: student.user.id, + topics: mockTopicIds.slice(0,2), + }); + + const res = await safe( + async () => studentApi.atlas.topic.replaceUserTopics({ + oldTopics: [...mockTopicIds.slice(0,2)], + newTopics: [...mockTopicIds.slice(2,3), 123], + }) + ); + expect(res).to.deep.eq(notfound.topic()); + }); + }); });