Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7882b04
refactor: remove unused quiz and recommendation service functions
NaderMohamed325 Aug 13, 2025
0fb3a0b
fix(pnpm-lock): update dependency versions and add axios
NaderMohamed325 Aug 13, 2025
684fdc7
fix(package.json): ensure axios dependency is included
NaderMohamed325 Aug 13, 2025
6ba0573
feat(prisma): add difficulty and attempt tracking to Topic model; set…
NaderMohamed325 Aug 13, 2025
6b4575f
feat(migration): add totalQuestions to DailyQuiz and attempted, diffi…
NaderMohamed325 Aug 13, 2025
2c972bb
chore: update app.ts for improved error handling and middleware confi…
NaderMohamed325 Aug 13, 2025
34d2001
feat(calendar): implement getQuizSubmissionCalendar to track user qui…
NaderMohamed325 Aug 13, 2025
37f6029
feat(quiz): implement getQuiz and submitQuiz endpoints for quiz manag…
NaderMohamed325 Aug 13, 2025
6dd9535
feat(quizzes): implement calendar endpoint for quiz submission tracking
NaderMohamed325 Aug 13, 2025
6a576b9
feat(quizzes): update submitDailyQuizBodySchema to require choiceInde…
NaderMohamed325 Aug 13, 2025
1eeb191
feat(ai): add fetchAiRecommendation function for AI-based quiz recomm…
NaderMohamed325 Aug 13, 2025
0437764
feat(daily-quiz): implement daily quiz service with CRUD operations
NaderMohamed325 Aug 13, 2025
1f6b5e2
feat(questions): add fetchQuestionsByRecommendation function to retri…
NaderMohamed325 Aug 13, 2025
81440cd
feat(quiz-data): implement buildUserQuizData function to aggregate us…
NaderMohamed325 Aug 13, 2025
88371c7
feat(quiz-submission): add gradeAnswers function to evaluate quiz sub…
NaderMohamed325 Aug 13, 2025
a60b30f
feat(daily-quiz): add quizDate field and update unique constraint for…
NaderMohamed325 Aug 19, 2025
498b256
feat(calendar): implement getQuizSubmissionCalendar function to retri…
NaderMohamed325 Aug 19, 2025
4960098
feat(calendar): remove getQuizSubmissionCalendar function and associa…
NaderMohamed325 Aug 19, 2025
3f81264
fix(quizzes): correct import path for getQuizSubmissionCalendar function
NaderMohamed325 Aug 19, 2025
309a7e2
chore(ai.service): no code changes made
NaderMohamed325 Aug 19, 2025
ffd1bbe
fix(quiz-submission): replace hardcoded value with constant for inval…
NaderMohamed325 Aug 19, 2025
fc0686b
feat(migration): add quizDate column and unique constraint to DailyQu…
NaderMohamed325 Aug 19, 2025
4e602d5
Merge branch 'dev' into main
saifsweelam Aug 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@prisma/client": "6.11.1",
"@tiptap/extension-horizontal-rule": "2.1.13",
"adminjs": "^7.8.17",
"axios": "^1.11.0",
"better-auth": "^1.2.12",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
Warnings:

- A unique constraint covering the columns `[userId,createdAt]` on the table `DailyQuiz` will be added. If there are existing duplicate values, this will fail.

*/
-- AlterTable
ALTER TABLE "DailyQuiz" ADD COLUMN "totalQuestions" INTEGER NOT NULL DEFAULT 10;

-- AlterTable
ALTER TABLE "Topic" ADD COLUMN "attempted" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "difficulty" "QuestionDifficulty" NOT NULL DEFAULT 'easy',
ADD COLUMN "solved" INTEGER NOT NULL DEFAULT 0;

-- CreateIndex
CREATE UNIQUE INDEX "DailyQuiz_userId_createdAt_key" ON "DailyQuiz"("userId", "createdAt");
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
Warnings:

- A unique constraint covering the columns `[userId,quizDate]` on the table `DailyQuiz` will be added. If there are existing duplicate values, this will fail.
- Added the required column `quizDate` to the `DailyQuiz` table without a default value. This is not possible if the table is not empty.

*/
-- DropIndex
DROP INDEX "DailyQuiz_userId_createdAt_key";

-- AlterTable
ALTER TABLE "DailyQuiz" ADD COLUMN "quizDate" TIMESTAMP(3) NOT NULL;

-- CreateIndex
CREATE UNIQUE INDEX "DailyQuiz_userId_quizDate_key" ON "DailyQuiz"("userId", "quizDate");
12 changes: 12 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ model Topic {
userCompletions UserCompletion[]
questions Question[]
userPerformances UserTopicPerformance[]


difficulty QuestionDifficulty @default(easy)
attempted Int @default(0)
solved Int @default(0)

notes Note[]
}

Expand All @@ -85,6 +91,7 @@ model Note {
topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)
courseId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)

}

model UserCompletion {
Expand Down Expand Up @@ -153,6 +160,10 @@ model DailyQuiz {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
submittedAt DateTime?
totalQuestions Int @default(10)
quizDate DateTime // Should be set to the date (midnight) of the quiz, without time component

@@unique([userId, quizDate])
}

model User {
Expand Down Expand Up @@ -246,3 +257,4 @@ model Jwks {

@@map("jwks")
}

5 changes: 5 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +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 { quizzesRouter } from "./routes/quizzes.js";
import api from "./routes/index.js";
import { notesRouter } from "./routes/notes.js";

Expand All @@ -12,8 +13,12 @@ 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);
app.use("/api", api);
app.use("/api/quizzes", quizzesRouter);


export default app;
84 changes: 84 additions & 0 deletions apps/api/src/controller/calendar.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Request, Response } from "express";
import type { Session, User } from "better-auth";
import { prisma } from "../lib/prisma.js";

declare global {
// Ensure session typing just like other controllers
namespace Express {
interface Request {
session?: Session;
user?: User;
}
}
}

interface ApiResponse<T> {
status: string;
message?: string;
data?: T;
}
const send = <T>(res: Response, code: number, body: ApiResponse<T>) =>
res.status(code).json(body);

// Utility to get month boundaries in local time (00:00:00.000 inclusive to next month start exclusive)
const monthRange = (year: number, month0: number) => {
const start = new Date(year, month0, 1, 0, 0, 0, 0);
const end = new Date(year, month0 + 1, 1, 0, 0, 0, 0);
return { start, end };
};

/**
* Returns an array of booleans (index 0 = day 1) for the requested month indicating
* which days the user submitted a quiz (DailyQuiz.submittedAt not null).
* Query params: year=YYYY, month=1-12 (defaults to current year/month if omitted)
*/
export const getQuizSubmissionCalendar = async (
req: Request,
res: Response
): Promise<void> => {
if (!req.session) {
send(res, 401, { status: "fail", message: "Unauthorized" });
return;
}
const userId = req.session.userId;

// Parse month/year with fallbacks
const now = new Date();
const year = Number(req.query.year) || now.getFullYear();
const monthParam = Number(req.query.month); // 1-12
const month0 =
monthParam && monthParam >= 1 && monthParam <= 12 ?
monthParam - 1
: now.getMonth();

try {
const { start, end } = monthRange(year, month0);
const daysInMonth = new Date(year, month0 + 1, 0).getDate();
const days: boolean[] = Array(daysInMonth).fill(false);

const submissions = await prisma.dailyQuiz.findMany({
where: {
userId,
submittedAt: { not: null, gte: start, lt: end },
},
select: { submittedAt: true },
});

for (const s of submissions) {
if (!s.submittedAt) continue;
const day = s.submittedAt.getDate(); // 1-based
if (day >= 1 && day <= daysInMonth) days[day - 1] = true;
}

send(res, 200, {
status: "success",
data: { year, month: month0 + 1, days },
});
} catch (err) {
console.error("[getQuizSubmissionCalendar] error", err);
send(res, 500, {
status: "error",
message: "Failed to fetch calendar",
});
}
};
130 changes: 130 additions & 0 deletions apps/api/src/controller/quiz.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Request, Response } from "express";
import type { Session, User } from "better-auth";
import { fetchAiRecommendation } from "../services/ai.service.js";
import { buildUserQuizData } from "../services/quiz-data.service.js";
import { fetchQuestionsByRecommendation } from "../services/question.service.js";
import {
saveOrUpdateDailyQuiz,
findTodayDailyQuiz,
} from "../services/daily-quiz.service.js";
import {
gradeAnswers,
SubmittedAnswerInput,
} from "../services/quiz-submission.service.js";
import { updateDailyQuizScoreByUserToday } from "../services/daily-quiz.service.js";

declare global {
namespace Express {
interface Request {
session?: Session;
user?: User;
}
}
}

// Utility helpers -------------------------------------------------------------
const startOfDay = (d: Date) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate());

interface ApiResponse<T> {
status: string;
message?: string;
data?: T;
}
const send = <T>(res: Response, code: number, body: ApiResponse<T>) =>
res.status(code).json(body);

export const getQuiz = async (req: Request, res: Response): Promise<void> => {
if (!req.session) {
send(res, 401, { status: "fail", message: "Unauthorized" });
return;
}
const userId = req.session.userId;
const today = new Date();

try {
// Fetch existing quiz (by date range) if any
const existingQuiz = await findTodayDailyQuiz(userId, today);
const totalQuestions = existingQuiz?.totalQuestions || 10;

// Build data for AI
const quizData = await buildUserQuizData(userId, totalQuestions);
if (!quizData) {
send(res, 404, {
status: "fail",
message: "User hasn't completed any topics yet",
});
return;
}

const aiRecommendation = await fetchAiRecommendation(quizData);
const questions = await fetchQuestionsByRecommendation(
aiRecommendation,
totalQuestions
);

// Persist / update quiz record (schema only stores counts currently)
const savedQuiz = await saveOrUpdateDailyQuiz(
userId,
startOfDay(today),
totalQuestions
);

send(res, 200, {
status: "success",
data: { quiz: savedQuiz, questions, aiRecommendation },
});
} catch (err) {
console.error("[getQuiz] error", err);
send(res, 500, {
status: "error",
message: "Failed to generate quiz",
});
}
};

export const submitQuiz = async (
req: Request,
res: Response
): Promise<void> => {
if (!req.session) {
send(res, 401, { status: "fail", message: "Unauthorized" });
return;
}
const userId = req.session.userId;
const today = new Date();

try {
const { answers } = req.body as { answers: SubmittedAnswerInput[] };
if (!Array.isArray(answers) || answers.length === 0) {
send(res, 400, {
status: "fail",
message: "answers array required",
});
return;
}

const grading = await gradeAnswers(answers);
await updateDailyQuizScoreByUserToday(
userId,
today,
grading.scorePercentage
);

send(res, 200, {
status: "success",
data: {
score: grading.scorePercentage,
correctCount: grading.correctCount,
total: grading.total,
answers: grading.graded,
},
});
} catch (err) {
console.error("[submitQuiz] error", err);
send(res, 500, {
status: "error",
message: "Failed to submit quiz",
});
}
};
24 changes: 15 additions & 9 deletions apps/api/src/routes/quizzes.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import { Router } from "express";
import { validate } from "../middlewares/validate.js";
import { requireAuth } from "../middlewares/auth.js";
import { submitDailyQuizBodySchema } from "../schemas/quizzes.js";
import { getQuiz, submitQuiz } from "../controller/quiz.controller.js";
import { getQuizSubmissionCalendar } from "../controller/calendar.controller.js";

const router = Router();

router.get("/monthly-stats", (req, res) => {
res.send(req.url);
});
// Calendar of submissions (month view)
router.get(
"/calendar",
requireAuth,
// optional validation for query could be added later
getQuizSubmissionCalendar
);

router.get("/daily", (req, res) => {
res.send(req.url);
});
// Fetch / (re)generate today's quiz for the user
router.get("/daily", requireAuth, getQuiz);

// Submit answers for today's quiz
router.post(
"/daily",
requireAuth,
validate({ body: submitDailyQuizBodySchema }),
(req, res) => {
res.send(req.url);
},
submitQuiz
);

export { router as quizzesRouter };
14 changes: 8 additions & 6 deletions apps/api/src/schemas/quizzes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import z from "zod";

export const submitDailyQuizBodySchema = z.object({
answers: z.array(
z.object({
questionId: z.string().uuid(),
answer: z.string().optional(),
}),
),
answers: z
.array(
z.object({
questionId: z.string().uuid(),
choiceIndex: z.number().int().min(0),
})
)
.min(1),
});
15 changes: 15 additions & 0 deletions apps/api/src/services/ai.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import axios from "axios";

export const fetchAiRecommendation = async (quizData: any) => {
try {
const aiApiUrl = process.env.AI_API_URL || "http://localhost:5000/api/data";
const response = await axios.post(
aiApiUrl,
quizData
);
return response.data;
} catch (err) {
console.error("AI Recommendation error:", err);
throw new Error("Failed to get AI recommendation");
}
};
Loading