Skip to content

Commit c187444

Browse files
committed
feat(admin): add domain-question authoring form, validation, and bulk upload (#20)
1 parent 2698b5d commit c187444

6 files changed

Lines changed: 509 additions & 16 deletions

File tree

admin-client/src/pages/AdminPanel.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import PageHeader from '../components/PageHeader';
1212
// --- Subcomponents for Tabs ---
1313
import DashboardTab from './admin/DashboardTab';
1414
import QuestionSetsTab from './admin/QuestionSetsTab';
15+
import DomainQuestionsTab from './admin/DomainQuestionsTab';
1516

1617
import ClanManagerTab from './admin/ClanManagerTab';
1718
import ResourcesTab from './admin/ResourcesTab';
@@ -20,7 +21,7 @@ import ReviewTab from './admin/ReviewTab';
2021

2122
const AdminPanel = () => {
2223
const [searchParams] = useSearchParams();
23-
const validTabs = ['dashboard', 'review', 'sets', 'clans', 'resources', 'members'];
24+
const validTabs = ['dashboard', 'review', 'sets', 'domain', 'clans', 'resources', 'members'];
2425
const initialTab = validTabs.includes(searchParams.get('tab')) ? searchParams.get('tab') : 'dashboard';
2526
const [activeTab, setActiveTab] = useState(initialTab);
2627
const [initialClanFilter, setInitialClanFilter] = useState('');
@@ -29,6 +30,7 @@ const AdminPanel = () => {
2930
{ id: 'dashboard', label: 'Overview', icon: FiActivity },
3031
{ id: 'review', label: 'Review Work', icon: FiEye },
3132
{ id: 'sets', label: 'Question Sets', icon: FiCode },
33+
{ id: 'domain', label: 'Domain Questions', icon: FiFileText },
3234

3335
{ id: 'clans', label: 'Clan Manager', icon: FiShield },
3436
{ id: 'resources', label: 'Resources', icon: FiFolder },
@@ -85,6 +87,7 @@ const AdminPanel = () => {
8587
{activeTab === 'dashboard' && <DashboardTab setActiveTab={setActiveTab} setInitialClanFilter={setInitialClanFilter} />}
8688
{activeTab === 'review' && <ReviewTab />}
8789
{activeTab === 'sets' && <QuestionSetsTab />}
90+
{activeTab === 'domain' && <DomainQuestionsTab />}
8891

8992
{activeTab === 'clans' && <ClanManagerTab />}
9093
{activeTab === 'resources' && <ResourcesTab />}
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import React, { useState } from 'react';
2+
import toast from 'react-hot-toast';
3+
import { FiPlus, FiTrash2, FiUploadCloud } from 'react-icons/fi';
4+
import BaseCard from '../../components/BaseCard';
5+
import { api } from '../../lib/api';
6+
7+
const DIFFICULTIES = ['Easy', 'Medium', 'Hard'];
8+
9+
const emptyForm = () => ({
10+
type: 'mcq',
11+
title: '',
12+
description: '',
13+
difficulty: 'Easy',
14+
tags: '',
15+
options: ['', ''],
16+
correctOption: 0,
17+
explanation: '',
18+
modelAnswer: '',
19+
});
20+
21+
// Pull a readable list of messages out of the API's validation-error response.
22+
const extractErrors = (err) => {
23+
const data = err?.response?.data;
24+
if (!data) return [err?.message || 'Request failed'];
25+
const out = [];
26+
if (Array.isArray(data.errors)) {
27+
for (const e of data.errors) out.push(e.message || `${e.path || ''} invalid`);
28+
} else if (data.errors && typeof data.errors === 'object') {
29+
for (const [k, v] of Object.entries(data.errors)) out.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
30+
}
31+
if (!out.length) out.push(data.message || 'Validation failed');
32+
return out;
33+
};
34+
35+
const inputCls =
36+
'w-full rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-sm text-white/90 focus:border-indigo-400 focus:outline-none';
37+
38+
const DomainQuestionsTab = () => {
39+
const [form, setForm] = useState(emptyForm());
40+
const [errors, setErrors] = useState([]);
41+
const [saving, setSaving] = useState(false);
42+
43+
const [bulkText, setBulkText] = useState('');
44+
const [bulkResult, setBulkResult] = useState(null);
45+
const [bulkBusy, setBulkBusy] = useState(false);
46+
47+
const set = (patch) => setForm((f) => ({ ...f, ...patch }));
48+
49+
const setOption = (i, val) =>
50+
setForm((f) => ({ ...f, options: f.options.map((o, idx) => (idx === i ? val : o)) }));
51+
const addOption = () => setForm((f) => ({ ...f, options: [...f.options, ''] }));
52+
const removeOption = (i) =>
53+
setForm((f) => {
54+
const options = f.options.filter((_, idx) => idx !== i);
55+
const correctOption = f.correctOption >= options.length ? 0 : f.correctOption;
56+
return { ...f, options, correctOption };
57+
});
58+
59+
const buildPayload = () => {
60+
const base = {
61+
type: form.type,
62+
title: form.title.trim(),
63+
description: form.description.trim(),
64+
difficulty: form.difficulty,
65+
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
66+
};
67+
if (form.type === 'mcq') {
68+
return {
69+
...base,
70+
options: form.options.map((o) => o.trim()).filter(Boolean),
71+
correctOption: Number(form.correctOption),
72+
explanation: form.explanation.trim(),
73+
};
74+
}
75+
return { ...base, modelAnswer: form.modelAnswer.trim() };
76+
};
77+
78+
const onCreate = async (e) => {
79+
e.preventDefault();
80+
setErrors([]);
81+
setSaving(true);
82+
try {
83+
await api.post('/api/challenges', buildPayload());
84+
toast.success('Question created');
85+
setForm(emptyForm());
86+
} catch (err) {
87+
const msgs = extractErrors(err);
88+
setErrors(msgs);
89+
toast.error(msgs[0]);
90+
} finally {
91+
setSaving(false);
92+
}
93+
};
94+
95+
const onBulk = async () => {
96+
setBulkResult(null);
97+
let parsed;
98+
try {
99+
parsed = JSON.parse(bulkText);
100+
} catch {
101+
toast.error('Bulk input is not valid JSON');
102+
return;
103+
}
104+
const questions = Array.isArray(parsed) ? parsed : parsed.questions;
105+
if (!Array.isArray(questions) || questions.length === 0) {
106+
toast.error('Provide a JSON array of questions (or { "questions": [...] })');
107+
return;
108+
}
109+
setBulkBusy(true);
110+
try {
111+
const res = await api.post('/api/challenges/domain/bulk', { questions });
112+
setBulkResult(res.data.data);
113+
toast.success(res.data.message || 'Uploaded');
114+
} catch (err) {
115+
toast.error(extractErrors(err)[0]);
116+
} finally {
117+
setBulkBusy(false);
118+
}
119+
};
120+
121+
return (
122+
<div className="grid gap-6 lg:grid-cols-2">
123+
{/* ── Single question authoring ─────────────────────────────── */}
124+
<BaseCard className="p-5">
125+
<h3 className="mb-4 text-lg font-semibold text-white">New domain question</h3>
126+
<form onSubmit={onCreate} className="space-y-4">
127+
<div className="flex gap-2">
128+
{['mcq', 'written'].map((t) => (
129+
<button
130+
key={t}
131+
type="button"
132+
onClick={() => set({ type: t })}
133+
className={`rounded-lg px-3 py-1.5 text-sm capitalize ${
134+
form.type === t ? 'bg-indigo-500 text-white' : 'bg-white/5 text-white/60'
135+
}`}
136+
>
137+
{t === 'mcq' ? 'Multiple choice' : 'Written'}
138+
</button>
139+
))}
140+
</div>
141+
142+
<input
143+
className={inputCls}
144+
placeholder="Title"
145+
value={form.title}
146+
onChange={(e) => set({ title: e.target.value })}
147+
/>
148+
<textarea
149+
className={inputCls}
150+
rows={3}
151+
placeholder="Question / prompt"
152+
value={form.description}
153+
onChange={(e) => set({ description: e.target.value })}
154+
/>
155+
156+
<div className="flex gap-3">
157+
<select
158+
className={inputCls}
159+
value={form.difficulty}
160+
onChange={(e) => set({ difficulty: e.target.value })}
161+
>
162+
{DIFFICULTIES.map((d) => (
163+
<option key={d} value={d}>{d}</option>
164+
))}
165+
</select>
166+
<input
167+
className={inputCls}
168+
placeholder="Tags (comma separated)"
169+
value={form.tags}
170+
onChange={(e) => set({ tags: e.target.value })}
171+
/>
172+
</div>
173+
174+
{form.type === 'mcq' ? (
175+
<div className="space-y-2">
176+
<p className="text-xs uppercase tracking-wide text-white/40">
177+
Options (select the correct one)
178+
</p>
179+
{form.options.map((opt, i) => (
180+
<div key={i} className="flex items-center gap-2">
181+
<input
182+
type="radio"
183+
name="correctOption"
184+
checked={Number(form.correctOption) === i}
185+
onChange={() => set({ correctOption: i })}
186+
/>
187+
<input
188+
className={inputCls}
189+
placeholder={`Option ${i + 1}`}
190+
value={opt}
191+
onChange={(e) => setOption(i, e.target.value)}
192+
/>
193+
{form.options.length > 2 && (
194+
<button type="button" onClick={() => removeOption(i)} className="text-white/40 hover:text-red-400">
195+
<FiTrash2 />
196+
</button>
197+
)}
198+
</div>
199+
))}
200+
<button type="button" onClick={addOption} className="flex items-center gap-1 text-sm text-indigo-300">
201+
<FiPlus /> Add option
202+
</button>
203+
<textarea
204+
className={inputCls}
205+
rows={2}
206+
placeholder="Explanation (shown after a graded attempt, optional)"
207+
value={form.explanation}
208+
onChange={(e) => set({ explanation: e.target.value })}
209+
/>
210+
</div>
211+
) : (
212+
<textarea
213+
className={inputCls}
214+
rows={5}
215+
placeholder="Model answer (revealed to the participant after they submit)"
216+
value={form.modelAnswer}
217+
onChange={(e) => set({ modelAnswer: e.target.value })}
218+
/>
219+
)}
220+
221+
{errors.length > 0 && (
222+
<ul className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-300">
223+
{errors.map((msg, i) => (
224+
<li key={i}>{msg}</li>
225+
))}
226+
</ul>
227+
)}
228+
229+
<button
230+
type="submit"
231+
disabled={saving}
232+
className="w-full rounded-lg bg-indigo-500 py-2 text-sm font-medium text-white hover:bg-indigo-400 disabled:opacity-50"
233+
>
234+
{saving ? 'Saving…' : 'Create question'}
235+
</button>
236+
</form>
237+
</BaseCard>
238+
239+
{/* ── Bulk upload ───────────────────────────────────────────── */}
240+
<BaseCard className="p-5">
241+
<h3 className="mb-1 flex items-center gap-2 text-lg font-semibold text-white">
242+
<FiUploadCloud /> Bulk upload
243+
</h3>
244+
<p className="mb-3 text-sm text-white/50">
245+
Paste a JSON array of questions. Valid entries are created; invalid ones are reported below.
246+
</p>
247+
<textarea
248+
className={`${inputCls} font-mono`}
249+
rows={12}
250+
placeholder={'[\n { "type": "mcq", "title": "...", "description": "...", "difficulty": "Easy",\n "options": ["A","B"], "correctOption": 0, "tags": ["databases"] }\n]'}
251+
value={bulkText}
252+
onChange={(e) => setBulkText(e.target.value)}
253+
/>
254+
<button
255+
type="button"
256+
onClick={onBulk}
257+
disabled={bulkBusy}
258+
className="mt-3 w-full rounded-lg bg-emerald-500 py-2 text-sm font-medium text-white hover:bg-emerald-400 disabled:opacity-50"
259+
>
260+
{bulkBusy ? 'Uploading…' : 'Upload'}
261+
</button>
262+
263+
{bulkResult && (
264+
<div className="mt-4 space-y-2 text-sm">
265+
<p className="text-emerald-300">Created {bulkResult.createdCount} question(s).</p>
266+
{bulkResult.failures?.length > 0 && (
267+
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-amber-200">
268+
<p className="mb-1 font-medium">{bulkResult.failures.length} failed:</p>
269+
<ul className="space-y-1">
270+
{bulkResult.failures.map((f) => (
271+
<li key={f.index}>
272+
#{f.index + 1}: {f.errors.map((e) => `${e.path}${e.message}`).join('; ')}
273+
</li>
274+
))}
275+
</ul>
276+
</div>
277+
)}
278+
</div>
279+
)}
280+
</BaseCard>
281+
</div>
282+
);
283+
};
284+
285+
export default DomainQuestionsTab;

server/src/features/challenges/challenge.controller.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,10 +495,50 @@ const selfAssessDomain = async (req, res, next) => {
495495
}
496496
};
497497

498+
// Bulk-create domain questions from a JSON array. Each entry is validated independently
499+
// so valid rows are inserted and invalid ones are reported without failing the batch.
500+
const bulkCreateDomainQuestions = async (req, res, next) => {
501+
try {
502+
const { domainQuestionObject } = require('../../../validators/challengeSchemas');
503+
const entries = Array.isArray(req.body) ? req.body : req.body?.questions;
504+
if (!Array.isArray(entries) || entries.length === 0) {
505+
res.status(400);
506+
throw new Error('Provide a non-empty "questions" array');
507+
}
508+
509+
const createdIds = [];
510+
const failures = [];
511+
512+
for (let i = 0; i < entries.length; i += 1) {
513+
const parsed = domainQuestionObject.safeParse(entries[i]);
514+
if (!parsed.success) {
515+
failures.push({
516+
index: i,
517+
errors: parsed.error.issues.map((is) => ({ path: is.path.join('.'), message: is.message })),
518+
});
519+
continue;
520+
}
521+
const data = parsed.data;
522+
if (data.points == null) data.points = getPointsForDifficulty(data.difficulty);
523+
const doc = await Challenge.create(data);
524+
createdIds.push(doc._id);
525+
}
526+
527+
return sendSuccess(res, {
528+
statusCode: createdIds.length > 0 ? 201 : 400,
529+
data: { createdCount: createdIds.length, createdIds, failures },
530+
message: `Created ${createdIds.length} question(s), ${failures.length} failed`,
531+
});
532+
} catch (err) {
533+
next(err);
534+
}
535+
};
536+
498537
module.exports = {
499538
getChallenges,
500539
browseDomainPool,
501540
selfAssessDomain,
541+
bulkCreateDomainQuestions,
502542
getChallengeById,
503543
createChallenge,
504544
updateChallenge,

server/src/features/challenges/challenge.routes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const {
1212
getLeetCodeDetails,
1313
browseDomainPool,
1414
selfAssessDomain,
15+
bulkCreateDomainQuestions,
1516
} = require('./challenge.controller');
1617

1718
const { protect, admin } = require('../../../middleware/auth');
@@ -44,6 +45,7 @@ router.get('/fetch-leetcode-details', protect, admin, getLeetCodeDetails);
4445
// Domain-question pool browse (must precede '/:id' so 'domain' isn't read as an id).
4546
router.get('/domain', protect, browseDomainPool);
4647
router.post('/domain/self-assess', protect, selfAssessDomain);
48+
router.post('/domain/bulk', protect, admin, bulkCreateDomainQuestions);
4749

4850
router.post('/import', protect, admin, upload.single('file'), importChallenges);
4951

0 commit comments

Comments
 (0)