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/luna/src/locales/ar-eg.json b/packages/luna/src/locales/ar-eg.json index a0ba2145..64f58927 100644 --- a/packages/luna/src/locales/ar-eg.json +++ b/packages/luna/src/locales/ar-eg.json @@ -494,7 +494,6 @@ "student-dashboard.overview.total-lessons": "اجمالي الحصص", "student-dashboard.overview.completed-lessons": "الحصص المكتملة", "student-dashboard.overview.total-learning-time": "اجمالي وقت التعلم", - "student-dashboard.overview.total-learning-time.unit.hour": "{value} س", "student-dashboard.overview.total-learning-time.unit.minute": "{value} د", "student-dashboard.overview.teachers": "عدد المعلمين", "student-dashboard.loading": "برجاء الانتظار... جاري تحميل بيانات لوحتك الرئيسية!", @@ -718,4 +717,4 @@ "error.password.short": "رقم سري قصير", "error.password.long": "رقم سري طويل", "error.password.invalid": "يجب أن يحتوي علي أرقام، حروف ، رموز" -} +} \ No newline at end of file diff --git a/packages/types/src/topic.ts b/packages/types/src/topic.ts index 47cd2f06..66351b10 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 = { + addTopics: number[], + removeTopics: 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..cbead0b4 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, @@ -27,10 +29,16 @@ const updateTopicPayload = zod.object({ englishName: zod.optional(string), }); -const generalUserTopicsBodyParser = zod.object({ +const generalUserTopicsPayload = zod.object({ topicIds: zod.array(zod.number()), }); +const replaceUserTopicsPayload = zod.object({ + removeTopics: zod.array(id), + addTopics: zod.array(id), +}); + + const orderByOptions = [ "name_ar", "name_en", @@ -141,7 +149,7 @@ async function addUserTopics(req: Request, res: Response, next: NextFunction) { const allowed = isStudent(user) || isTutor(user) || isTutorManager(user); if (!allowed) return next(forbidden()); const { topicIds }: ITopic.AddUserTopicsApiPayload = - generalUserTopicsBodyParser.parse(req.body); + generalUserTopicsPayload.parse(req.body); // filter passed topics to ignore the ones that does already exist const userTopics = await topics.findUserTopics({ users: [user.id] }); @@ -170,12 +178,56 @@ 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 { removeTopics, addTopics }: ITopic.ReplaceUserTopicsApiPayload = + replaceUserTopicsPayload.parse(req.body); + + if (isEmpty(removeTopics) && isEmpty(addTopics)) return next(empty()); + + // ensure that all topics in `addTopics` exists for the current user + const inDBTopics = await topics.findUserTopics({ users: [user.id] }); + const inDBTopicIds = inDBTopics.map(topic => topic.id); + for (const topicId of removeTopics) { + if (!inDBTopicIds.includes(topicId)) + return next(notfound.topic()); + } + + // verify that all topics do exist in the database + const isExists = await topics.isExistsBatch(addTopics); + if (Object.values(isExists).includes(false)) + return next(notfound.topic()); + + // ensure that user topics will not exceed the max num after insertion + const exceededMaxAllowedTopics = + (inDBTopicIds.length + addTopics.length - removeTopics.length) > MAX_TOPICS_COUNT; + if (exceededMaxAllowedTopics) return next(refused()); + + await knex.transaction(async (tx: Knex.Transaction) => { + if (!isEmpty(removeTopics)) await topics.deleteUserTopics({ + user: user.id, + topics: removeTopics, + tx, + }); + + if(!isEmpty(addTopics)) await topics.registerUserTopics({ + user: user.id, + topics: addTopics + }, 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); if (!allowed) return next(forbidden()); const { topicIds }: ITopic.DeleteUserTopicsApiPayload = - generalUserTopicsBodyParser.parse(req.body); + generalUserTopicsPayload.parse(req.body); if (topicIds.length === 0) return next(bad()); @@ -202,4 +254,5 @@ export default { findUserTopics: safeRequest(findUserTopics), addUserTopics: safeRequest(addUserTopics), deleteUserTopics: safeRequest(deleteUserTopics), + replaceUserTopics: safeRequest(replaceUserTopics), }; 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..e49ff02d 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,210 @@ 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({ + removeTopics: mockTopicIds.slice(0,2), + addTopics: 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({ + removeTopics: mockTopicIds.slice(0,2), + addTopics: mockTopicIds.slice(3,4), + }) + ); + expect(res).to.deep.eq("OK"); + }); + + it("should respond with 403 if the number of topics will exceed the MAX_TOPICS_NUM.", async () => { + const studentApi = await Api.forStudent(); + const student = await studentApi.findCurrentUser(); + + const mockTopicIds = await Promise.all( + range(0, 31).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,30), + }); + + const res = await safe( + async () => studentApi.atlas.topic.replaceUserTopics({ + removeTopics: [], + addTopics: mockTopicIds.slice(30,31), + }) + ); + expect(res).to.deep.eq(refused()); + }); + + it("should respond with 200 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({ + removeTopics: [], + addTopics: mockTopicIds.slice(2,4), + }) + ); + expect(res1).to.eq("OK"); + + const res2 = await safe( + async () => studentApi.atlas.topic.replaceUserTopics({ + removeTopics: mockTopicIds.slice(0,2), + addTopics: [], + }) + ); + expect(res2).to.eq("OK"); + }); + + it("should respond with 400 when both lists are 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 res = await safe( + async () => studentApi.atlas.topic.replaceUserTopics({ + removeTopics: [], + addTopics: [], + }) + ); + expect(res).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({ + removeTopics: [...mockTopicIds.slice(0,2), 123], + addTopics: [...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({ + removeTopics: [...mockTopicIds.slice(0,2)], + addTopics: [...mockTopicIds.slice(2,3), 123], + }) + ); + expect(res).to.deep.eq(notfound.topic()); + }); + }); });