Skip to content

Commit

Permalink
added: api endpoint for replacing batch of user topics with new one.
Browse files Browse the repository at this point in the history
  • Loading branch information
mmoehabb committed Dec 26, 2024
1 parent 7347f89 commit ae90349
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 1 deletion.
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 });
}
}
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 = {
oldTopics: number[],
newTopics: number[],
}

export type FindTopicsQueryFilter = IFilter.Pagination & {
name?: string;
Expand Down
49 changes: 49 additions & 0 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 @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand Down
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;
152 changes: 151 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,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());
});
});
});

0 comments on commit ae90349

Please sign in to comment.