diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index be0dc45..c79443c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,7 +3,7 @@ import express from "express"; import { auth } from "./lib/auth.js"; import { toNodeHandler } from "better-auth/node"; import { admin, adminRouter } from "./lib/admin.js"; - +import api from "./routes/index.js"; const app = express(); app.disable("x-powered-by"); @@ -11,5 +11,6 @@ app.disable("x-powered-by"); app.all("/api/auth/*", toNodeHandler(auth)); app.use(admin.options.rootPath, adminRouter); console.log(`AdminJS is running under ${admin.options.rootPath}`); +app.use("/api", api); export default app; diff --git a/apps/api/src/routes/courses.ts b/apps/api/src/routes/courses.ts new file mode 100644 index 0000000..6414a44 --- /dev/null +++ b/apps/api/src/routes/courses.ts @@ -0,0 +1,69 @@ +import exprees from "express"; +const router = exprees.Router(); +import { requireAuth } from "../middlewares/auth.js"; +import { validate } from "../middlewares/validate.js"; +import { + getCourseParamsSchema, + getCourseQuerySchema, +} from "../schemas/courses.js"; +import * as Service from "../services/courses.js"; +import * as topicService from "../services/topics.js"; + +router.get( + "/:courseId", + requireAuth, + validate({ params: getCourseParamsSchema }), + async (req, res) => { + const course = await Service.getCourse(req.params.courseId); + + const totalTopics = await Service.getToltalTopics(req.params.courseId); + + const completedTopics = await Service.getCompletedTopics( + req.user!.id, + req.params.courseId, + ); + const persentage = (completedTopics.length / totalTopics.length) * 100; + + res.status(200).json({ + course, + totalTopics, + persentage, + }); + }, +); +router.get( + "/:courseId/topics", + requireAuth, + validate({ params: getCourseParamsSchema, query: getCourseQuerySchema }), + async (req, res) => { + const { completed } = req.query; + + const totalTopics = await topicService.getToltalTopics(req.params.courseId); + + const completedTopics = await topicService.getCompletedTopics( + req.user!.id, + req.params.courseId, + ); + + // const completedTopicsSet = new Set(completedTopics) + let filterdTopics; + if (completed === "true") { + filterdTopics = completedTopics; + } else if (completed === "false") { + const completedTopicsIdsSet = new Set(); + completedTopics.forEach((ele) => completedTopicsIdsSet.add(ele)); + const unCompletedTopics = totalTopics.filter( + (ele) => !completedTopicsIdsSet.has(ele.id), + ); + filterdTopics = unCompletedTopics; + } else { + filterdTopics = totalTopics; + } + + res.status(200).json({ + filterdTopics, + }); + }, +); + +export { router as coursesRouter }; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts new file mode 100644 index 0000000..a66cad7 --- /dev/null +++ b/apps/api/src/routes/index.ts @@ -0,0 +1,17 @@ +import express from "express"; +const router = express.Router(); +import { tracksRouter } from "./tracks.js"; +import { coursesRouter } from "./courses.js"; +import { topicsRouter } from "./topics.js"; + +router.get("/", (req, res) => { + res.status(200).json({ + message: "hello from api.", + }); +}); + +router.use("/tracks", tracksRouter); +router.use("/courses", coursesRouter); +router.use("/topics", topicsRouter); + +export default router; diff --git a/apps/api/src/routes/topics.ts b/apps/api/src/routes/topics.ts new file mode 100644 index 0000000..d56f2e3 --- /dev/null +++ b/apps/api/src/routes/topics.ts @@ -0,0 +1,40 @@ +import exprees from "express"; +const router = exprees.Router(); +import { validate } from "../middlewares/validate.js"; +import { getTopicParamsSchema } from "../schemas/topics.js"; +import * as Service from "../services/topics.js"; +import { requireAuth } from "../middlewares/auth.js"; + +router.get( + "/:topicId", + validate({ params: getTopicParamsSchema }), + async (req, res) => { + const topic = await Service.getTopic(req.params.topicId); + + res.status(200).json({ + topic, + }); + }, +); +router.post( + "/:topicId/completion", + requireAuth, + validate({ params: getTopicParamsSchema }), + async (req, res) => { + const topic = await Service.completeTopic(req.user!.id, req.params.topicId); + res.status(201).json({ + topic, + }); + }, +); +router.delete( + "/:topicId/completion", + requireAuth, + validate({ params: getTopicParamsSchema }), + async (req, res) => { + await Service.inCompleteTopic(req.user!.id, req.params.topicId); + res.status(204).json({}); + }, +); + +export { router as topicsRouter }; diff --git a/apps/api/src/routes/tracks.ts b/apps/api/src/routes/tracks.ts new file mode 100644 index 0000000..9776dc9 --- /dev/null +++ b/apps/api/src/routes/tracks.ts @@ -0,0 +1,60 @@ +import exprees from "express"; +const router = exprees.Router(); +import { Prisma } from "../generated/prisma/client.js"; +import { validate } from "../middlewares/validate.js"; +import { + getTrackQuerySchema, + getTrackParamsSchema, +} from "../schemas/tracks.js"; +import * as Service from "../services/tracks.js"; +import { requireAuth } from "../middlewares/auth.js"; +import * as courseSrvice from "../services/courses.js"; + +router.get("/", async (req, res) => { + const tracks = await Service.getAllTracks(); + + res.status(200).json({ + length: tracks.length, + data: tracks, + }); +}); +router.get( + "/:trackId", + requireAuth, + validate({ params: getTrackParamsSchema, query: getTrackQuerySchema }), + async (req, res) => { + const { levelId } = req.query as { levelId: string }; + const track = await Service.getTrack(req.params.trackId); + + const courses = await courseSrvice.getCourses( + levelId, + req.params.trackId, + req.user!.id, + ); + const progress = courses.map( + ( + course: Prisma.CourseGetPayload<{ + include: { topics: { select: { userCompletions: true } } }; + }>, + ) => { + const totalTopics = course.topics.length; + const completedTopics = course.topics.filter( + (topic) => topic.userCompletions.length > 0, + ).length; + const percentage = (completedTopics / totalTopics) * 100; + + return { + id: course.id, + title: course.title, + percentage, + }; + }, + ); + res.status(200).json({ + track, + courses: progress, + }); + }, +); + +export { router as tracksRouter }; diff --git a/apps/api/src/schemas/courses.ts b/apps/api/src/schemas/courses.ts new file mode 100644 index 0000000..3571e3c --- /dev/null +++ b/apps/api/src/schemas/courses.ts @@ -0,0 +1,9 @@ +import z from "zod"; + +export const getCourseParamsSchema = z.object({ + courseId: z.string().min(1, "courseId is required"), +}); + +export const getCourseQuerySchema = z.object({ + completed: z.string().optional(), +}); diff --git a/apps/api/src/schemas/topics.ts b/apps/api/src/schemas/topics.ts new file mode 100644 index 0000000..0a34e09 --- /dev/null +++ b/apps/api/src/schemas/topics.ts @@ -0,0 +1,5 @@ +import z from "zod"; + +export const getTopicParamsSchema = z.object({ + topicId: z.string().min(1, "topicId is required"), +}); diff --git a/apps/api/src/schemas/tracks.ts b/apps/api/src/schemas/tracks.ts new file mode 100644 index 0000000..8130adb --- /dev/null +++ b/apps/api/src/schemas/tracks.ts @@ -0,0 +1,8 @@ +import z from "zod"; + +export const getTrackParamsSchema = z.object({ + trackId: z.string().min(1, "trackId is required"), +}); +export const getTrackQuerySchema = z.object({ + levelId: z.string().optional(), +}); diff --git a/apps/api/src/services/courses.ts b/apps/api/src/services/courses.ts new file mode 100644 index 0000000..065b1b0 --- /dev/null +++ b/apps/api/src/services/courses.ts @@ -0,0 +1,61 @@ +import { prisma } from "../lib/prisma.js"; + +export const getCourse = async (courseId: string) => { + return await prisma.course.findUnique({ + where: { + id: courseId, + }, + }); +}; + +export const getToltalTopics = async (courseId: string) => { + return await prisma.topic.findMany({ + where: { + courseId, + }, + select: { + title: true, + }, + orderBy: { + order: "asc", + }, + }); +}; + +export const getCompletedTopics = async (userId: string, courseId: string) => { + return await prisma.userCompletion.findMany({ + where: { + userId, + topic: { + courseId, + }, + }, + }); +}; + +export const getCourses = async ( + levelId: string, + trackId: string, + userId: string, +) => { + return await prisma.course.findMany({ + where: { + trackId, + ...(levelId && { levelId }), + }, + orderBy: { + order: "asc", + }, + include: { + topics: { + select: { + userCompletions: { + where: { + userId, + }, + }, + }, + }, + }, + }); +}; diff --git a/apps/api/src/services/topics.ts b/apps/api/src/services/topics.ts new file mode 100644 index 0000000..c42b5c0 --- /dev/null +++ b/apps/api/src/services/topics.ts @@ -0,0 +1,54 @@ +import { prisma } from "../lib/prisma.js"; + +export const getTopic = async (topicId: string) => { + return prisma.topic.findUnique({ + where: { + id: topicId, + }, + select: { + title: true, + durationInMinutes: true, + content: true, + }, + }); +}; + +export const completeTopic = async (userId: string, topicId: string) => { + return await prisma.userCompletion.create({ + data: { + userId, + topicId, + }, + }); +}; +export const inCompleteTopic = async (userId: string, topicId: string) => { + prisma.userCompletion.delete({ + where: { + userId_topicId: { userId, topicId }, + }, + }); +}; + +export const getToltalTopics = async (courseId: string) => { + return await prisma.topic.findMany({ + where: { + courseId, + }, + orderBy: { + order: "asc", + }, + }); +}; +export const getCompletedTopics = async (userId: string, courseId: string) => { + return await prisma.userCompletion.findMany({ + where: { + userId, + topic: { + courseId, + }, + }, + include: { + topic: true, + }, + }); +}; diff --git a/apps/api/src/services/tracks.ts b/apps/api/src/services/tracks.ts new file mode 100644 index 0000000..2c9c858 --- /dev/null +++ b/apps/api/src/services/tracks.ts @@ -0,0 +1,25 @@ +import { prisma } from "../lib/prisma.js"; + +export const getAllTracks = async () => { + const tracks = await prisma.track.findMany({ + select: { + id: true, + title: true, + }, + }); + return tracks; +}; + +export const getTrack = async (trackId: string) => { + const track = await prisma.track.findUnique({ + where: { + id: trackId, + }, + select: { + id: true, + title: true, + description: true, + }, + }); + return track; +};