From 5363e6beb154608555dacb4ca66625d7d1e58646 Mon Sep 17 00:00:00 2001 From: wielopolski Date: Wed, 11 Sep 2024 15:23:57 +0200 Subject: [PATCH 1/2] feat: add courses list endpoint --- apps/api/src/courses/helpers/index.ts | 10 ++++++++ apps/api/src/swagger/api-schema.json | 33 +++++++++++++++------------ 2 files changed, 29 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/courses/helpers/index.ts diff --git a/apps/api/src/courses/helpers/index.ts b/apps/api/src/courses/helpers/index.ts new file mode 100644 index 00000000..d9e6ec25 --- /dev/null +++ b/apps/api/src/courses/helpers/index.ts @@ -0,0 +1,10 @@ +import { asc, desc } from "drizzle-orm"; +import { + CourseSortField, + SortCourseFieldsOptions, +} from "src/courses/schemas/courseQuery"; + +export const getSortOptions = (sort: SortCourseFieldsOptions) => ({ + sortOrder: sort.startsWith("-") ? desc : asc, + sortedField: sort.replace(/^-/, "") as CourseSortField, +}); diff --git a/apps/api/src/swagger/api-schema.json b/apps/api/src/swagger/api-schema.json index 7791a4ae..60408b3c 100644 --- a/apps/api/src/swagger/api-schema.json +++ b/apps/api/src/swagger/api-schema.json @@ -1288,17 +1288,7 @@ "title": { "type": "string" }, - "archived": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ] - }, - "createdAt": { + "imageUrl": { "anyOf": [ { "type": "string" @@ -1307,13 +1297,28 @@ "type": "null" } ] + }, + "author": { + "type": "string" + }, + "category": { + "type": "string" + }, + "courseLessonCount": { + "type": "number" + }, + "enrolledParticipantCount": { + "type": "number" } }, "required": [ "id", "title", - "archived", - "createdAt" + "imageUrl", + "author", + "category", + "courseLessonCount", + "enrolledParticipantCount" ] } }, @@ -1492,4 +1497,4 @@ } } } -} \ No newline at end of file +} From 2c4c77f47f15d6aa3e549f4dba61a96731badb61 Mon Sep 17 00:00:00 2001 From: wielopolski Date: Wed, 11 Sep 2024 23:46:38 +0200 Subject: [PATCH 2/2] feat: add student favourtie course endpoint --- apps/api/src/app.module.ts | 2 + apps/api/src/courses/courses.service.ts | 3 +- apps/api/src/courses/helpers/index.ts | 10 -- apps/api/src/storage/schema/utils.ts | 4 +- .../studentFavouritedCourses.controller.ts | 53 +++++++++++ .../schemas/course.schema.ts | 16 ++++ .../schemas/createFavouritedCourse.schema.ts | 10 ++ .../studentFavouritedCourses.module.ts | 12 +++ .../studentFavouritedCourses.service.ts | 61 ++++++++++++ apps/api/src/swagger/api-schema.json | 94 +++++++++++++++---- 10 files changed, 233 insertions(+), 32 deletions(-) delete mode 100644 apps/api/src/courses/helpers/index.ts create mode 100644 apps/api/src/studentFavouritedCourses/api/studentFavouritedCourses.controller.ts create mode 100644 apps/api/src/studentFavouritedCourses/schemas/course.schema.ts create mode 100644 apps/api/src/studentFavouritedCourses/schemas/createFavouritedCourse.schema.ts create mode 100644 apps/api/src/studentFavouritedCourses/studentFavouritedCourses.module.ts create mode 100644 apps/api/src/studentFavouritedCourses/studentFavouritedCourses.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f0debc75..6ce39154 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -18,6 +18,7 @@ import { StagingGuard } from "./common/guards/staging.guard"; import { HealthModule } from "./health/health.module"; import { ScheduleModule } from "@nestjs/schedule"; import { CoursesModule } from "./courses/courses.module"; +import { StudentFavouritedCoursesModule } from "./studentFavouritedCourses/studentFavouritedCourses.module"; @Module({ imports: [ @@ -62,6 +63,7 @@ import { CoursesModule } from "./courses/courses.module"; (env) => env.NODE_ENV !== "test", ), CoursesModule, + StudentFavouritedCoursesModule, ], controllers: [], providers: [ diff --git a/apps/api/src/courses/courses.service.ts b/apps/api/src/courses/courses.service.ts index 595481b5..f95f97bb 100644 --- a/apps/api/src/courses/courses.service.ts +++ b/apps/api/src/courses/courses.service.ts @@ -17,6 +17,7 @@ import { users, } from "../storage/schema"; import { getSortOptions } from "src/common/helpers/getSortOptions"; +import { Status } from "src/storage/schema/utils"; @Injectable() export class CoursesService { @@ -170,7 +171,7 @@ export class CoursesService { } private getFiltersConditions(filters: CoursesFilterSchema) { - const conditions = [eq(courses.state, "published")]; + const conditions = [eq(courses.state, Status.published.key)]; if (filters.title) { conditions.push( like(categories.title, `%${filters.title.toLowerCase()}%`), diff --git a/apps/api/src/courses/helpers/index.ts b/apps/api/src/courses/helpers/index.ts deleted file mode 100644 index d9e6ec25..00000000 --- a/apps/api/src/courses/helpers/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { asc, desc } from "drizzle-orm"; -import { - CourseSortField, - SortCourseFieldsOptions, -} from "src/courses/schemas/courseQuery"; - -export const getSortOptions = (sort: SortCourseFieldsOptions) => ({ - sortOrder: sort.startsWith("-") ? desc : asc, - sortedField: sort.replace(/^-/, "") as CourseSortField, -}); diff --git a/apps/api/src/storage/schema/utils.ts b/apps/api/src/storage/schema/utils.ts index cdb28cd6..638ae10f 100644 --- a/apps/api/src/storage/schema/utils.ts +++ b/apps/api/src/storage/schema/utils.ts @@ -29,6 +29,6 @@ export const timestamps = { export const archived = boolean("archived").default(false).notNull(); export const Status = { - draft: "Draft", - published: "Published", + draft: { key: "draft", value: "Draft" }, + published: { key: "published", value: "Published" }, } as const; diff --git a/apps/api/src/studentFavouritedCourses/api/studentFavouritedCourses.controller.ts b/apps/api/src/studentFavouritedCourses/api/studentFavouritedCourses.controller.ts new file mode 100644 index 00000000..bc9b5356 --- /dev/null +++ b/apps/api/src/studentFavouritedCourses/api/studentFavouritedCourses.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Delete, Post, Query } from "@nestjs/common"; +import { Validate } from "nestjs-typebox"; +import { BaseResponse, nullResponse, UUIDSchema } from "src/common"; +import { CurrentUser } from "src/common/decorators/user.decorator"; +import { StudentFavouritedCoursesService } from "../studentFavouritedCourses.service"; +import { + createFavouritedCourseSchema, + CreateFavouritedCourseSchema, +} from "../schemas/createFavouritedCourse.schema"; + +@Controller("studentFavouritedCourses") +export class StudentFavouritedCoursesController { + constructor( + private readonly studentFavouritedCoursesService: StudentFavouritedCoursesService, + ) {} + + @Post() + @Validate({ + request: [{ type: "body", schema: createFavouritedCourseSchema }], + }) + async createFavouritedCourse( + @Body() data: CreateFavouritedCourseSchema, + @CurrentUser() currentUser: { userId: string }, + ): Promise> { + await this.studentFavouritedCoursesService.createFavouritedCourseForUser( + data.courseId, + currentUser.userId, + ); + + return new BaseResponse({ + message: "Favourite course created successfully", + }); + } + + @Delete() + @Validate({ + response: nullResponse(), + request: [{ type: "query", name: "id", schema: UUIDSchema }], + }) + async deleteFavouritedCourseForUser( + @Query("id") id: string, + @CurrentUser() currentUser: { userId: string }, + ): Promise { + console.log(id, currentUser.userId); + + await this.studentFavouritedCoursesService.deleteFavouritedCourseForUser( + id, + currentUser.userId, + ); + + return null; + } +} diff --git a/apps/api/src/studentFavouritedCourses/schemas/course.schema.ts b/apps/api/src/studentFavouritedCourses/schemas/course.schema.ts new file mode 100644 index 00000000..3f7ba526 --- /dev/null +++ b/apps/api/src/studentFavouritedCourses/schemas/course.schema.ts @@ -0,0 +1,16 @@ +import { Type, Static } from "@sinclair/typebox"; +import { UUIDSchema } from "src/common"; + +export const allCoursesSchema = Type.Array( + Type.Object({ + id: UUIDSchema, + title: Type.String(), + imageUrl: Type.Union([Type.String(), Type.Null()]), + author: Type.String(), + category: Type.String(), + courseLessonCount: Type.Number(), + enrolledParticipantCount: Type.Number(), + }), +); + +export type AllCoursesResponse = Static; diff --git a/apps/api/src/studentFavouritedCourses/schemas/createFavouritedCourse.schema.ts b/apps/api/src/studentFavouritedCourses/schemas/createFavouritedCourse.schema.ts new file mode 100644 index 00000000..428a9450 --- /dev/null +++ b/apps/api/src/studentFavouritedCourses/schemas/createFavouritedCourse.schema.ts @@ -0,0 +1,10 @@ +import { Static, Type } from "@sinclair/typebox"; +import { UUIDSchema } from "src/common"; + +export const createFavouritedCourseSchema = Type.Object({ + courseId: UUIDSchema, +}); + +export type CreateFavouritedCourseSchema = Static< + typeof createFavouritedCourseSchema +>; diff --git a/apps/api/src/studentFavouritedCourses/studentFavouritedCourses.module.ts b/apps/api/src/studentFavouritedCourses/studentFavouritedCourses.module.ts new file mode 100644 index 00000000..c8f1d0bf --- /dev/null +++ b/apps/api/src/studentFavouritedCourses/studentFavouritedCourses.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; + +import { StudentFavouritedCoursesService } from "./studentFavouritedCourses.service"; +import { StudentFavouritedCoursesController } from "./api/studentFavouritedCourses.controller"; + +@Module({ + imports: [], + controllers: [StudentFavouritedCoursesController], + providers: [StudentFavouritedCoursesService], + exports: [], +}) +export class StudentFavouritedCoursesModule {} diff --git a/apps/api/src/studentFavouritedCourses/studentFavouritedCourses.service.ts b/apps/api/src/studentFavouritedCourses/studentFavouritedCourses.service.ts new file mode 100644 index 00000000..2781cabe --- /dev/null +++ b/apps/api/src/studentFavouritedCourses/studentFavouritedCourses.service.ts @@ -0,0 +1,61 @@ +import { + ConflictException, + Inject, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { and, eq } from "drizzle-orm"; +import { DatabasePg } from "src/common"; +import { courses, studentFavouritedCourses } from "src/storage/schema"; +import { Status } from "src/storage/schema/utils"; + +@Injectable() +export class StudentFavouritedCoursesService { + constructor(@Inject("DB") private readonly db: DatabasePg) {} + + async createFavouritedCourseForUser(courseId: string, userId: string) { + const [course] = await this.db + .select() + .from(courses) + .where( + and(eq(courses.id, courseId), eq(courses.state, Status.published.key)), + ); + if (!course) { + throw new NotFoundException("Course not found"); + } + + const [existingRecord] = await this.db + .select() + .from(studentFavouritedCourses) + .where( + and( + eq(studentFavouritedCourses.courseId, courseId), + eq(studentFavouritedCourses.studentId, userId), + ), + ); + if (existingRecord) { + throw new ConflictException("Favourite course already exists"); + } + + await this.db + .insert(studentFavouritedCourses) + .values({ courseId: courseId, studentId: userId }) + .returning(); + } + + async deleteFavouritedCourseForUser(id: string, userId: string) { + const [deletedFavouritedCourse] = await this.db + .delete(studentFavouritedCourses) + .where( + and( + eq(studentFavouritedCourses.id, id), + eq(studentFavouritedCourses.studentId, userId), + ), + ) + .returning(); + + if (!deletedFavouritedCourse) { + throw new NotFoundException("Favourite course not found"); + } + } +} diff --git a/apps/api/src/swagger/api-schema.json b/apps/api/src/swagger/api-schema.json index 60408b3c..8f577f7f 100644 --- a/apps/api/src/swagger/api-schema.json +++ b/apps/api/src/swagger/api-schema.json @@ -836,6 +836,52 @@ } } } + }, + "/api/studentFavouritedCourses": { + "post": { + "operationId": "StudentFavouritedCoursesController_createFavouritedCourse", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFavouritedCourseBody" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + } + }, + "delete": { + "operationId": "StudentFavouritedCoursesController_deleteFavouritedCourseForUser", + "parameters": [ + { + "name": "id", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteFavouritedCourseForUserResponse" + } + } + } + } + } + } } }, "info": { @@ -1288,37 +1334,32 @@ "title": { "type": "string" }, - "imageUrl": { + "archived": { "anyOf": [ { - "type": "string" + "type": "boolean" }, { "type": "null" } ] }, - "author": { - "type": "string" - }, - "category": { - "type": "string" - }, - "courseLessonCount": { - "type": "number" - }, - "enrolledParticipantCount": { - "type": "number" + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ "id", "title", - "imageUrl", - "author", - "category", - "courseLessonCount", - "enrolledParticipantCount" + "archived", + "createdAt" ] } }, @@ -1494,7 +1535,22 @@ "data", "pagination" ] + }, + "CreateFavouritedCourseBody": { + "type": "object", + "properties": { + "courseId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "courseId" + ] + }, + "DeleteFavouritedCourseForUserResponse": { + "type": "null" } } } -} +} \ No newline at end of file