diff --git a/backend-services/question-backend-service/src/db/types/questionQuery.ts b/backend-services/question-backend-service/src/db/types/questionQuery.ts new file mode 100644 index 000000000..325ef4362 --- /dev/null +++ b/backend-services/question-backend-service/src/db/types/questionQuery.ts @@ -0,0 +1,8 @@ +export interface QuestionQuery { + categoryTitle?: string; + difficulty?: "Easy" | "Medium" | "Hard"; + timeLimit?: { + $gte?: number; + $lte?: number; + }; +} diff --git a/backend-services/question-backend-service/src/routes.ts b/backend-services/question-backend-service/src/routes.ts index 50ec033cb..40c049dbe 100644 --- a/backend-services/question-backend-service/src/routes.ts +++ b/backend-services/question-backend-service/src/routes.ts @@ -11,6 +11,8 @@ import { withDbLimit } from "./db/dbLimiter.js"; import { Question } from "./db/model/question.js"; import { z } from "zod"; import crypto from "crypto"; +import { Types } from "mongoose"; +import type { QuestionQuery } from "./db/types/questionQuery.js"; if (!process.env.ADMIN_TOKEN) { throw new Error("ADMIN_TOKEN environment variable must be set"); @@ -217,6 +219,402 @@ const leetcodeRoutes: FastifyPluginCallback = (app: FastifyInstance) => { message: "Question inserted successfully", }); }); + + /** + * GET /questions + * Supports filtering, pagination, and sorting. + */ + app.get("/questions", async (req, reply) => { + // Define schema for validation + const QuerySchema = z.object({ + category: z.string().optional(), + difficulty: z.enum(["Easy", "Medium", "Hard"]).optional(), + minTime: z.coerce.number().int().min(1).optional(), + maxTime: z.coerce.number().int().min(1).optional(), + size: z.coerce.number().int().min(1).max(100).default(10), + page: z.coerce.number().int().min(1).default(1), + sortBy: z + .enum(["newest", "oldest", "easiest", "hardest", "shortest", "longest"]) + .default("newest"), + }); + + // Validate query + const parsed = QuerySchema.safeParse(req.query); + if (!parsed.success) { + return reply + .status(400) + .send({ error: "Invalid query params", details: parsed.error.issues }); + } + + const { category, difficulty, minTime, maxTime, size, page, sortBy } = + parsed.data; + + // Build MongoDB query + const query: QuestionQuery = {}; + + if (category) query.categoryTitle = category; + if (difficulty) query.difficulty = difficulty; + if (minTime || maxTime) { + const timeLimitQuery: { $gte?: number; $lte?: number } = {}; + if (minTime) timeLimitQuery.$gte = minTime; + if (maxTime) timeLimitQuery.$lte = maxTime; + query.timeLimit = timeLimitQuery; + } + + // Pagination + const skip = (page - 1) * size; + + // Sorting + const sortOptions: Record = (() => { + switch (sortBy) { + case "oldest": + return { createdAt: 1 }; + case "newest": + return { createdAt: -1 }; + case "easiest": + return { difficulty: 1 }; + case "hardest": + return { difficulty: -1 }; + case "shortest": + return { timeLimit: 1 }; + case "longest": + return { timeLimit: -1 }; + default: + return { createdAt: -1 }; + } + })(); + + try { + const [total, questions] = await withDbLimit(async () => { + const total = await Question.countDocuments(query); + const questions = await Question.find(query) + .sort(sortOptions) + .skip(skip) + .limit(size) + .select("title categoryTitle difficulty timeLimit _id") + .lean(); + return [total, questions] as const; + }); + + const previews = questions.map((q) => ({ + questionId: q._id.toString(), + questionName: q.title, + topic: q.categoryTitle ?? "Uncategorized", + difficulty: q.difficulty, + timeLimit: q.timeLimit?.toString() ?? "-", + })); + + return reply.send({ + page, + size, + total, + questions: previews, + }); + } catch (err: unknown) { + if (err instanceof Error) { + req.log?.error({ err }, "Failed to fetch questions"); + return reply.status(500).send({ error: err.message }); + } + req.log?.error({ err }, "Failed to fetch questions"); + return reply.status(500).send({ error: "Internal Server Error" }); + } + }); + + /** + * GET /questions/categories + * Returns a list of distinct categories from questions. + */ + app.get("/questions/categories", async (_req, reply) => { + try { + const categories = await withDbLimit(() => + Question.distinct("categoryTitle"), + ); + return reply.send({ categories }); + } catch (err) { + _req.log?.error({ err }, "Failed to fetch categories"); + return reply.status(500).send({ error: "Internal Server Error" }); + } + }); + + /** + * GET /questions/difficulties + * Returns all distinct difficulties from questions. + */ + app.get("/questions/difficulties", async (_req, reply) => { + try { + const difficulties = await withDbLimit(() => + Question.distinct("difficulty"), + ); + return reply.send({ difficulties }); + } catch (err: unknown) { + if (err instanceof Error) { + _req.log?.error({ err }, "Failed to fetch question difficulties"); + return reply.status(500).send({ error: err.message }); + } + _req.log?.error({ err }, "Failed to fetch question difficulties"); + return reply.status(500).send({ error: "Internal Server Error" }); + } + }); + + /** + * POST /add-question + * Add a new question to the database with minimal required fields from the PeerPrep app itself. + * Auto-generates slugs from title. + */ + app.post("/add-question", async (req, res) => { + const token = getHeader(req, "x-admin-token"); + if (!ADMIN_TOKEN || !token || !safeCompare(token, ADMIN_TOKEN)) { + return res.status(401).send({ error: "Unauthorized" }); + } + + const Body = z.object({ + title: z.string().min(1, "Title is required"), + categoryTitle: z.string().max(100, "Category title is required"), + difficulty: z.enum(["Easy", "Medium", "Hard"]), + timeLimit: z.number().min(1).max(MAX_TIME_LIMIT_MINUTES), + content: z.string().min(1, "Content is required"), + hints: z.array(z.string()).optional(), + }); + + const result = Body.safeParse(req.body); + if (!result.success) { + return res + .status(400) + .send({ error: "Invalid input", details: result.error.issues }); + } + + const data = result.data; + + // Check for title uniqueness + const existing = await withDbLimit(() => + Question.findOne({ title: data.title }).lean(), + ); + + if (existing) { + return res.status(409).send({ + ok: false, + message: "A question with this title already exists", + existingId: existing._id.toString(), + }); + } + + // Auto-generate slug from title + const slug = data.title + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, ""); + + const doc = { + source: "admin", + globalSlug: slug, + titleSlug: slug, + title: data.title, + categoryTitle: data.categoryTitle, + difficulty: data.difficulty, + timeLimit: data.timeLimit, + content: data.content, + hints: data.hints && data.hints.length > 0 ? data.hints : null, + exampleTestcases: null, + codeSnippets: null, + }; + + try { + const savedDoc = await withDbLimit(() => Question.create(doc)); + + return res.status(200).send({ + ok: true, + id: savedDoc._id.toString(), + message: "Question created successfully", + }); + } catch (err: unknown) { + if (err instanceof Error) { + req.log?.error({ err }, "Failed to add question"); + return res.status(500).send({ error: err.message }); + } + return res.status(500).send({ error: "Internal Server Error" }); + } + }); + + /** + * GET /questions/:id + * Returns full question details for a given ID. + */ + app.get<{ + Params: { id: string }; + }>("/questions/:id", async (req, reply) => { + const { id } = req.params; + + // Validate ObjectId + if (!Types.ObjectId.isValid(id)) { + return reply.status(400).send({ error: "Invalid question ID" }); + } + + try { + const question = await withDbLimit(() => Question.findById(id).lean()); + + if (!question) { + return reply.status(404).send({ error: "Question not found" }); + } + + return reply.send({ + questionId: question._id.toString(), + title: question.title, + categoryTitle: question.categoryTitle, + difficulty: question.difficulty, + timeLimit: question.timeLimit, + content: question.content, + hints: question.hints ?? [], + exampleTestcases: question.exampleTestcases ?? "", + codeSnippets: question.codeSnippets ?? [], + createdAt: question.createdAt, + updatedAt: question.updatedAt, + }); + } catch (err: unknown) { + if (err instanceof Error) { + req.log?.error({ err }, "Failed to fetch question details"); + return reply.status(500).send({ error: err.message }); + } + req.log?.error({ err }, "Failed to fetch question details"); + return reply.status(500).send({ error: "Internal Server Error" }); + } + }); + + /** + * PUT /questions/:id + * Updates a question by ID. + * Requires admin token. + */ + app.put<{ + Params: { id: string }; + Body: { + title?: string; + categoryTitle?: string; + difficulty?: Difficulty; + timeLimit?: number; + content?: string; + hints?: string[]; + }; + }>("/questions/:id", async (req, reply) => { + const token = getHeader(req, "x-admin-token"); + if (!ADMIN_TOKEN || !token || !safeCompare(token, ADMIN_TOKEN)) { + return reply.status(401).send({ error: "Unauthorized" }); + } + + const { id } = req.params; + + // Validate ObjectId + if (!Types.ObjectId.isValid(id)) { + return reply.status(400).send({ error: "Invalid question ID" }); + } + + // Validate body using Zod + const BodySchema = z.object({ + title: z.string().min(1).optional(), + categoryTitle: z.string().max(100).optional(), + difficulty: z.enum(["Easy", "Medium", "Hard"]).optional(), + timeLimit: z.number().min(1).max(MAX_TIME_LIMIT_MINUTES).optional(), + content: z.string().min(1).optional(), + hints: z.array(z.string()).optional(), + }); + + type UpdateData = z.infer & { + titleSlug?: string; + globalSlug?: string; + }; + + const parsed = BodySchema.safeParse(req.body); + if (!parsed.success) { + return reply + .status(400) + .send({ error: "Invalid input", details: parsed.error.issues }); + } + + const updateData: UpdateData = parsed.data; + + if (updateData.title) { + const slug = updateData.title + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, ""); + updateData.titleSlug = slug; + updateData.globalSlug = slug; + } + + try { + const updated = await withDbLimit(() => + Question.findByIdAndUpdate( + id, + { $set: updateData }, + { new: true, runValidators: true, lean: true }, + ), + ); + + if (!updated) { + return reply.status(404).send({ error: "Question not found" }); + } + + return reply.send({ + ok: true, + message: "Question updated successfully", + questionId: updated._id.toString(), + title: updated.title, + titleSlug: updated.titleSlug, + globalSlug: updated.globalSlug, + }); + } catch (err: unknown) { + if (err instanceof Error) { + req.log?.error({ err }, "Failed to update question"); + return reply.status(500).send({ error: err.message }); + } + req.log?.error({ err }, "Failed to update question"); + return reply.status(500).send({ error: "Internal Server Error" }); + } + }); + + /** + * DELETE /questions/:id + * Deletes a question from the database by ID. + * Requires admin token. + */ + app.delete<{ + Params: { id: string }; + }>("/questions/:id", async (req, reply) => { + const token = getHeader(req, "x-admin-token"); + if (!ADMIN_TOKEN || !token || !safeCompare(token, ADMIN_TOKEN)) { + return reply.status(401).send({ error: "Unauthorized" }); + } + + const { id } = req.params; + + // Validate ObjectId + if (!Types.ObjectId.isValid(id)) { + return reply.status(400).send({ error: "Invalid question ID" }); + } + + try { + const deleted = await withDbLimit(() => + Question.findByIdAndDelete(id).lean(), + ); + + if (!deleted) { + return reply.status(404).send({ error: "Question not found" }); + } + + return reply.send({ + ok: true, + message: "Question deleted successfully", + deletedId: id, + title: deleted.title, + }); + } catch (err: unknown) { + if (err instanceof Error) { + req.log?.error({ err }, "Failed to delete question"); + return reply.status(500).send({ error: err.message }); + } + req.log?.error({ err }, "Failed to delete question"); + return reply.status(500).send({ error: "Internal Server Error" }); + } + }); }; export default leetcodeRoutes; diff --git a/backend-services/question-backend-service/src/server.ts b/backend-services/question-backend-service/src/server.ts index d3f99d9a3..aeae7cb64 100644 --- a/backend-services/question-backend-service/src/server.ts +++ b/backend-services/question-backend-service/src/server.ts @@ -8,7 +8,10 @@ export async function buildServer() { const app = Fastify({ logger: true }); // plugins - await app.register(cors, { origin: "*" }); // will need to change this in production + await app.register(cors, { + origin: "*", + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + }); // will need to change this in production await app.register(db); await app.register(rateLimit, { global: false, diff --git a/ui-services/question-ui-service/.env.example b/ui-services/question-ui-service/.env.example new file mode 100644 index 000000000..e867405f3 --- /dev/null +++ b/ui-services/question-ui-service/.env.example @@ -0,0 +1,3 @@ +/* This is an example env file for the matching-ui-service. Rename this file to .env and update the variables as needed. */ + +VITE_QUESTION_SERVICE_API_LINK=http://localhost:5275/api/v1/question-service/ diff --git a/ui-services/question-ui-service/package-lock.json b/ui-services/question-ui-service/package-lock.json index d442c7e6d..52e3d4a4e 100644 --- a/ui-services/question-ui-service/package-lock.json +++ b/ui-services/question-ui-service/package-lock.json @@ -8,8 +8,11 @@ "name": "question-ui-service", "version": "0.0.0", "dependencies": { + "@hookform/resolvers": "^5.2.2", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/vite": "^4.1.13", "class-variance-authority": "^0.7.1", @@ -17,8 +20,10 @@ "lucide-react": "^0.544.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-hook-form": "^7.65.0", "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.13" + "tailwindcss": "^4.1.13", + "zod": "^4.1.12" }, "devDependencies": { "@eslint/js": "^9.33.0", @@ -327,7 +332,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -344,7 +348,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -361,7 +364,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -378,7 +380,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -395,7 +396,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -412,7 +412,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -429,7 +428,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -446,7 +444,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -463,7 +460,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -480,7 +476,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -497,7 +492,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -514,7 +508,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -531,7 +524,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -548,7 +540,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -565,7 +556,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -582,7 +572,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -599,7 +588,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -616,7 +604,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -633,7 +620,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -650,7 +636,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -667,7 +652,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -684,7 +668,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -701,7 +684,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -718,7 +700,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -735,7 +716,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -752,7 +732,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -916,6 +895,56 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1078,12 +1107,41 @@ "pnpm": ">=7.0.1" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-checkbox": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", @@ -1114,6 +1172,32 @@ } } }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1180,6 +1264,21 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", @@ -1265,6 +1364,38 @@ } } }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-portal": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", @@ -1336,6 +1467,82 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -1454,6 +1661,24 @@ } } }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-size": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", @@ -1472,6 +1697,35 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.34", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz", @@ -1486,7 +1740,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1500,7 +1753,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1514,7 +1766,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1528,7 +1779,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1542,7 +1792,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1556,7 +1805,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1570,7 +1818,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1584,7 +1831,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1598,7 +1844,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1612,7 +1857,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1626,7 +1870,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1640,7 +1883,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1654,7 +1896,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1668,7 +1909,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1682,7 +1922,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1696,7 +1935,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1710,7 +1948,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1724,7 +1961,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1738,7 +1974,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1752,7 +1987,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1766,13 +2000,18 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", @@ -2093,7 +2332,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -2107,7 +2345,7 @@ "version": "24.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.0.tgz", "integrity": "sha512-y1dMvuvJspJiPSDZUQ+WMBvF7dpnEqN4x9DDC9ie5Fs/HUZJA3wFp7EhHoVaKX/iI0cRoECV8X2jL8zi0xrHCg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.12.0" @@ -2117,7 +2355,7 @@ "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2127,7 +2365,7 @@ "version": "19.1.9", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -2706,7 +2944,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -2773,7 +3011,6 @@ "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -3151,7 +3388,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3774,7 +4010,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -3890,7 +4125,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -3910,7 +4144,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -4013,6 +4246,22 @@ "react": "^19.1.1" } }, + "node_modules/react-hook-form": { + "version": "7.65.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", + "integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4117,7 +4366,6 @@ "version": "4.50.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.2.tgz", "integrity": "sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -4311,7 +4559,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -4328,7 +4575,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -4346,7 +4592,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4452,7 +4697,7 @@ "version": "7.12.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -4543,7 +4788,6 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -4618,7 +4862,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -4636,7 +4879,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4690,6 +4932,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/ui-services/question-ui-service/package.json b/ui-services/question-ui-service/package.json index cda27c8eb..afc7206ba 100644 --- a/ui-services/question-ui-service/package.json +++ b/ui-services/question-ui-service/package.json @@ -13,8 +13,11 @@ "serve": "vite preview --port 5175 --strictPort" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/vite": "^4.1.13", "class-variance-authority": "^0.7.1", @@ -22,8 +25,10 @@ "lucide-react": "^0.544.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-hook-form": "^7.65.0", "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.13" + "tailwindcss": "^4.1.13", + "zod": "^4.1.12" }, "devDependencies": { "@eslint/js": "^9.33.0", diff --git a/ui-services/question-ui-service/src/api/questionService.ts b/ui-services/question-ui-service/src/api/questionService.ts new file mode 100644 index 000000000..37a1441c3 --- /dev/null +++ b/ui-services/question-ui-service/src/api/questionService.ts @@ -0,0 +1,222 @@ +import type { QuestionPreview } from "@/types/QuestionPreview"; + +interface GetQuestionsParams { + category?: string; + difficulty?: string; + minTime?: number; + maxTime?: number; + size?: number; + page?: number; +} + +interface GetQuestionsResponse { + questions: QuestionPreview[]; + totalCount: number; +} + +export async function getQuestions( + params: GetQuestionsParams, +): Promise { + const apiUri = import.meta.env.VITE_QUESTION_SERVICE_API_LINK; + const query = new URLSearchParams(); + + if (params.category) query.append("category", params.category); + if (params.difficulty) query.append("difficulty", params.difficulty); + if (params.minTime !== undefined) + query.append("minTime", params.minTime.toString()); + if (params.maxTime !== undefined) + query.append("maxTime", params.maxTime.toString()); + if (params.size) query.append("size", params.size.toString()); + if (params.page) query.append("page", params.page.toString()); + + const uriLink = `${apiUri}/questions?${query.toString()}`; + + const response = await fetch(uriLink, { method: "GET" }); + if (!response.ok) throw new Error("Failed to fetch questions"); + + const data: { questions?: QuestionPreview[]; total?: number } = + await response.json(); + + return { + questions: data.questions ?? [], + totalCount: data.total ?? 0, + }; +} + +export async function updateQuestion( + id: string, + payload: Record, +): Promise<{ ok: boolean; message?: string }> { + const adminToken = import.meta.env.VITE_QUESTION_SERVICE_ADMIN_TOKEN; + const apiUri = import.meta.env.VITE_QUESTION_SERVICE_API_LINK; + const res = await fetch(`${apiUri}/questions/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "x-admin-token": adminToken, + }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error("Failed to update question"); + return res.json(); +} + +export interface GetCategoriesResponse { + categories: string[]; +} + +export async function getCategories(): Promise { + const apiUri = import.meta.env.VITE_QUESTION_SERVICE_API_LINK; + const uriLink = `${apiUri}/questions/categories`; + + const response = await fetch(uriLink, { method: "GET" }); + if (!response.ok) throw new Error("Failed to fetch categories"); + + const data: { categories?: string[] } = await response.json(); + + return { + categories: data.categories ?? [], + }; +} + +export async function getDifficulties(): Promise<{ difficulties: string[] }> { + const apiUri = import.meta.env.VITE_QUESTION_SERVICE_API_LINK; + const response = await fetch(`${apiUri}/questions/difficulties`); + if (!response.ok) throw new Error("Failed to fetch difficulties"); + const data: { difficulties?: string[] } = await response.json(); + return { difficulties: data.difficulties ?? [] }; +} + +export interface QuestionDetails { + questionId: string; + title: string; + categoryTitle: string; + difficulty: "Easy" | "Medium" | "Hard"; + timeLimit: number; + content: string; + hints: string[]; + exampleTestcases: string; + codeSnippets: { + lang: string; + langSlug: string; + code: string; + }[]; + createdAt: string; + updatedAt: string; + answer?: string; +} + +/** + * Fetch question details by ID + */ +export async function getQuestionById(id: string): Promise { + const apiUri = import.meta.env.VITE_QUESTION_SERVICE_API_LINK; + const response = await fetch(`${apiUri}/questions/${id}`); + + if (response.status === 404) throw new Error("Question not found"); + if (!response.ok) throw new Error("Failed to fetch question details"); + + const data: QuestionDetails = await response.json(); + return data; +} + +export interface CreateQuestionPayload { + title: string; + categoryTitle: string; + difficulty: "Easy" | "Medium" | "Hard"; + timeLimit: number; + content: string; + hints: string[]; +} + +export interface CreateQuestionResponse { + questionId: string; + ok: boolean; + id?: string; + message: string; +} + +/** + * Create a new question + */ +export async function createQuestion( + payload: CreateQuestionPayload, +): Promise { + const apiUri = import.meta.env.VITE_QUESTION_SERVICE_API_LINK; + const adminToken = import.meta.env.VITE_QUESTION_SERVICE_ADMIN_TOKEN; + const response = await fetch(`${apiUri}/add-question`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-admin-token": adminToken, + }, + body: JSON.stringify(payload), + }); + + const json: { + ok?: boolean; + message?: string; + id?: string; + questionId?: string; + details?: { message: string }[]; + error?: string; + } = await response.json(); + + if (!response.ok) { + const errorMsg = json.details + ? `Validation errors: ${json.details.map((d) => d.message).join(", ")}` + : json.error || "Failed to save question"; + throw new Error(errorMsg); + } + + return { + ok: json.ok ?? false, + message: json.message ?? "Question created", + id: json.id, + questionId: json.questionId ?? "", + }; +} + +export interface DeleteQuestionResponse { + ok: boolean; + message: string; + deletedId: string; + title: string; +} + +/** + * Delete a question by ID + */ +export async function deleteQuestion( + id: string, +): Promise { + const apiUri = import.meta.env.VITE_QUESTION_SERVICE_API_LINK; + const adminToken = import.meta.env.VITE_QUESTION_SERVICE_ADMIN_TOKEN; + + const response = await fetch(`${apiUri}/questions/${id}`, { + method: "DELETE", + headers: { + "x-admin-token": adminToken, + }, + }); + + const json: { + ok?: boolean; + message?: string; + deletedId?: string; + title?: string; + error?: string; + } = await response.json(); + + if (!response.ok) { + const errorMsg = json.error || "Failed to delete question"; + throw new Error(errorMsg); + } + + return { + ok: json.ok ?? false, + message: json.message ?? "Question deleted successfully", + deletedId: json.deletedId ?? id, + title: json.title ?? "", + }; +} diff --git a/ui-services/question-ui-service/src/components/AnswerButton.tsx b/ui-services/question-ui-service/src/components/AnswerButton.tsx index d117b0f56..55c1ba920 100644 --- a/ui-services/question-ui-service/src/components/AnswerButton.tsx +++ b/ui-services/question-ui-service/src/components/AnswerButton.tsx @@ -8,11 +8,15 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { mockQuestions } from "@/data/mock-data"; +interface AnswerButtonProps { + answer?: string; +} -const AnswerButton: React.FC = () => { +const AnswerButton: React.FC = ({ answer }) => { const [confirmed, setConfirmed] = useState(false); + const hasAnswer = Boolean(answer && answer.trim().length > 0); + return ( !open && setConfirmed(false)}> @@ -20,7 +24,7 @@ const AnswerButton: React.FC = () => { variant="outline" className="bg-gray-700 text-white border-gray-600 hover:bg-gray-600" > - Show Answer 0/2 + Show Answer @@ -39,13 +43,16 @@ const AnswerButton: React.FC = () => { ) : (

- {mockQuestions[0].answer} + {hasAnswer + ? answer + : "No answer has been provided for this question."}

)} diff --git a/ui-services/question-ui-service/src/components/HintDialog.tsx b/ui-services/question-ui-service/src/components/HintDialog.tsx index 49fca5e13..9d4fe5b24 100644 --- a/ui-services/question-ui-service/src/components/HintDialog.tsx +++ b/ui-services/question-ui-service/src/components/HintDialog.tsx @@ -3,7 +3,6 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, DialogTrigger, @@ -28,13 +27,11 @@ const HintDialog: React.FC = ({ hint, index }) => { Hint #{index + 1} - - You have used {index + 1}/2 hints. - -
-

{hint}

-
+
); diff --git a/ui-services/question-ui-service/src/components/QuestionCard.tsx b/ui-services/question-ui-service/src/components/QuestionCard.tsx new file mode 100644 index 000000000..9821248ae --- /dev/null +++ b/ui-services/question-ui-service/src/components/QuestionCard.tsx @@ -0,0 +1,48 @@ +import { Card, CardContent, CardTitle } from "@/components/ui/card"; +import type { QuestionPreview } from "@/types/QuestionPreview"; + +interface QuestionCardProps { + item: QuestionPreview; + index: number; + onClick?: () => void; +} + +const QuestionCard: React.FC = ({ + item, + index, + onClick, +}) => { + return ( +
+ {/* Index Card */} + + {index} + + + {/* Question Info Card */} + + +
+ + {item.questionName} + +
+
+ {item.topic} +
+
+ {item.difficulty} +
+
+ {item.timeLimit} min +
+
+
+
+ ); +}; + +export default QuestionCard; diff --git a/ui-services/question-ui-service/src/components/QuestionDisplay.tsx b/ui-services/question-ui-service/src/components/QuestionDisplay.tsx index f7012b339..aafe3b30f 100644 --- a/ui-services/question-ui-service/src/components/QuestionDisplay.tsx +++ b/ui-services/question-ui-service/src/components/QuestionDisplay.tsx @@ -1,28 +1,72 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import type { Question } from "@/types/Question"; +import { getQuestionById } from "@/api/questionService"; import HintDialog from "./HintDialog"; import AnswerButton from "./AnswerButton"; -import { mockQuestions } from "@/data/mock-data"; + interface QuestionDisplayProps { - question: Question; + questionId: string; } -const QuestionDisplay: React.FC = ({ - question = mockQuestions[0], -}) => { +const QuestionDisplay: React.FC = ({ questionId }) => { + const [question, setQuestion] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchQuestion = async () => { + setLoading(true); + setError(null); + + try { + const data = await getQuestionById(questionId); + + // Map backend response to frontend Question type + setQuestion({ + id: data.questionId, + title: data.title, + body: data.content, + topics: [data.categoryTitle ?? "Uncategorized"], + hints: data.hints ?? [], + answer: data.answer ?? "", // gracefully handle missing answer + difficulty: data.difficulty, + timeLimit: data.timeLimit, + }); + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message); + } else { + setError("Failed to load question"); + } + } finally { + setLoading(false); + } + }; + + fetchQuestion(); + }, [questionId]); + + if (loading) return

Loading question...

; + if (error) return

{error}

; + if (!question) return

No question found

; + return (
-

{question.title}

-
+

+ {question.title} +

+ +
{question.hints.map((hint, index) => ( - ))}{" "} - + ))} +
-
-

{question.body}

-
+
); }; diff --git a/ui-services/question-ui-service/src/components/QuestionForm.tsx b/ui-services/question-ui-service/src/components/QuestionForm.tsx new file mode 100644 index 000000000..2a9f51cc6 --- /dev/null +++ b/ui-services/question-ui-service/src/components/QuestionForm.tsx @@ -0,0 +1,215 @@ +import React, { useState } from "react"; +import { useForm, type SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@/components/ui/button"; +import { + questionSchema, + type QuestionForm, + type QuestionFormValues, +} from "@/types/QuestionSchemas"; + +interface QuestionFormUiProps { + initialValues?: QuestionFormValues; + mode: "add" | "edit"; + onSubmit: (data: QuestionForm, hints: string[]) => Promise; + onBack: () => void; +} + +const QuestionFormUi: React.FC = ({ + initialValues, + mode, + onSubmit, + onBack, +}) => { + const [hints, setHints] = useState(initialValues?.hints ?? [""]); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(questionSchema), + defaultValues: { + title: initialValues?.title ?? "", + categoryTitle: initialValues?.categoryTitle ?? "", + difficulty: initialValues?.difficulty ?? "Easy", + timeLimit: initialValues?.timeLimit ?? 60, + content: initialValues?.content ?? "", + }, + }); + + const addHint = () => setHints([...hints, ""]); + const removeHint = (index: number) => + setHints(hints.filter((_, i) => i !== index)); + const updateHint = (index: number, value: string) => { + const newHints = [...hints]; + newHints[index] = value; + setHints(newHints); + }; + + const handleFormSubmit: SubmitHandler = async (data) => { + const cleanedHints = hints.map((h) => h.trim()).filter(Boolean); + await onSubmit(data, cleanedHints); + }; + + return ( +
+
+

+ {mode === "add" ? "Add New Question" : "Edit Question"} +

+ + {/* Title */} +
+ + + {errors.title && ( + {errors.title.message} + )} +
+ +
+ {/* Category */} +
+ + + {errors.categoryTitle && ( + + {errors.categoryTitle.message} + + )} +
+ + {/* Difficulty */} +
+ + + {errors.difficulty && ( + + {errors.difficulty.message} + + )} +
+ + {/* Time Limit */} +
+ + + {errors.timeLimit && ( + + {errors.timeLimit.message} + + )} +
+
+ + {/* Content */} +
+ +