Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added(server): api endpoint for replacing batch of user topics with new one. #253

Merged
merged 1 commit into from
Dec 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/atlas/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export class Base {
.then((response) => response.data);
}

async patch<T, R = void, P = {}>(attr: HTTPMethodAttr<T, P>): Promise<R> {
return this.client
.patch(attr.route, JSON.stringify(attr.payload))
.then((response) => response.data);
}

async del<T, R = void, P = {}>(attr: HTTPMethodAttr<T, P>): Promise<R> {
return this.client
.delete(attr.route, {
Expand Down
4 changes: 4 additions & 0 deletions packages/atlas/src/topic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,8 @@ export class Topic extends Base {
async deleteUserTopics(payload: ITopic.DeleteUserTopicsApiPayload): Promise<void> {
return await this.del({ route: `/api/v1/topic/of/user`, payload });
}

async replaceUserTopics(payload: ITopic.ReplaceUserTopicsApiPayload): Promise<void> {
return await this.patch({ route: `/api/v1/topic/of/user`, payload });
}
}
3 changes: 1 addition & 2 deletions packages/luna/src/locales/ar-eg.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "برجاء الانتظار... جاري تحميل بيانات لوحتك الرئيسية!",
Expand Down Expand Up @@ -718,4 +717,4 @@
"error.password.short": "رقم سري قصير",
"error.password.long": "رقم سري طويل",
"error.password.invalid": "يجب أن يحتوي علي أرقام، حروف ، رموز"
}
}
4 changes: 4 additions & 0 deletions packages/types/src/topic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
59 changes: 56 additions & 3 deletions services/server/src/handlers/topic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { apierror, bad, empty, forbidden, notfound, refused } from "@/lib/error";
import {
id,
orderDirection,
pageNumber,
pageSize,
Expand All @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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] });
Expand Down Expand Up @@ -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());
Expand All @@ -202,4 +254,5 @@ export default {
findUserTopics: safeRequest(findUserTopics),
addUserTopics: safeRequest(addUserTopics),
deleteUserTopics: safeRequest(deleteUserTopics),
replaceUserTopics: safeRequest(replaceUserTopics),
};
1 change: 1 addition & 0 deletions services/server/src/routes/topic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
208 changes: 207 additions & 1 deletion services/server/tests/api/topic.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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());
});
});
});
Loading