From 86cbfd5dc1f9a181213cea0ef842c6fb55435dd4 Mon Sep 17 00:00:00 2001 From: harshu167 Date: Sun, 5 Jul 2026 20:12:35 +0530 Subject: [PATCH] Implement Recovery Missions feature, integrate AI Config in Contest tab, resolve layout issues & add dev scripts --- .env.ssh.example | 2 +- .gitignore | 159 +++- client/package-lock.json | 39 - client/src/main.jsx | 1276 ++++++++++++++++++++++++++++++- client/src/styles.css | 178 ++++- package-lock.json | 634 +++++++++++++++ package.json | 10 +- scripts/dev.mjs | 52 ++ scripts/setup.mjs | 45 ++ server/models/AIConfig.js | 43 ++ server/models/Contest.js | 28 + server/models/ContestAttempt.js | 20 + server/models/Mission.js | 52 ++ server/models/MissionAttempt.js | 33 + server/models/SPTransaction.js | 2 +- server/models/SessionEvent.js | 2 +- server/routes/aiConfig.js | 137 ++++ server/routes/contest.js | 548 +++++++++++++ server/routes/missions.js | 459 +++++++++++ server/server.js | 18 + server/services/aiProvider.js | 364 +++++++++ server/services/crypto.js | 82 ++ tests/contest_balance.test.mjs | 112 +++ tests/contest_smoke.test.mjs | 169 ++++ 24 files changed, 4413 insertions(+), 51 deletions(-) create mode 100644 scripts/dev.mjs create mode 100644 scripts/setup.mjs create mode 100644 server/models/AIConfig.js create mode 100644 server/models/Contest.js create mode 100644 server/models/ContestAttempt.js create mode 100644 server/models/Mission.js create mode 100644 server/models/MissionAttempt.js create mode 100644 server/routes/aiConfig.js create mode 100644 server/routes/contest.js create mode 100644 server/routes/missions.js create mode 100644 server/services/aiProvider.js create mode 100644 server/services/crypto.js create mode 100644 tests/contest_balance.test.mjs create mode 100644 tests/contest_smoke.test.mjs diff --git a/.env.ssh.example b/.env.ssh.example index bfba24d..67ced46 100644 --- a/.env.ssh.example +++ b/.env.ssh.example @@ -1,6 +1,6 @@ # samagama.in deployment example. Copy to `.env` on the SSH server. PORT=5003 MONGO_URI=mongodb://127.0.0.1:27017/sakshi_spurti -ALLOW_STUDENT_SEARCH=false +ALLOW_STUDENT_SEARCH=true SPURTI_AUTH_SECRET=replace-with-the-same-secret-used-by-samagama SPURTI_COOKIE_SECURE=true diff --git a/.gitignore b/.gitignore index b4d3d20..e6c4a2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,163 @@ +# ===================================================================== +# Spurti — local-dev and secrets exclusion list +# Generated 2026-07-05. Anything in here is INTENTIONAL: do NOT commit. +# Personal/scratch notes stay local. Real code goes through PR review. +# ===================================================================== + +# ---- Node / package managers ---- node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.pnpm-store/ +.npm/ +.yarn/ +*.tgz + +# ---- Build outputs ---- +# Top-level (rare in this repo) dist/ +build/ +out/ +*.tsbuildinfo + +# Vite client +client/dist/ +client/.vite/ + +# Coverage / test artifacts +coverage/ +.nyc_output/ +*.lcov +junit.xml +test-results/ + +# ---- Runtime data & caches ---- +.cache/ +.parcel-cache/ +.eslintcache +.stylelintcache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +*.pid +*.seed +*.pid.lock + +# ---- Environment & secrets ---- +# Real .env / .env.local must NEVER be committed. .env .env.* !.env.example !.env.ssh.example +.envrc + +# Keys, certs, tokens +*.pem +*.key +*.crt +*.cer +*.p12 +secrets/ +.service-account.json +firebase-adminsdk*.json +gha-creds-*.json + +# ---- Mongo / data dumps ---- +*.mongo dump/ +dump/ +data/ +*.bson +*.json.gz + +# ---- Logs ---- +logs/ +*.log +spurti_server.log +spurti_server.err + +# ---- Local dev launchers (one-shot scripts using in-memory Mongo) ---- +run.sh +run-mongo.js +seed-students.js +scripts-dev-only/ + +# ---- Editor / IDE ---- +.idea/ +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json +!.vscode/launch.json +!.vscode/tasks.json +*.swp +*.swo +*.swn +*~ +.vim/ +*.iml +.nbproject/ +.classpath +.project +.settings/ +*.sublime-project +*.sublime-workspace +.cursor/ +.aider* +.continue/ + +# ---- OS junk ---- .DS_Store -npm-debug.log* -yarn-debug.log* -yarn-error.log* +.AppleDouble +.LSOverride +Icon? +ehthumbs.db +Thumbs.db +Desktop.ini +$RECYCLE.BIN/ +*~ + +# ---- Hermes / agent scratch (local only) ---- +.hermes/ +.spurti-scratch/ +scratch/ +brain/ +.agents/ +spurti_scan.py +spurti_*.py +spurti_search.sh +spurti_issues.json +spurti_pulls.json +spurti_branches.json +spurti_tree.json +spurti_pr*.json +spurti_pr*.txt +spurti_pr*.patch +spurti_pr*.txt +spurti_pipeline*.txt +spurti-inspect/ +list_spurti.py +spurti-*-intel.md +spurti-contest-mechanics-menu.md +spurti-engagement-mechanics-brainstorm.md +spurti-design-system.md +spurti-pr-analysis.md + +# ---- Temp / patch artifacts from local tooling ---- +*.tmp +*.bak +*.orig +*.rej +.gitignore.backup-* + +# ---- Contest-feature working files (NOT for upstream PR) ---- +# These are personal scratch for the in-progress "your contest" work +CONTEST_CHANGELOG.md +CONTEST_INTENT_MAP.md + +# ---- Misc ---- +.history/ +.firebase/ +.vercel/ +.netlify/ +.turbo/ diff --git a/client/package-lock.json b/client/package-lock.json index b8bb10c..722ee1b 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -782,9 +782,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -798,9 +795,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -814,9 +808,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -830,9 +821,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -846,9 +834,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -862,9 +847,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -878,9 +860,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -894,9 +873,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -910,9 +886,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -926,9 +899,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -942,9 +912,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -958,9 +925,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -974,9 +938,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/client/src/main.jsx b/client/src/main.jsx index db54082..c66a2b1 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -260,12 +260,14 @@ function StudentView({ profile, onBack }) {
SP{student.totalSp}Rank {student.rank} of {student.cohortSize}
- + {tab === 'bank' && } {tab === 'chats' && } {tab === 'polls' && } {tab === 'leaderboard' && } {tab === 'event' && } + {tab === 'contest' && } + {tab === 'missions' && } ); } @@ -537,7 +539,7 @@ function AdminView({ admin, auth, onBack }) {

Admin Dashboard

Spurti Control Room

Yet to onboard{stats?.yetToOnboard ?? admin.yetToOnboard ?? 0}|Active{stats?.activeStudents ?? admin.activeStudents ?? admin.students ?? 0}|Excused{stats?.excusedStudents ?? admin.excusedStudents ?? 0}{stats?.transactions ?? admin.transactions ?? 0} txns
- + {tab === 'leaderboard' && (
@@ -559,6 +561,8 @@ function AdminView({ admin, auth, onBack }) { {tab === 'analytics' && } {tab === 'students' && } {tab === 'investment' && } + {tab === 'contest-mgr' && } + {tab === 'missions-mgr' && } {studentProfile &&

{studentProfile.student.name}

} ); @@ -1270,5 +1274,1273 @@ function InvestmentAdminPanel({ headers }) {
); } +// ═══════════════════════════════════════════ +// CONTEST — AI CONFIG PANEL +// ═══════════════════════════════════════════ + +function ContestAIConfig({ headers, onBack }) { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [msg, setMsg] = useState(''); + + const loadConfig = async () => { + try { + const res = await fetch(`${API}/ai-config/config`, { headers }); + if (res.ok) setConfig(await res.json()); + } finally { setLoading(false); } + }; + + useEffect(() => { loadConfig(); }, []); + + const saveConfig = async () => { + setSaving(true); setMsg(''); + try { + const res = await fetch(`${API}/ai-config/config`, { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + if (!res.ok) { const d = await res.json(); setMsg(d.error || 'Save failed.'); } + else { setMsg('Configuration saved!'); setConfig(await res.json()); } + } catch (e) { setMsg('Error: ' + e.message); } + finally { setSaving(false); } + }; + + const testConfig = async () => { + setTesting(true); setMsg('Testing connection...'); + try { + const res = await fetch(`${API}/ai-config/config/test`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(config) // Send the current (unsaved) apiKey to test + }); + const data = await res.json(); + if (res.ok) setMsg(`Success! Connected to ${data.model}`); + else setMsg(data.error || 'Test failed.'); + } catch (e) { setMsg('Error: ' + e.message); } + finally { setTesting(false); } + }; + + if (loading) return

Loading...

; + if (!config) return

Failed to load AI Config

; + + return ( +
+
+

⚙️ AI Configuration

+ +
+
+
+ + +
+
+ + setConfig({...config, baseUrl: e.target.value})} placeholder="e.g. https://api.openai.com/v1" /> +
+
+ + setConfig({...config, modelName: e.target.value})} placeholder="e.g. gpt-4o" /> +
+
+ + setConfig({...config, apiKey: e.target.value})} placeholder={config.apiKeyConfigured ? `Configured (ends in ${config.apiKeyLast4}) - Type to replace` : "Enter API Key"} /> +
+
+ + +
+ {msg &&

{msg}

} +
+
+ ); +} + +// ═══════════════════════════════════════════ +// CONTEST — ADMIN PANEL +// ═══════════════════════════════════════════ + +function ContestAdminPanel({ headers }) { + const [contests, setContests] = useState([]); + const [loading, setLoading] = useState(true); + const [subView, setSubView] = useState('list'); // list | create | stats | aiconfig + const [draft, setDraft] = useState(null); + const [transcript, setTranscript] = useState(''); + const [contestName, setContestName] = useState(''); + const [statsData, setStatsData] = useState(null); + const [msg, setMsg] = useState(''); + + const loadContests = async () => { + setLoading(true); + try { + const res = await fetch(`${API}/contest/admin/contests`, { headers }); + if (res.ok) setContests(await res.json()); + } finally { setLoading(false); } + }; + + useEffect(() => { loadContests(); }, []); + + const handleFileUpload = (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => setTranscript(ev.target.result); + reader.readAsText(file); + }; + + const generateDraft = async () => { + if (!transcript.trim()) return setMsg('Please paste or upload a transcript first.'); + setMsg('Generating draft questions...'); + try { + const res = await fetch(`${API}/contest/admin/contests/create-from-transcript`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ transcript, contestName: contestName || 'Weekend Contest' }) + }); + const data = await res.json(); + if (!res.ok) return setMsg(data.error || 'Failed to generate draft.'); + setDraft(data); + setMsg('Draft generated! Review and edit questions below, then save.'); + } catch (err) { setMsg('Error: ' + err.message); } + }; + + const saveDraft = async () => { + if (!draft) return; + setMsg('Saving...'); + try { + const res = await fetch(`${API}/contest/admin/contests`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(draft) + }); + if (!res.ok) { const d = await res.json(); return setMsg(d.error || 'Save failed.'); } + setMsg('Contest saved!'); + setDraft(null); setTranscript(''); setContestName(''); + setSubView('list'); + await loadContests(); + } catch (err) { setMsg('Error: ' + err.message); } + }; + + const toggleContest = async (id) => { + await fetch(`${API}/contest/admin/contests/${id}/toggle`, { method: 'POST', headers }); + await loadContests(); + }; + + const loadStats = async (id) => { + const res = await fetch(`${API}/contest/admin/contests/${id}/stats`, { headers }); + if (res.ok) { setStatsData(await res.json()); setSubView('stats'); } + }; + + const updateDraftQuestion = (index, field, value) => { + setDraft(prev => { + const questions = [...prev.questions]; + questions[index] = { ...questions[index], [field]: value }; + return { ...prev, questions }; + }); + }; + + const updateDraftOption = (qIndex, oIndex, value) => { + setDraft(prev => { + const questions = [...prev.questions]; + const options = [...questions[qIndex].options]; + options[oIndex] = value; + questions[qIndex] = { ...questions[qIndex], options }; + return { ...prev, questions }; + }); + }; + + const removeQuestion = (index) => { + setDraft(prev => ({ ...prev, questions: prev.questions.filter((_, i) => i !== index) })); + }; + + const addQuestion = () => { + setDraft(prev => ({ + ...prev, + questions: [...prev.questions, { question: 'New question?', options: ['Option A', 'Option B', 'Option C', 'Option D'], correctAnswer: 0, timeLimit: 20 }] + })); + }; + + // ── LIST VIEW ── + if (subView === 'list') { + return ( +
+
+

🏆 Contest Manager

+
+ + +
+
+ {loading ?

Loading...

: contests.length === 0 ?

No contests created yet.

: ( + + + {contests.map(c => ( + + + + + + + + + + ))} +
NameQuestionsThresholdSPMax AttemptsActiveActions
{c.name}{c.questions?.length || 0}{c.threshold}%{c.spReward}{c.maxAttempts || '∞'}{c.isActive ? 'Active' : 'Inactive'} +
+ + +
+
+ )} +
+ ); + } + + // ── AI CONFIG VIEW ── + if (subView === 'aiconfig') { + return setSubView('list')} />; + } + + // ── STATS VIEW ── + if (subView === 'stats' && statsData) { + const { contest, stats, attempts } = statsData; + return ( +
+
+

📊 {contest.name} — Stats

+ +
+
+ + + + + +
+ {attempts.length > 0 && ( +
+ + + {attempts.map(a => ( + + + + + + + + + + + ))} +
StudentEmailAttemptScorePassedReflectionSPDate
{a.studentName}{a.studentEmail}#{a.attemptNumber}{a.score}%{a.passed ? 'Yes' : 'No'}{a.reflectionResponse ? '✅' : '—'}{a.earnedSp > 0 ? `+${a.earnedSp}` : '0'}{new Date(a.completedAt).toLocaleString()}
+
+ )} +
+ ); + } + + // ── CREATE VIEW ── + return ( +
+
+

📝 Create New Contest

+ +
+ + {!draft ? ( +
+
+ + setContestName(e.target.value)} placeholder="e.g. Weekend Quiz - 5 July" /> +
+
+ + +
+
+ +