Skip to content

Commit 3597b22

Browse files
authored
Merge branch 'dev' into main
2 parents 7335942 + 1dd8f91 commit 3597b22

7 files changed

Lines changed: 398 additions & 1 deletion

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- CreateTable
2+
CREATE TABLE "Note" (
3+
"id" TEXT NOT NULL,
4+
"title" TEXT NOT NULL,
5+
"content" TEXT NOT NULL,
6+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
7+
"updatedAt" TIMESTAMP(3) NOT NULL,
8+
"userId" TEXT NOT NULL,
9+
"topicId" TEXT NOT NULL,
10+
"courseId" TEXT NOT NULL,
11+
12+
CONSTRAINT "Note_pkey" PRIMARY KEY ("id")
13+
);
14+
15+
-- AddForeignKey
16+
ALTER TABLE "Note" ADD CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
17+
18+
-- AddForeignKey
19+
ALTER TABLE "Note" ADD CONSTRAINT "Note_topicId_fkey" FOREIGN KEY ("topicId") REFERENCES "Topic"("id") ON DELETE CASCADE ON UPDATE CASCADE;
20+
21+
-- AddForeignKey
22+
ALTER TABLE "Note" ADD CONSTRAINT "Note_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;

apps/api/prisma/schema.prisma

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ model Course {
4949
createdAt DateTime @default(now())
5050
updatedAt DateTime @updatedAt
5151
topics Topic[]
52+
notes Note[]
5253
}
5354

5455
model Topic {
@@ -66,6 +67,24 @@ model Topic {
6667
userCompletions UserCompletion[]
6768
questions Question[]
6869
userPerformances UserTopicPerformance[]
70+
notes Note[]
71+
}
72+
73+
model Note {
74+
id String @id @default(cuid())
75+
title String
76+
content String @db.Text
77+
78+
createdAt DateTime @default(now())
79+
updatedAt DateTime @updatedAt
80+
81+
// --- Relations ---
82+
userId String
83+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
84+
topicId String
85+
topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)
86+
courseId String
87+
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
6988
}
7089

7190
model UserCompletion {
@@ -158,6 +177,7 @@ model User {
158177
trackId String?
159178
joinedTrack Track? @relation(fields: [trackId], references: [id], onDelete: Cascade, name: "joinedTrack")
160179
userCompletions UserCompletion[]
180+
notes Note[]
161181
162182
// Instructors
163183
createdTracks Track[]

apps/api/src/app.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { auth } from "./lib/auth.js";
44
import { toNodeHandler } from "better-auth/node";
55
import { admin, adminRouter } from "./lib/admin.js";
66
import api from "./routes/index.js";
7-
const app = express();
7+
import { notesRouter } from "./routes/notes.js";
88

9+
const app = express();
910
app.disable("x-powered-by");
1011

1112
app.all("/api/auth/*", toNodeHandler(auth));
1213
app.use(admin.options.rootPath, adminRouter);
1314
console.log(`AdminJS is running under ${admin.options.rootPath}`);
15+
app.use(express.json());
16+
app.use(notesRouter);
1417
app.use("/api", api);
1518

1619
export default app;

apps/api/src/errors/not-found.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import BaseError from "./base.js";
2+
3+
export class NotFoundError extends BaseError<undefined> {
4+
constructor(hint?: string) {
5+
super(
6+
"Not Found: Ensure the requested resource exists.",
7+
404,
8+
undefined,
9+
hint,
10+
);
11+
}
12+
}

apps/api/src/routes/notes.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { Router } from "express";
2+
import { validate } from "../middlewares/validate.js";
3+
import {
4+
createNote,
5+
deleteNote,
6+
getAllFilteredNotes,
7+
getNoteById,
8+
updateNote,
9+
} from "../services/notes.js";
10+
import {
11+
CreateNoteBodySchema,
12+
GetAllNotesQuerySchema,
13+
noteIdSchema,
14+
topicIdSchema,
15+
UpdateNoteBodySchema,
16+
} from "../schemas/notes.js";
17+
import { requireAuth } from "../middlewares/auth.js";
18+
19+
const router = Router();
20+
21+
router.use(requireAuth);
22+
23+
router.get(
24+
"/api/notes/:noteId",
25+
validate({ params: noteIdSchema }),
26+
async (req, res) => {
27+
const { noteId } = req.params;
28+
const response = await getNoteById(noteId, req.user!.id);
29+
res.status(200).json({
30+
status: "success",
31+
data: response,
32+
});
33+
},
34+
);
35+
36+
router.get(
37+
"/api/notes",
38+
validate({ query: GetAllNotesQuerySchema }),
39+
async (req, res) => {
40+
const response = await getAllFilteredNotes(req.user!.id, req.query);
41+
res.status(200).json({
42+
status: "success",
43+
pagination: response.pagination,
44+
data: response.data,
45+
});
46+
},
47+
);
48+
49+
router.post(
50+
"/api/topics/:topicId/notes",
51+
validate({ params: topicIdSchema, body: CreateNoteBodySchema }),
52+
async (req, res) => {
53+
const { topicId } = req.params;
54+
const { title, content } = req.body;
55+
const response = await createNote(
56+
{ title, content },
57+
topicId,
58+
req.user!.id,
59+
);
60+
res.status(201).json({
61+
status: "success",
62+
data: response,
63+
});
64+
},
65+
);
66+
67+
router.put(
68+
"/api/notes/:noteId",
69+
validate({ params: noteIdSchema, body: UpdateNoteBodySchema }),
70+
async (req, res) => {
71+
const { noteId } = req.params;
72+
const { title, content } = req.body;
73+
74+
const response = await updateNote(noteId, req.user!.id, { title, content });
75+
res.status(200).json({
76+
status: "success",
77+
data: response,
78+
});
79+
},
80+
);
81+
82+
router.delete(
83+
"/api/notes/:noteId",
84+
validate({ params: noteIdSchema }),
85+
async (req, res) => {
86+
const { noteId } = req.params;
87+
await deleteNote(noteId, req.user!.id);
88+
res.status(204).json({
89+
status: "success",
90+
message: "Note deleted successfully.",
91+
});
92+
},
93+
);
94+
95+
export { router as notesRouter };

apps/api/src/schemas/notes.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import z from "zod";
2+
3+
export const CreateNoteBodySchema = z.object({
4+
title: z
5+
.string()
6+
.trim()
7+
.min(1, { message: "Title is required and must be at least 1 character." }),
8+
content: z.string().trim().min(1, {
9+
message: "Content is required and must be at least 1 character.",
10+
}),
11+
});
12+
13+
export const NoteSchema = z.object({
14+
id: z.string().cuid({ message: "id must be a valid CUID." }),
15+
title: z.string().min(1, { message: "Title must be at least 1 character." }),
16+
content: z
17+
.string()
18+
.min(1, { message: "Content must be at least 1 character." }),
19+
topicId: z.string().cuid({ message: "topicId must be a valid CUID." }),
20+
userId: z.string().cuid({ message: "userId must be a valid CUID." }),
21+
courseId: z.string().cuid({ message: "courseId must be a valid CUID." }),
22+
createdAt: z.date({ message: "createdAt must be a valid date." }),
23+
updatedAt: z.date({ message: "updatedAt must be a valid date." }),
24+
});
25+
26+
export const UpdateNoteBodySchema = z.object({
27+
title: z
28+
.string()
29+
.trim()
30+
.min(1, { message: "Title must be at least 1 character." })
31+
.optional(),
32+
content: z
33+
.string()
34+
.trim()
35+
.min(1, { message: "Content must be at least 1 character." })
36+
.optional(),
37+
});
38+
39+
// valitate query string
40+
const validSortFields = new Set([
41+
"title",
42+
"-title",
43+
"createdAt",
44+
"-createdAt",
45+
"updatedAt",
46+
"-updatedAt",
47+
]);
48+
49+
export const GetAllNotesQuerySchema = z.object({
50+
courseId: z
51+
.string()
52+
.cuid({ message: "courseId must be a valid CUID." })
53+
.optional(),
54+
topicId: z
55+
.string()
56+
.cuid({ message: "topicId must be a valid CUID." })
57+
.optional(),
58+
search: z.string().optional(),
59+
sort: z
60+
.string()
61+
.refine(
62+
(value) => {
63+
// Ensure every comma-separated value is in whitelist
64+
return value.split(",").every((field) => validSortFields.has(field));
65+
},
66+
{ message: `Invalid sort field.` },
67+
)
68+
.optional(),
69+
page: z.coerce
70+
.number({ message: "page must be an number." })
71+
.int({ message: "page must be an integer." })
72+
.min(1, { message: "page must be at least 1." })
73+
.default(1),
74+
limit: z.coerce
75+
.number({ message: "limit must be an number." })
76+
.int({ message: "limit must be an integer." })
77+
.min(1, { message: "limit must be at least 1." })
78+
.default(10),
79+
});
80+
81+
// valitate params
82+
export const topicIdSchema = z.object({
83+
topicId: z.string().trim().cuid({ message: "id must be a valid CUID." }),
84+
});
85+
export const noteIdSchema = z.object({
86+
noteId: z.string().trim().cuid({ message: "id must be a valid CUID." }),
87+
});
88+
89+
export type NoteServiceType = z.infer<typeof NoteSchema>;
90+
export type UpdateNoteServiceType = z.infer<typeof UpdateNoteBodySchema>;
91+
export type CreateNoteBodyType = z.infer<typeof CreateNoteBodySchema>;
92+
export type GetAllNotesQueryType = z.infer<typeof GetAllNotesQuerySchema>;

0 commit comments

Comments
 (0)