diff --git a/packages/atlas/src/auth.ts b/packages/atlas/src/auth.ts index 4f5b3f903..9cb7bb188 100644 --- a/packages/atlas/src/auth.ts +++ b/packages/atlas/src/auth.ts @@ -5,37 +5,58 @@ export class Auth extends Base { async password( credentials: IUser.Credentials ): Promise { - return this.post("/api/v1/auth/password", credentials); + return this.post({ + route: "/api/v1/auth/password", + payload: credentials, + }); } async google( token: string, role?: typeof IUser.Role.Student | typeof IUser.Role.Tutor ): Promise { - return this.post("/api/v1/auth/google", { token, role }); + return this.post({ + route: "/api/v1/auth/google", + payload: { token, role }, + }); } async forgotPassword( payload: IUser.ForegetPasswordApiPayload ): Promise { - await this.post("/api/v1/auth/password/forgot", payload); + await this.post({ + route: "/api/v1/auth/password/forgot", + payload, + }); } async resetPassword( payload: IUser.ResetPasswordApiPayload ): Promise { - return await this.put("/api/v1/auth/password/reset", payload); + return await this.put({ + route: "/api/v1/auth/password/reset", + payload, + }); } async token(token: string) { - await this.post("/api/v1/auth/token", { token }); + await this.post({ + route: "/api/v1/auth/token", + payload: { token }, + }); } async verifyEmail(token: string): Promise { - await this.put("/api/v1/auth/verify-email", { token }); + await this.put({ + route: "/api/v1/auth/verify-email", + payload: { token }, + }); } async sendVerifyEmail(callbackUrl: string): Promise { - await this.put("/api/v1/auth/send-verify-email", { callbackUrl }); + await this.put({ + route: "/api/v1/auth/send-verify-email", + payload: { callbackUrl }, + }); } } diff --git a/packages/atlas/src/base.ts b/packages/atlas/src/base.ts index 10070df6c..ca3219c55 100644 --- a/packages/atlas/src/base.ts +++ b/packages/atlas/src/base.ts @@ -2,6 +2,12 @@ import { Backend } from "@litespace/types"; import { AxiosInstance } from "axios"; import { createClient, AuthToken } from "@/client"; +type HTTPMethodAttr = { + route: string; + payload?: T; + params?: P; +} + export class Base { public readonly client: AxiosInstance; @@ -9,31 +15,33 @@ export class Base { this.client = createClient(backend, token); } - async post(route: string, payload?: T): Promise { + async post(attr: HTTPMethodAttr): Promise { return this.client - .post(route, payload ? JSON.stringify(payload) : undefined) + .post(attr.route, attr.payload ? JSON.stringify(attr.payload) : undefined) .then((response) => response.data); } - async put(route: string, payload: T): Promise { + async put(attr: HTTPMethodAttr): Promise { return this.client - .put(route, JSON.stringify(payload)) + .put(attr.route, JSON.stringify(attr.payload)) .then((response) => response.data); } - async del(route: string, params?: P): Promise { + async del(attr: HTTPMethodAttr): Promise { return this.client - .delete(route, { params }) + .delete(attr.route, { + data: JSON.stringify(attr.payload), + params: attr.params, + }) .then((response) => response.data); } - async get( - route: string, - payload?: T, - params?: P - ): Promise { + async get(attr: HTTPMethodAttr): Promise { return this.client - .get(route, { data: JSON.stringify(payload), params }) + .get(attr.route, { + data: JSON.stringify(attr.payload), + params: attr.params, + }) .then((response) => response.data); } } diff --git a/packages/atlas/src/cache.ts b/packages/atlas/src/cache.ts index 4963d451e..cadf6f3c9 100644 --- a/packages/atlas/src/cache.ts +++ b/packages/atlas/src/cache.ts @@ -2,6 +2,6 @@ import { Base } from "@/base"; export class Cache extends Base { public async flush(): Promise { - return await this.del(`/api/v1/cache/flush`); + return await this.del({ route: `/api/v1/cache/flush` }); } } diff --git a/packages/atlas/src/call.ts b/packages/atlas/src/call.ts index 1cd6aa221..75496a333 100644 --- a/packages/atlas/src/call.ts +++ b/packages/atlas/src/call.ts @@ -3,17 +3,17 @@ import { ICall } from "@litespace/types"; export class Call extends Base { async findUserCalls(id: number): Promise { - return await this.get(`/api/v1/call/list/user/${id}`); + return await this.get({ route: `/api/v1/call/list/user/${id}` }); } async findById(id: number): Promise { - return await this.get(`/api/v1/call/${id}`); + return await this.get({ route: `/api/v1/call/${id}` }); } async findCallMembers( id: number, type: ICall.Type ): Promise { - return await this.get(`/api/v1/call/${id}/${type}/members`); + return await this.get({ route: `/api/v1/call/${id}/${type}/members` }); } } diff --git a/packages/atlas/src/chat.ts b/packages/atlas/src/chat.ts index 0742e9169..9a345b3de 100644 --- a/packages/atlas/src/chat.ts +++ b/packages/atlas/src/chat.ts @@ -3,43 +3,55 @@ import { IFilter, IMessage, IRoom } from "@litespace/types"; export class Chat extends Base { async createRoom(userId: number): Promise { - return await this.post(`/api/v1/chat/${userId}`); + return await this.post({ route: `/api/v1/chat/${userId}` }); } async findRoomMessages( id: number, pagination?: IFilter.Pagination ): Promise { - return await this.get(`/api/v1/chat/list/${id}/messages`, {}, pagination); + return await this.get({ + route: `/api/v1/chat/list/${id}/messages`, + params: pagination, + }); } async findRooms( userId: number, query?: IRoom.FindUserRoomsApiQuery ): Promise { - return await this.get(`/api/v1/chat/list/rooms/${userId}/`, {}, query); + return await this.get({ + route: `/api/v1/chat/list/rooms/${userId}/`, + params: query, + }); } async findRoomByMembers( members: number[] ): Promise { - return await this.get("/api/v1/chat/room/by/members", {}, { members }); + return await this.get({ + route:"/api/v1/chat/room/by/members", + params: { members }, + }); } async findRoomMembers( room: number ): Promise { - return await this.get(`/api/v1/chat/room/members/${room}`); + return await this.get({ route: `/api/v1/chat/room/members/${room}` }); } async findCallRoom(call: number): Promise { - return await this.get(`/api/v1/chat/room/call/${call}`); + return await this.get({ route: `/api/v1/chat/room/call/${call}` }); } async updateRoom( room: number, payload: IRoom.UpdateRoomApiPayload ): Promise { - return await this.put(`/api/v1/chat/room/${room}`, payload); + return await this.put({ + route: `/api/v1/chat/room/${room}`, + payload, + }); } } diff --git a/packages/atlas/src/coupon.ts b/packages/atlas/src/coupon.ts index 0df8d62e1..5f3d04eb9 100644 --- a/packages/atlas/src/coupon.ts +++ b/packages/atlas/src/coupon.ts @@ -3,29 +3,35 @@ import { ICoupon } from "@litespace/types"; export class Coupon extends Base { async create(payload: ICoupon.CreateApiPayload): Promise { - return this.post(`/api/v1/coupon`, payload); + return this.post({ + route: `/api/v1/coupon`, + payload, + }); } async update( id: number, payload: ICoupon.UpdateApiPayload ): Promise { - return this.put(`/api/v1/coupon/${id}`, payload); + return this.put({ + route: `/api/v1/coupon/${id}`, + payload, + }); } async delete(id: number): Promise { - await this.del(`/api/v1/coupon/${id}`); + await this.del({ route: `/api/v1/coupon/${id} `}); } async findById(id: number): Promise { - return this.get(`/api/v1/coupon/${id}`); + return this.get({ route: `/api/v1/coupon/${id}` }); } async findByCode(code: string): Promise { - return this.get(`/api/v1/coupon/code/${code}`); + return this.get({ route: `/api/v1/coupon/code/${code}` }); } async find(): Promise { - return this.get(`/api/v1/coupon/list`); + return this.get({ route: `/api/v1/coupon/list` }); } } diff --git a/packages/atlas/src/interview.ts b/packages/atlas/src/interview.ts index 32e5254a8..d2f0c926a 100644 --- a/packages/atlas/src/interview.ts +++ b/packages/atlas/src/interview.ts @@ -1,27 +1,36 @@ import { Base } from "@/base"; -import { IFilter, IInterview } from "@litespace/types"; +import { IInterview } from "@litespace/types"; export class Interview extends Base { public async create( payload: IInterview.CreateApiPayload ): Promise { - return await this.post(`/api/v1/interview`, payload); + return await this.post({ + route: `/api/v1/interview`, + payload, + }); } public async update( id: number, payload: IInterview.UpdateApiPayload ): Promise { - return await this.put(`/api/v1/interview/${id}`, payload); + return await this.put({ + route: `/api/v1/interview/${id}`, + payload, + }); } public async findInterviews( query: IInterview.FindInterviewsApiQuery ): Promise { - return this.get("/api/v1/interview/list/", null, query); + return this.get({ + route: "/api/v1/interview/list/", + params: query + }); } public async findInterviewById(id: number): Promise { - return this.get(`/api/v1/interview/${id}`); + return this.get({ route: `/api/v1/interview/${id}` }); } } diff --git a/packages/atlas/src/invoice.ts b/packages/atlas/src/invoice.ts index 4223ad655..3538582f9 100644 --- a/packages/atlas/src/invoice.ts +++ b/packages/atlas/src/invoice.ts @@ -4,18 +4,24 @@ import { safeFormData } from "@/lib/form"; export class Invoice extends Base { async create(payload: IInvoice.CreateApiPayload): Promise { - return await this.post("/api/v1/invoice", payload); + return await this.post({ + route: "/api/v1/invoice", + payload + }); } async stats(userId: number): Promise { - return await this.get(`/api/v1/invoice/stats/${userId}`); + return await this.get({ route: `/api/v1/invoice/stats/${userId}` }); } async updateByReceiver( invoiceId: number, payload: IInvoice.UpdateByReceiverApiPayload ): Promise { - return await this.put(`/api/v1/invoice/receiver/${invoiceId}`, payload); + return await this.put({ + route: `/api/v1/invoice/receiver/${invoiceId}`, + payload, + }); } async updateByAdmin( @@ -44,10 +50,13 @@ export class Invoice extends Base { async find( params: IInvoice.FindInvoicesQuery ): Promise> { - return this.get("/api/v1/invoice/list", null, params); + return this.get({ + route: "/api/v1/invoice/list", + params, + }); } async cancel(invoiceId: number): Promise { - return this.del(`/api/v1/invoice/${invoiceId}`); + return this.del({ route: `/api/v1/invoice/${invoiceId}` }); } } diff --git a/packages/atlas/src/lesson.ts b/packages/atlas/src/lesson.ts index 023150bf9..347576301 100644 --- a/packages/atlas/src/lesson.ts +++ b/packages/atlas/src/lesson.ts @@ -5,16 +5,22 @@ export class Lesson extends Base { async create( payload: ILesson.CreateApiPayload ): Promise { - return this.post(`/api/v1/lesson/`, payload); + return this.post({ + route: `/api/v1/lesson/`, + payload, + }); } async findLessons( query: ILesson.FindLessonsApiQuery ): Promise { - return this.get(`/api/v1/lesson/list/`, {}, query); + return this.get({ + route: `/api/v1/lesson/list/`, + params: query, + }); } async cancel(id: number): Promise { - return this.del(`/api/v1/lesson/${id}`); + return this.del({ route: `/api/v1/lesson/${id}` }); } } diff --git a/packages/atlas/src/peer.ts b/packages/atlas/src/peer.ts index 06f661960..920fc0b23 100644 --- a/packages/atlas/src/peer.ts +++ b/packages/atlas/src/peer.ts @@ -6,19 +6,19 @@ export class Peer extends Base { * @deprecated we will use web sockets to register peer ids */ async register(payload: IPeer.RegisterPeerIdApiPayload) { - await this.post("/api/v1/peer", payload); + await this.post({ route: "/api/v1/peer", payload }); } /** * @deprecated we will use web sockets to de-register peer ids */ async delete(payload: IPeer.DeletePeerIdApiQurey) { - await this.del("/api/v1/peer", payload); + await this.del({ route: "/api/v1/peer", payload }); } async findPeerId( payload: IPeer.FindPeerIdApiQuery ): Promise { - return this.get("/api/v1/peer", null, payload); + return this.get({ route: "/api/v1/peer", payload }); } } diff --git a/packages/atlas/src/plan.ts b/packages/atlas/src/plan.ts index 202022a04..63e0f7650 100644 --- a/packages/atlas/src/plan.ts +++ b/packages/atlas/src/plan.ts @@ -3,24 +3,24 @@ import { IFilter, IPlan } from "@litespace/types"; export class Plan extends Base { async create(payload: IPlan.CreateApiPayload): Promise { - await this.post("/api/v1/plan", payload); + await this.post({ route: "/api/v1/plan", payload }); } async update(id: number, payload: IPlan.UpdateApiPayload) { - await this.put(`/api/v1/plan/${id}`, payload); + await this.put({ route: `/api/v1/plan/${id}`, payload }); } async delete(id: number) { - await this.del(`/api/v1/plan/${id}`); + await this.del({ route: `/api/v1/plan/${id}` }); } async findById(id: number): Promise { - return this.get(`/api/v1/plan/${id}`); + return this.get({ route: `/api/v1/plan/${id}` }); } async find( pagination?: IFilter.Pagination ): Promise { - return this.get(`/api/v1/plan/list`, null, pagination); + return this.get({ route: `/api/v1/plan/list`, params: pagination }); } } diff --git a/packages/atlas/src/rating.ts b/packages/atlas/src/rating.ts index fa98da24c..9aeb0f7c5 100644 --- a/packages/atlas/src/rating.ts +++ b/packages/atlas/src/rating.ts @@ -3,7 +3,7 @@ import { IFilter, IRating } from "@litespace/types"; export class Rating extends Base { async findById(id: number): Promise { - return await this.get(`/api/v1/rating/${id}`); + return await this.get({ route: `/api/v1/rating/${id}` }); } async create(payload: IRating.CreateApiPayload): Promise { @@ -38,6 +38,6 @@ export class Rating extends Base { id: number, pagination?: IFilter.Pagination ): Promise { - return await this.get(`/api/v1/rating/list/tutor/${id}`, {}, pagination); + return await this.get({ route: `/api/v1/rating/list/tutor/${id}`, params: pagination }); } } diff --git a/packages/atlas/src/rule.ts b/packages/atlas/src/rule.ts index e9638d444..f786f4a86 100644 --- a/packages/atlas/src/rule.ts +++ b/packages/atlas/src/rule.ts @@ -3,7 +3,7 @@ import { IRule } from "@litespace/types"; export class Rule extends Base { async create(payload: IRule.CreateApiPayload): Promise { - return await this.post(`/api/v1/rule/`, payload); + return await this.post({ route: `/api/v1/rule/`, payload }); } /** @@ -11,7 +11,7 @@ export class Rule extends Base { * @deprecated should be removed in favor of {@link findUserRulesWithSlots} */ async findUserRules(id: number): Promise { - return await this.get(`/api/v1/rule/list/${id}`); + return await this.get({ route: `/api/v1/rule/list/${id}` }); } async findUserRulesWithSlots({ @@ -24,7 +24,7 @@ export class Rule extends Base { */ id: number; } & IRule.FindRulesWithSlotsApiQuery): Promise { - return await this.get(`/api/v1/rule/slots/${id}`, {}, { after, before }); + return await this.get({ route: `/api/v1/rule/slots/${id}`, params: { after, before }}); } /** @@ -37,24 +37,23 @@ export class Rule extends Base { start: string, end: string ): Promise { - return await this.get( - `/api/v1/rule/list/unpacked/${id}`, - {}, - { + return await this.get({ + route: `/api/v1/rule/list/unpacked/${id}`, + params: { start, end, } - ); + }); } async update( id: number, payload: IRule.UpdateApiPayload ): Promise { - return await this.put(`/api/v1/rule/${id}`, payload); + return await this.put({ route: `/api/v1/rule/${id}`, payload }); } async delete(id: number): Promise { - return await this.del(`/api/v1/rule/${id}`); + return await this.del({ route: `/api/v1/rule/${id}` }); } } diff --git a/packages/atlas/src/topic.ts b/packages/atlas/src/topic.ts index df87c6db2..bab9d63f7 100644 --- a/packages/atlas/src/topic.ts +++ b/packages/atlas/src/topic.ts @@ -5,23 +5,35 @@ export class Topic extends Base { async create( payload: ITopic.CreateApiPayload ): Promise { - return await this.post("/api/v1/topic/", payload); + return await this.post({ route: "/api/v1/topic/", payload }); } async findTopics( params: ITopic.FindTopicsApiQuery ): Promise { - return await this.get(`/api/v1/topic/list`, null, params); + return await this.get({ route: `/api/v1/topic/list`, params }); } async updateTopic( id: number, payload: ITopic.UpdateApiPayload ): Promise { - return await this.put(`/api/v1/topic/${id}`, payload); + return await this.put({ route: `/api/v1/topic/${id}`, payload }); } async deleteTopic(id: number) { - return await this.del(`/api/v1/topic/${id}`); + return await this.del({ route: `/api/v1/topic/${id}` }); + } + + async findUserTopics(): Promise { + return await this.get({ route: `/api/v1/topic/of/user` }); + } + + async addUserTopics(payload: ITopic.AddUserTopicsApiPayload): Promise { + return await this.post({ route: `/api/v1/topic/of/user`, payload }); + } + + async deleteUserTopics(payload: ITopic.DeleteUserTopicsApiPayload): Promise { + return await this.del({ route: `/api/v1/topic/of/user`, payload }); } } diff --git a/packages/atlas/src/user.ts b/packages/atlas/src/user.ts index 6c81b9829..fd543230c 100644 --- a/packages/atlas/src/user.ts +++ b/packages/atlas/src/user.ts @@ -1,11 +1,11 @@ import { Base } from "@/base"; -import { IFilter, ITutor, IUser, PagniationParams } from "@litespace/types"; +import { IFilter, ITutor, IUser } from "@litespace/types"; export class User extends Base { async create( payload: IUser.CreateApiPayload ): Promise { - return this.post("/api/v1/user/", payload); + return this.post({ route: "/api/v1/user/", payload }); } async findById(id: number | string): Promise { @@ -18,20 +18,20 @@ export class User extends Base { } async findCurrentUser(): Promise { - return await this.get("/api/v1/user/current"); + return await this.get({ route: "/api/v1/user/current" }); } async find( query: IUser.FindUsersApiQuery ): Promise { - return this.get(`/api/v1/user/list`, null, query); + return this.get({ route: `/api/v1/user/list`, params: query }); } async update( id: number, payload: IUser.UpdateApiPayload ): Promise { - return this.put(`/api/v1/user/${id}`, payload); + return this.put({ route: `/api/v1/user/${id}`, payload }); } async updateMedia( @@ -63,40 +63,40 @@ export class User extends Base { } async findTutorMeta(tutorId: number): Promise { - return await this.get(`/api/v1/user/tutor/meta/${tutorId}`); + return await this.get({ route: `/api/v1/user/tutor/meta/${tutorId}` }); } async findTutorInfo(id: number): Promise { - return await this.get(`/api/v1/user/tutor/info/${id}`); + return await this.get({ route: `/api/v1/user/tutor/info/${id}` }); } async findOnboardedTutors( params?: ITutor.FindOnboardedTutorsParams ): Promise { - return await this.get(`/api/v1/user/tutor/list/onboarded`, {}, params); + return await this.get({ route: `/api/v1/user/tutor/list/onboarded`, params }); } async findTutorsForMediaProvider( pagination?: IFilter.Pagination ): Promise { - return this.get(`/api/v1/user/media-provider/tutors`, {}, pagination); + return this.get({ route: `/api/v1/user/media-provider/tutors`, params: pagination }); } async findTutorStats( tutor: number ): Promise { - return await this.get(`/api/v1/user/tutor/stats/${tutor}`); + return await this.get({ route: `/api/v1/user/tutor/stats/${tutor}` }); } async findStudentStats( student: number ): Promise { - return await this.get(`/api/v1/user/student/stats/${student}`); + return await this.get({ route: `/api/v1/user/student/stats/${student}` }); } async findTutorActivityScores( tutor: number ): Promise { - return await this.get(`/api/v1/user/tutor/activity/${tutor}`); + return await this.get({ route: `/api/v1/user/tutor/activity/${tutor}` }); } } diff --git a/packages/atlas/src/withdrawMethod.ts b/packages/atlas/src/withdrawMethod.ts index b1e682ce9..c38a6048f 100644 --- a/packages/atlas/src/withdrawMethod.ts +++ b/packages/atlas/src/withdrawMethod.ts @@ -5,17 +5,17 @@ export class WithdrawMethod extends Base { async create( payload: IWithdrawMethod.CreatePayload ): Promise { - return this.post("/api/v1/withdraw-method", payload); + return this.post({ route: "/api/v1/withdraw-method", payload }); } async update( type: IWithdrawMethod.Type, payload: IWithdrawMethod.UpdatePayload ): Promise { - return this.put(`/api/v1/withdraw-method/${type}`, payload); + return this.put({ route: `/api/v1/withdraw-method/${type}`, payload }); } async find(): Promise { - return this.get(`/api/v1/withdraw-method/list`); + return this.get({ route: `/api/v1/withdraw-method/list` }); } } diff --git a/packages/models/src/topics.ts b/packages/models/src/topics.ts index 3e663e023..030143039 100644 --- a/packages/models/src/topics.ts +++ b/packages/models/src/topics.ts @@ -71,17 +71,17 @@ export class Topics { async deleteUserTopics({ user, - topic, + topics, tx, }: { user: number; - topic: number; + topics: number[]; tx?: Knex.Transaction; }): Promise { await this.builder(tx) .userTopics.delete() .where(this.column.userTopics("user_id"), user) - .andWhere(this.column.userTopics("topic_id"), topic); + .whereIn(this.column.userTopics("topic_id"), topics); } async findById( @@ -107,7 +107,7 @@ export class Topics { id: this.column.topics("id"), name_ar: this.column.topics("name_ar"), name_en: this.column.topics("name_en"), - created_at: this.column.topics("name_en"), + created_at: this.column.topics("created_at"), updated_at: this.column.topics("updated_at"), user_id: this.column.userTopics("user_id"), }; @@ -159,6 +159,23 @@ export class Topics { }; } + async isExistsBatch( + ids: number[], + tx?: Knex.Transaction + ): Promise<{ [x: number]: boolean }> { + const baseBuilder = this.builder(tx).topics; + const rows = await baseBuilder + .select[]>(this.column.topics("id")) + .whereIn(this.column.topics("id"), ids); + + const existanceMap: { [x: number]: boolean } = {}; + ids.forEach(id => { + existanceMap[id] = rows.find(row => row.id === id) !== undefined; + }); + + return existanceMap; + } + builder(tx?: Knex.Transaction) { const builder = tx || knex; return { diff --git a/packages/models/tests/topics.test.ts b/packages/models/tests/topics.test.ts index a6544d3ab..9c7b69fc6 100644 --- a/packages/models/tests/topics.test.ts +++ b/packages/models/tests/topics.test.ts @@ -3,6 +3,8 @@ import { nameof } from "@litespace/sol/utils"; import { knex, topics } from "@/index"; import { expect } from "chai"; import dayjs from "@/lib/dayjs"; +import { IUser } from "@litespace/types"; +import { first } from "lodash"; describe("Topics", () => { beforeEach(async () => { @@ -136,4 +138,45 @@ describe("Topics", () => { }); }); }); + + describe(nameof(topics.isExistsBatch), () => { + it("should return a map that tells if the passed topic ids exists in the db or not.", async () => { + const topic1 = await fixtures.topic(); + const topic2 = await fixtures.topic(); + + const list = [topic1.id, topic2.id, 123]; + const existanceMap = await topics.isExistsBatch(list); + + expect(existanceMap[topic1.id]).to.eq(true); + expect(existanceMap[topic2.id]).to.eq(true); + expect(existanceMap[123]).to.eq(false); + }); + }); + + describe(nameof(topics.deleteUserTopics), () => { + it("should delete list of topics for a specific user.", async () => { + const user = await fixtures.user({ role: IUser.Role.Student }); + + const topic1 = await fixtures.topic(); + const topic2 = await fixtures.topic(); + + const list = [topic1.id, topic2.id]; + await topics.registerUserTopics({ + user: user.id, + topics: list, + }); + + await topics.deleteUserTopics({ + user: user.id, + topics: [topic1.id], + }); + + const found = await topics.findUserTopics({ users: [user.id] }); + expect(found).to.have.length(1); + expect(first(found)).to.deep.eq({ + ...topic2, + userId: user.id, + }); + }); + }); }); diff --git a/packages/sol/src/constants.ts b/packages/sol/src/constants.ts index cfa6e0235..239d2ba74 100644 --- a/packages/sol/src/constants.ts +++ b/packages/sol/src/constants.ts @@ -62,6 +62,7 @@ export const TOPIC_ARABIC_NAME_REGEX = /[\u0600-\u06ff\s]{3,50}/; export const TOPIC_ENGLISH_NAME_REGEX = /[a-zA-Z\s]{3,50}/; export const MIN_TOPIC_LEGNTH = 3; export const MAX_TOPIC_LEGNTH = 50; +export const MAX_TOPICS_COUNT = 30; /** * Interview duration in minutes. diff --git a/packages/types/src/topic.ts b/packages/types/src/topic.ts index ca144de99..47cd2f06c 100644 --- a/packages/types/src/topic.ts +++ b/packages/types/src/topic.ts @@ -65,6 +65,9 @@ export type UpdatePayload = { export type UpdateApiPayload = UpdatePayload; +export type AddUserTopicsApiPayload = { topicIds: number[] }; +export type DeleteUserTopicsApiPayload = { topicIds: number[] } + export type FindTopicsQueryFilter = IFilter.Pagination & { name?: string; orderBy?: ExtractObjectKeys< @@ -81,3 +84,5 @@ export type UpdateTopicApiResponse = Self; export type FindTopicsApiQuery = FindTopicsQueryFilter; export type FindTopicsApiResponse = Paginated; + +export type FindUserTopicsApiResponse = PopulatedUserTopic[]; diff --git a/services/server/src/handlers/topic.ts b/services/server/src/handlers/topic.ts index 03123d198..e0c35a709 100644 --- a/services/server/src/handlers/topic.ts +++ b/services/server/src/handlers/topic.ts @@ -1,4 +1,4 @@ -import { apierror, empty, forbidden, notfound } from "@/lib/error"; +import { apierror, bad, empty, forbidden, notfound, refused } from "@/lib/error"; import { orderDirection, pageNumber, @@ -6,7 +6,7 @@ import { string, withNamedId, } from "@/validation/utils"; -import { isAdmin } from "@litespace/auth"; +import { isAdmin, isStudent, isTutor, isTutorManager } from "@litespace/auth"; import { NextFunction, Request, Response } from "express"; import { knex, topics } from "@litespace/models"; import { ITopic } from "@litespace/types"; @@ -14,6 +14,8 @@ import { isValidTopicName } from "@litespace/sol/verification"; import safeRequest from "express-async-handler"; import zod from "zod"; import { Knex } from "knex"; +import { MAX_TOPICS_COUNT } from "@litespace/sol"; +import { FindUserTopicsApiResponse } from "@litespace/types/dist/esm/topic"; const createTopicPayload = zod.object({ arabicName: string, @@ -25,6 +27,10 @@ const updateTopicPayload = zod.object({ englishName: zod.optional(string), }); +const generalUserTopicsBodyParser = zod.object({ + topicIds: zod.array(zod.number()), +}); + const orderByOptions = [ "name_ar", "name_en", @@ -114,21 +120,86 @@ async function deleteTopic(req: Request, res: Response, next: NextFunction) { res.status(200).send(); } -async function findTopics(req: Request, res: Response, next: NextFunction) { +async function findTopics(req: Request, res: Response, _: NextFunction) { const query = findTopicsQuery.parse(req.query); const response: ITopic.FindTopicsApiResponse = await topics.find(query); res.status(200).json(response); } -async function registerUserTopics() {} +async function findUserTopics(req: Request, res: Response, next: NextFunction) { + const user = req.user; + const allowed = isStudent(user) || isTutor(user) || isTutorManager(user); + if (!allowed) return next(forbidden()); + + const userTopics = await topics.findUserTopics({ users: [user.id] }); + const response: FindUserTopicsApiResponse = userTopics; + res.status(200).json(response); +} + +async function addUserTopics(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.AddUserTopicsApiPayload = + generalUserTopicsBodyParser.parse(req.body); + + // filter passed topics to ignore the ones that does already exist + const userTopics = await topics.findUserTopics({ users: [user.id] }); + const userTopicsIds = userTopics.map(t => t.id); + const filteredIds = topicIds.filter(id => !userTopicsIds.includes(id)); + + // ensure that user topics will not exceed the max num after insertion + if (userTopics.length + filteredIds.length > MAX_TOPICS_COUNT) + return next(refused()); + + if (filteredIds.length === 0) { + res.sendStatus(200); + return; + } + + // verify that all topics do exist in the database + const isExists = await topics.isExistsBatch(filteredIds); + if (Object.values(isExists).includes(false)) + return next(notfound.topic()); + + await topics.registerUserTopics({ + user: user.id, + topics: filteredIds, + }) + + 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); + + if (topicIds.length === 0) + return next(bad()); + + // verify that all topics do exist in the database + const isExists = await topics.isExistsBatch(topicIds); + if (Object.values(isExists).includes(false)) + return next(notfound.topic()); -async function deleteUserTopics() {} + // remove user topics from the database + await topics.deleteUserTopics({ + user: user.id, + topics: topicIds, + }); + + res.sendStatus(200); +} export default { createTopic: safeRequest(createTopic), updateTopic: safeRequest(updateTopic), deleteTopic: safeRequest(deleteTopic), findTopics: safeRequest(findTopics), - registerUserTopics: safeRequest(registerUserTopics), + findUserTopics: safeRequest(findUserTopics), + addUserTopics: safeRequest(addUserTopics), deleteUserTopics: safeRequest(deleteUserTopics), }; diff --git a/services/server/src/lib/error.ts b/services/server/src/lib/error.ts index e9f3896dc..f11ff7cf1 100644 --- a/services/server/src/lib/error.ts +++ b/services/server/src/lib/error.ts @@ -30,6 +30,10 @@ export const apierror = error; export const forbidden = () => error(ApiError.Forbidden, "Unauthorized access", 401); +// The server understood the request, but it's refusing to fulfill it +export const refused = () => + error(ApiError.Forbidden, "Cannot fulfill the request", 403); + export const bad = () => error(ApiError.BadRequest, "Bad request", 400); export const empty = () => error(ApiError.EmptyRequest, "Empty request", 400); diff --git a/services/server/src/routes/topic.ts b/services/server/src/routes/topic.ts index ac91404c0..5e2520b9c 100644 --- a/services/server/src/routes/topic.ts +++ b/services/server/src/routes/topic.ts @@ -8,4 +8,8 @@ router.put("/:id", topic.updateTopic); router.delete("/:id", topic.deleteTopic); router.get("/list", topic.findTopics); +router.get("/of/user", topic.findUserTopics); +router.post("/of/user", topic.addUserTopics); +router.delete("/of/user", topic.deleteUserTopics); + export default router; diff --git a/services/server/tests/api/topic.test.ts b/services/server/tests/api/topic.test.ts new file mode 100644 index 000000000..d310a5419 --- /dev/null +++ b/services/server/tests/api/topic.test.ts @@ -0,0 +1,166 @@ +import { forbidden, notfound, refused } from "@/lib/error"; +import { Api } from "@fixtures/api"; +import db, { faker } from "@fixtures/db"; +import { topics } from "@litespace/models"; +import { MAX_TOPICS_COUNT, safe } from "@litespace/sol"; +import { expect } from "chai"; +import { range } from "lodash"; + +describe("/api/v1/topic/", () => { + beforeEach(async () => { + await db.flush(); + }); + + describe("POST /api/v1/topic/user", () => { + it("should respond with 401 if the user neither a student, a tutor, nor a tutor-manager.", async () => { + const adminApi = await Api.forSuperAdmin(); + const res = await safe(async () => adminApi.atlas.topic.addUserTopics({ + topicIds: [1], + })); + expect(res).to.deep.eq(forbidden()); + }); + + it("should respond with 403 if the number of topics will exceed the MAX_TOPICS_NUM.", async () => { + const studentApi = await Api.forStudent(); + const mockTopicIds = await Promise.all( + range(0, MAX_TOPICS_COUNT + 1).map( + async (i) => (await db.topic({ + name: { + ar: `${faker.animal.bear()}-${i}`, + en: `${faker.animal.bird()}-${i}`, + } + })).id) + ); + + const res1 = await safe( + async () => studentApi.atlas.topic.addUserTopics({ + topicIds: mockTopicIds.slice(0, MAX_TOPICS_COUNT), + }) + ); + expect(res1).to.eq("OK"); + + const res2 = await safe( + async () => studentApi.atlas.topic.addUserTopics({ + topicIds: mockTopicIds.slice(MAX_TOPICS_COUNT), + }) + ); + expect(res2).to.deep.eq(refused()); + }); + + it("should respond with 404 if atleast one topic is not found.", async () => { + const studentApi = await Api.forStudent(); + const res = await safe(async () => studentApi.atlas.topic.addUserTopics({ + topicIds: [123] + })); + expect(res).to.deep.eq(notfound.topic()); + }); + + it("should respond with 200 and successfully insert user topics in the database.", async () => { + const studentApi = await Api.forStudent(); + const student = await studentApi.findCurrentUser(); + + const mockTopicIds = await Promise.all( + range(0, 3).map( + async (i) => (await db.topic({ + name: { + ar: `${faker.animal.bear()}-${i}`, + en: `${faker.animal.bird()}-${i}`, + } + })).id) + ); + + const res1 = await safe(async () => studentApi.atlas.topic.addUserTopics({ topicIds: mockTopicIds })); + expect(res1).to.eq("OK"); + + // it should ignore duplicated topics + const res2 = await safe(async () => studentApi.atlas.topic.addUserTopics({ topicIds: mockTopicIds })); + expect(res2).to.eq("OK"); + + const myTopics = await topics.findUserTopics({ users: [student.user.id] }); + expect(myTopics.length).to.eq(3); + }); + }); + + describe("DELETE /api/v1/topic/user", () => { + it("should respond with 401 if the user neither a student, a tutor, nor a tutor-manager.", async () => { + const adminApi = await Api.forSuperAdmin(); + const res = await safe(async () => adminApi.atlas.topic.deleteUserTopics({ topicIds: [1] })); + expect(res).to.deep.eq(forbidden()); + }); + + it("should respond with 200 even when the user delete topics that he doesn't have.", async () => { + const studentApi = await Api.forStudent(); + + const mockTopicIds = await Promise.all( + range(0, 3).map( + async (i) => (await db.topic({ + name: { + ar: `${faker.animal.bear()}-${i}`, + en: `${faker.animal.bird()}-${i}`, + } + })).id) + ); + + const res = await safe(async () => studentApi.atlas.topic.deleteUserTopics({ topicIds: mockTopicIds })); + expect(res).to.deep.eq("OK"); + }); + + it("should respond with 200 and successfully remove user topics.", async () => { + const studentApi = await Api.forStudent(); + const student = await studentApi.findCurrentUser(); + + const mockTopicIds = await Promise.all( + range(0, 3).map( + async (i) => (await db.topic({ + name: { + ar: `${faker.animal.bear()}-${i}`, + en: `${faker.animal.bird()}-${i}`, + } + })).id) + ); + + const res1 = await safe( + async () => studentApi.atlas.topic.addUserTopics({ topicIds: mockTopicIds }) + ); + expect(res1).to.eq("OK"); + + const res = await safe( + async () => studentApi.atlas.topic.deleteUserTopics({ topicIds: mockTopicIds.slice(0,2) }) + ); + expect(res).to.deep.eq("OK"); + + const myTopics = await topics.findUserTopics({ users: [student.user.id] }); + expect(myTopics.length).to.eq(1); + }); + }); + + describe("GET /api/v1/topic/user", () => { + it("should respond with 401 if the user neither a student, a tutor, nor a tutor-manager.", async () => { + const adminApi = await Api.forSuperAdmin(); + const res = await safe(async () => adminApi.atlas.topic.findUserTopics()); + expect(res).to.deep.eq(forbidden()); + }); + + it("should successfully retrieve a list of user topics.", async () => { + const studentApi = await Api.forStudent(); + + const mockTopics = await Promise.all( + range(0, 3).map( + async (i) => await db.topic({ + name: { + ar: `${faker.animal.bear()}-${i}`, + en: `${faker.animal.bird()}-${i}`, + } + })) + ); + const mockTopicIds = mockTopics.map(t => t.id); + + const res = await safe(async () => studentApi.atlas.topic.addUserTopics({ topicIds: mockTopicIds })); + expect(res).to.eq("OK"); + + const myTopics = await safe(async () => studentApi.atlas.topic.findUserTopics()); + expect(myTopics).to.be.an("array"); + expect(myTopics).to.have.length(3); + }); + }); +});