diff --git a/apps/api/prisma/migrations/20250815080928_add_notes_model/migration.sql b/apps/api/prisma/migrations/20250815080928_add_notes_model/migration.sql new file mode 100644 index 0000000..6d8bc9b --- /dev/null +++ b/apps/api/prisma/migrations/20250815080928_add_notes_model/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "Note" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + "topicId" TEXT NOT NULL, + "courseId" TEXT NOT NULL, + + CONSTRAINT "Note_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Note" ADD CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Note" ADD CONSTRAINT "Note_topicId_fkey" FOREIGN KEY ("topicId") REFERENCES "Topic"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Note" ADD CONSTRAINT "Note_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 9f7ec5e..14c4e60 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -49,6 +49,7 @@ model Course { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt topics Topic[] + notes Note[] } model Topic { @@ -66,6 +67,24 @@ model Topic { userCompletions UserCompletion[] questions Question[] userPerformances UserTopicPerformance[] + notes Note[] +} + +model Note { + id String @id @default(cuid()) + title String + content String @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // --- Relations --- + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + topicId String + topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade) + courseId String + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) } model UserCompletion { @@ -158,6 +177,7 @@ model User { trackId String? joinedTrack Track? @relation(fields: [trackId], references: [id], onDelete: Cascade, name: "joinedTrack") userCompletions UserCompletion[] + notes Note[] // Instructors createdTracks Track[] diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index be0dc45..84f9736 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,13 +3,15 @@ import express from "express"; import { auth } from "./lib/auth.js"; import { toNodeHandler } from "better-auth/node"; import { admin, adminRouter } from "./lib/admin.js"; +import { notesRouter } from "./routes/notes.js"; const app = express(); - 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(express.json()); +app.use(notesRouter); export default app; diff --git a/apps/api/src/errors/not-found.ts b/apps/api/src/errors/not-found.ts new file mode 100644 index 0000000..e2157bd --- /dev/null +++ b/apps/api/src/errors/not-found.ts @@ -0,0 +1,12 @@ +import BaseError from "./base.js"; + +export class NotFoundError extends BaseError { + constructor(hint?: string) { + super( + "Not Found: Ensure the requested resource exists.", + 404, + undefined, + hint, + ); + } +} diff --git a/apps/api/src/routes/notes.ts b/apps/api/src/routes/notes.ts new file mode 100644 index 0000000..1cad568 --- /dev/null +++ b/apps/api/src/routes/notes.ts @@ -0,0 +1,95 @@ +import { Router } from "express"; +import { validate } from "../middlewares/validate.js"; +import { + createNote, + deleteNote, + getAllFilteredNotes, + getNoteById, + updateNote, +} from "../services/notes.js"; +import { + CreateNoteBodySchema, + GetAllNotesQuerySchema, + noteIdSchema, + topicIdSchema, + UpdateNoteBodySchema, +} from "../schemas/notes.js"; +import { requireAuth } from "../middlewares/auth.js"; + +const router = Router(); + +router.use(requireAuth); + +router.get( + "/api/notes/:noteId", + validate({ params: noteIdSchema }), + async (req, res) => { + const { noteId } = req.params; + const response = await getNoteById(noteId, req.user!.id); + res.status(200).json({ + status: "success", + data: response, + }); + }, +); + +router.get( + "/api/notes", + validate({ query: GetAllNotesQuerySchema }), + async (req, res) => { + const response = await getAllFilteredNotes(req.user!.id, req.query); + res.status(200).json({ + status: "success", + pagination: response.pagination, + data: response.data, + }); + }, +); + +router.post( + "/api/topics/:topicId/notes", + validate({ params: topicIdSchema, body: CreateNoteBodySchema }), + async (req, res) => { + const { topicId } = req.params; + const { title, content } = req.body; + const response = await createNote( + { title, content }, + topicId, + req.user!.id, + ); + res.status(201).json({ + status: "success", + data: response, + }); + }, +); + +router.put( + "/api/notes/:noteId", + validate({ params: noteIdSchema, body: UpdateNoteBodySchema }), + async (req, res) => { + const { noteId } = req.params; + const { title, content } = req.body; + + const response = await updateNote(noteId, req.user!.id, { title, content }); + res.status(200).json({ + status: "success", + data: response, + }); + }, +); + +router.delete( + "/api/notes/:noteId", + validate({ params: noteIdSchema }), + async (req, res) => { + const { noteId } = req.params; + await deleteNote(noteId, req.user!.id); + res.status(204).json({ + status: "success", + message: "Note deleted successfully.", + }); + }, +); + +export { router as notesRouter }; diff --git a/apps/api/src/schemas/notes.ts b/apps/api/src/schemas/notes.ts new file mode 100644 index 0000000..37944d6 --- /dev/null +++ b/apps/api/src/schemas/notes.ts @@ -0,0 +1,92 @@ +import z from "zod"; + +export const CreateNoteBodySchema = z.object({ + title: z + .string() + .trim() + .min(1, { message: "Title is required and must be at least 1 character." }), + content: z.string().trim().min(1, { + message: "Content is required and must be at least 1 character.", + }), +}); + +export const NoteSchema = z.object({ + id: z.string().cuid({ message: "id must be a valid CUID." }), + title: z.string().min(1, { message: "Title must be at least 1 character." }), + content: z + .string() + .min(1, { message: "Content must be at least 1 character." }), + topicId: z.string().cuid({ message: "topicId must be a valid CUID." }), + userId: z.string().cuid({ message: "userId must be a valid CUID." }), + courseId: z.string().cuid({ message: "courseId must be a valid CUID." }), + createdAt: z.date({ message: "createdAt must be a valid date." }), + updatedAt: z.date({ message: "updatedAt must be a valid date." }), +}); + +export const UpdateNoteBodySchema = z.object({ + title: z + .string() + .trim() + .min(1, { message: "Title must be at least 1 character." }) + .optional(), + content: z + .string() + .trim() + .min(1, { message: "Content must be at least 1 character." }) + .optional(), +}); + +// valitate query string +const validSortFields = new Set([ + "title", + "-title", + "createdAt", + "-createdAt", + "updatedAt", + "-updatedAt", +]); + +export const GetAllNotesQuerySchema = z.object({ + courseId: z + .string() + .cuid({ message: "courseId must be a valid CUID." }) + .optional(), + topicId: z + .string() + .cuid({ message: "topicId must be a valid CUID." }) + .optional(), + search: z.string().optional(), + sort: z + .string() + .refine( + (value) => { + // Ensure every comma-separated value is in whitelist + return value.split(",").every((field) => validSortFields.has(field)); + }, + { message: `Invalid sort field.` }, + ) + .optional(), + page: z.coerce + .number({ message: "page must be an number." }) + .int({ message: "page must be an integer." }) + .min(1, { message: "page must be at least 1." }) + .default(1), + limit: z.coerce + .number({ message: "limit must be an number." }) + .int({ message: "limit must be an integer." }) + .min(1, { message: "limit must be at least 1." }) + .default(10), +}); + +// valitate params +export const topicIdSchema = z.object({ + topicId: z.string().trim().cuid({ message: "id must be a valid CUID." }), +}); +export const noteIdSchema = z.object({ + noteId: z.string().trim().cuid({ message: "id must be a valid CUID." }), +}); + +export type NoteServiceType = z.infer; +export type UpdateNoteServiceType = z.infer; +export type CreateNoteBodyType = z.infer; +export type GetAllNotesQueryType = z.infer; diff --git a/apps/api/src/services/notes.ts b/apps/api/src/services/notes.ts new file mode 100644 index 0000000..913eb93 --- /dev/null +++ b/apps/api/src/services/notes.ts @@ -0,0 +1,153 @@ +import { NotFoundError } from "../errors/not-found.js"; +import { Prisma } from "../generated/prisma/client.js"; +import { prisma } from "../lib/prisma.js"; +import { + CreateNoteBodyType, + GetAllNotesQueryType, + NoteServiceType, + UpdateNoteServiceType, +} from "../schemas/notes.js"; + +interface PaginatedNotesResult { + pagination: { + totalNotes: number; + totalPages: number; + currentPage: number; + limit: number; + }; + data: NoteServiceType[]; +} + +export async function createNote( + input: CreateNoteBodyType, + topicId: string, + userId: string, +): Promise { + const topic = await prisma.topic.findUnique({ + where: { id: topicId }, + select: { courseId: true }, + }); + + if (!topic) { + throw new NotFoundError(); + } + + const createdNote = await prisma.note.create({ + data: { + title: input.title, + content: input.content, + topicId, + userId, + courseId: topic.courseId, + }, + }); + + return createdNote; +} + +export async function getNoteById( + noteId: string, + userId: string, +): Promise { + const note = await prisma.note.findFirst({ + where: { + id: noteId, + userId: userId, // Authorization check + }, + }); + + if (!note) { + throw new NotFoundError(); + } + + return note; +} + +export async function updateNote( + noteId: string, + userId: string, + data: UpdateNoteServiceType, +): Promise { + const note = await prisma.note.findFirst({ + where: { + id: noteId, + userId: userId, + }, + }); + + if (!note) { + throw new NotFoundError(); + } + + const updatedNote = await prisma.note.update({ + where: { id: noteId }, + data, + }); + + return updatedNote; +} + +export async function deleteNote( + noteId: string, + userId: string, +): Promise { + const note = await prisma.note.findFirst({ + where: { + id: noteId, + userId: userId, + }, + }); + + if (!note) { + throw new NotFoundError(); + } + + await prisma.note.delete({ where: { id: noteId } }); +} + +export async function getAllFilteredNotes( + userId: string, + query: GetAllNotesQueryType, +): Promise { + const { courseId, topicId, search, sort, page, limit } = query; + + const where: Prisma.NoteWhereInput = { userId }; + if (topicId) { + where.topicId = topicId; + } else if (courseId) { + where.courseId = courseId; + } + if (search) { + where.OR = [ + { title: { contains: search, mode: "insensitive" } }, + { content: { contains: search, mode: "insensitive" } }, + ]; + } + + // default sorting by updatedAt desc + let orderBy: Prisma.NoteOrderByWithRelationInput[] = [{ updatedAt: "desc" }]; + if (sort) { + orderBy = sort.split(",").map((field) => { + const direction = field.startsWith("-") ? "desc" : "asc"; + const fieldName = field.replace(/^-/, ""); + return { [fieldName]: direction }; + }); + } + + const skip = (page - 1) * limit; + + const [notes, totalCount] = await prisma.$transaction([ + prisma.note.findMany({ where, orderBy, skip, take: limit }), + prisma.note.count({ where }), + ]); + + return { + pagination: { + totalNotes: totalCount, + totalPages: Math.ceil(totalCount / limit), + currentPage: page, + limit, + }, + data: notes, + }; +}