From 4b5b86ea2459b6a7cdc4b0286a8bbca0727e570a Mon Sep 17 00:00:00 2001 From: rishisaini1403-ship-it Date: Wed, 15 Jul 2026 18:47:49 +0530 Subject: [PATCH 1/4] feat(student): add SP Investment Vault --- client/src/main.jsx | 291 +++++++++++++++++++++++-- client/src/styles.css | 152 +++++++++++++ server/config.js | 12 ++ server/models/InvestmentRecord.js | 28 +++ server/server.js | 145 ++++++++++++- server/services/investmentService.js | 310 +++++++++++++++++++++++++++ 6 files changed, 923 insertions(+), 15 deletions(-) create mode 100644 server/models/InvestmentRecord.js create mode 100644 server/services/investmentService.js diff --git a/client/src/main.jsx b/client/src/main.jsx index cebf2cc..0d3cfd7 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -17,6 +17,7 @@ function App() { useEffect(() => { if (!profile?.student) return; const send = () => fetch(`${API}/ping`, { + credentials: 'include', method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -35,13 +36,13 @@ function App() { let active = true; async function bootstrap() { try { - const configRes = await fetch(`${API}/config`); + const configRes = await fetch(`${API}/config`, { credentials: 'include' }); const nextConfig = configRes.ok ? await configRes.json() : { allowStudentSearch: true }; if (!active) return; setConfig(nextConfig); if (view !== 'admin-login') { - const meRes = await fetch(`${API}/me`); + const meRes = await fetch(`${API}/me`, { credentials: 'include' }); if (meRes.ok) { const data = await meRes.json(); if (data.authenticated && data.profile && active) { @@ -149,7 +150,7 @@ function AdminLogin({ onAdmin, onBack }) { setError(''); try { const auth = { email, token }; - const res = await fetch(`${API}/admin/stats`, { headers: adminHeaders(auth) }); + const res = await fetch(`${API}/admin/stats`, { credentials: 'include', headers: adminHeaders(auth) }); if (!res.ok) throw new Error('Forbidden'); onAdmin(await res.json(), auth); } catch { @@ -208,7 +209,7 @@ function SearchModal({ onClose, onStudent }) { const search = async () => { if (query.trim().length < 2) return setMessage('Type at least 2 characters.'); - const res = await fetch(`${API}/search?q=${encodeURIComponent(query.trim())}`); + const res = await fetch(`${API}/search?q=${encodeURIComponent(query.trim())}`, { credentials: 'include' }); const data = await res.json(); if (data.excused) return onStudent(data); if (data.exact) return onStudent(data.profile); @@ -218,6 +219,7 @@ function SearchModal({ onClose, onStudent }) { const confirm = async () => { const res = await fetch(`${API}/confirm`, { + credentials: 'include', method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ studentId: selected?._id, email: confirmEmail }) @@ -279,10 +281,11 @@ function StudentView({ profile, onBack }) { - + {tab === 'bank' && } {tab === 'polls' && } {tab === 'leaderboard' && } + {tab === 'vault' && } ); } @@ -505,6 +508,267 @@ function Leaderboard({ rows }) { ); } +function formatDate(date) { + return new Date(date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); +} + +const PLAN_DESCRIPTIONS = { + safe: 'Recommended for short-term commitment.', + growth: 'Balanced reward and commitment.', + diamond: 'Highest reward for committed students.' +}; + +function Vault({ student }) { + const [plans, setPlans] = useState([]); + const [investments, setInvestments] = useState([]); + const [loading, setLoading] = useState(true); + const [planKey, setPlanKey] = useState(''); + const [principal, setPrincipal] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [confirming, setConfirming] = useState(false); + + const loadData = async () => { + try { + const [plansRes, mineRes] = await Promise.all([ + fetch(`${API}/investments/plans`, { credentials: 'include' }), + fetch(`${API}/investments/mine`, { credentials: 'include' }) + ]); + if (plansRes.ok) { + const data = await plansRes.json(); + setPlans(data.plans || []); + if (!planKey && data.plans?.length) setPlanKey(data.plans[0].key); + } + if (mineRes.ok) { + const data = await mineRes.json(); + setInvestments(data.investments || []); + } + } catch { + setError('Failed to load vault data.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadData(); }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const actuallySubmit = async () => { + setError(''); + setSuccess(''); + setSubmitting(true); + setConfirming(false); + try { + const res = await fetch(`${API}/investments`, { + credentials: 'include', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ planKey, principal: Number(principal) }) + }); + const data = await res.json(); + if (!res.ok) { + const message = ({ + INSUFFICIENT_BALANCE_OR_INACTIVE: 'Insufficient SP balance.', + ALREADY_ACTIVE: 'You already have an active investment.', + INVALID_PLAN: 'Please select an investment plan.', + BELOW_MIN_PRINCIPAL: `Minimum investment is ${minPrincipal} SP.` + })[data.error] || data.error || 'Failed to create investment. Please try again.'; + setError(message); + return; + } + setSuccess(`Investment created successfully. ${data.investment.principal} SP locked in the ${planKey} vault.`); + setPrincipal(''); + await loadData(); + } catch { + setError('Network error — please try again.'); + } finally { + setSubmitting(false); + } + }; + + const onInvestClick = (e) => { + if (e && e.preventDefault) e.preventDefault(); + if (submitting) return; + if (!principalValid || !selectedPlan) return; + setConfirming(true); + }; + + if (loading) return
Loading vault…
; + + const active = investments.find(i => i.status === 'active'); + const history = investments.filter(i => i.status !== 'active'); + const selectedPlan = plans.find(p => p.key === planKey); + const minPrincipal = selectedPlan?.minPrincipal ?? plans[0]?.minPrincipal ?? 10; + const principalNum = Number(principal); + const principalValid = Number.isFinite(principalNum) && principalNum >= minPrincipal && principalNum <= student.totalSp; + const expectedProfit = (selectedPlan && principalValid) + ? Math.round(principalNum * selectedPlan.bonusRate) + : 0; + const expectedReturn = principalValid ? principalNum + expectedProfit : 0; + + return ( +
+

SP Investment Vault

+

Lock your Spurti Points to earn a bonus — but only if you attend every session during the lock period. If attendance slips, the invested SP is forfeited.

+ + {active && ( +
+
+ Active Investment + {active.planKey.charAt(0).toUpperCase() + active.planKey.slice(1)} Plan +
+ {active.principal} SP locked + Bonus: +{Math.round(active.bonusRate * 100)}% + Expected Return: {Math.round(active.principal * active.bonusRate) + active.principal} SP + Matures: {formatDate(active.endDate)} +
+
+ active +
+ )} + + {!active && ( +
+
+ {plans.map(plan => ( + + ))} +
+
+ setPrincipal(e.target.value)} + placeholder={`Amount in SP (min ${minPrincipal}, you have ${student.totalSp})`} + /> + +
+
+ )} + + {!active && selectedPlan && principalValid && ( +
+

SP Investment Calculator

+
+
Selected Plan{selectedPlan.label}
+
Investment Amount{principalNum} SP
+
Duration{selectedPlan.durationDays} Days
+
Attendance Requirement{Math.round(selectedPlan.attendanceRequirement * 100)}%
+
Bonus Percentage{Math.round(selectedPlan.bonusRate * 100)}%
+
Expected Profit+{expectedProfit} SP
+
Expected Return{expectedReturn} SP
+
+

+ If you successfully maintain the required attendance during the investment period, you will receive {expectedReturn} SP when your investment matures. +

+
+ )} + + {confirming && selectedPlan && ( + setConfirming(false)} + onConfirm={actuallySubmit} + /> + )} + + {error &&

{error}

} + {success &&

{success}

} + + {investments.length > 0 && ( +
+ {history.length > 0 &&

History

} + {history.map(inv => { + const planLabel = plans.find(p => p.key === inv.planKey)?.label || inv.planKey; + return ( +
+
+ {planLabel.toUpperCase()} PLAN + {inv.status.toUpperCase()} +
+
+
Invested: {inv.principal} SP
+ {inv.status === 'completed' && ( + <> +
Bonus Earned: +{inv.bonus} SP
+
Received: {inv.totalReturn} SP
+
Matured on: {formatDate(inv.endDate)}
+ + )} + {inv.status === 'failed' && ( +
Reason: Attendance requirement not met.
+ )} + {inv.status === 'cancelled' && ( +
Status: Cancelled
+ )} +
+
+ ); + })} + {active && history.length === 0 && ( +

No completed investments yet.

+ )} +
+ )} +

Note: Investments are automatically resolved when you visit the Vault after the maturity date. If you meet the attendance requirements, your invested SP and eligible bonus will be credited to your SP balance automatically.

+
+ ); +} + +function VaultConfirmModal({ plan, principal, expectedProfit, expectedReturn, submitting, onCancel, onConfirm }) { + return ( +
e.target === e.currentTarget && !submitting && onCancel()}> +
+
+

Confirm Your Investment

+ +
+

You are about to invest:

+

{principal} SP

+
+
Selected Plan{plan.label}
+
Duration{plan.durationDays} Days
+
Attendance Requirement{Math.round(plan.attendanceRequirement * 100)}%
+
Bonus Percentage{Math.round(plan.bonusRate * 100)}%
+
Expected Profit+{expectedProfit} SP
+
Expected Return{expectedReturn} SP
+
+
+

+ Important Notice: Your SP will remain locked until the investment matures. If you fail to maintain the required attendance during the investment period, your investment may fail and you may lose the invested SP. +

+
+
+ + +
+
+
+ ); +} + function AdminView({ admin, auth, onBack }) { const [tab, setTab] = useState('leaderboard'); const [leaderLimit, setLeaderLimit] = useState(50); @@ -521,6 +785,7 @@ function AdminView({ admin, auth, onBack }) { useEffect(() => { if (!auth?.email) return; const doPing = (page) => fetch(`${API}/ping`, { + credentials: 'include', method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: auth.email, name: auth.email, page }) @@ -530,29 +795,29 @@ function AdminView({ admin, auth, onBack }) { return () => clearInterval(id); }, [admin]); const loadLeaderboard = async (limit = leaderLimit) => { - const res = await fetch(`${API}/admin/leaderboard?limit=${limit}`, { headers }); + const res = await fetch(`${API}/admin/leaderboard?limit=${limit}`, { credentials: 'include', headers }); setLeaderboard(await res.json()); }; const loadAttendance = async () => { - const res = await fetch(`${API}/admin/attendance`, { headers }); + const res = await fetch(`${API}/admin/attendance`, { credentials: 'include', headers }); setAttendance(await res.json()); }; const loadStudent = async (id) => { - const res = await fetch(`${API}/admin/student/${id}`, { headers }); + const res = await fetch(`${API}/admin/student/${id}`, { credentials: 'include', headers }); setStudentProfile(await res.json()); }; const loadActive = async () => { - const res = await fetch(`${API}/admin/active`, { headers }); + const res = await fetch(`${API}/admin/active`, { credentials: 'include', headers }); setActive(await res.json()); }; const loadAnalytics = async () => { - const res = await fetch(`${API}/admin/analytics`, { headers }); + const res = await fetch(`${API}/admin/analytics`, { credentials: 'include', headers }); setAnalytics(await res.json()); }; useEffect(() => { loadLeaderboard(50); fetchStats(); }, []); const fetchStats = async () => { - const r = await fetch(`${API}/admin/stats`, headers); + const r = await fetch(`${API}/admin/stats`, { ...headers, credentials: 'include' }); if (r.ok) setStats(await r.json()); }; useEffect(() => { @@ -744,7 +1009,7 @@ function AllStudentsPanel({ stats, onStudent, auth }) { const loadList = async (status) => { setLoading(true); try { - const res = await fetch(`${API}/admin/students-by-status?status=${status}&limit=200`, headers); + const res = await fetch(`${API}/admin/students-by-status?status=${status}&limit=200`, { ...headers, credentials: 'include' }); if (res.ok) setList(await res.json()); } finally { setLoading(false); @@ -789,7 +1054,7 @@ function SurveyModal({ survey, student, onDone, statusPath = '/survey/status', c if (done.current) return; if (showNote) { setChecking(true); setNote(''); } try { - const r = await fetch(`${API}${statusPath}`); + const r = await fetch(`${API}${statusPath}`, { credentials: 'include' }); if (r.ok && (await r.json()).completed) { done.current = true; onDone(); return; } if (showNote) setNote("We haven't received your response yet. Please make sure you pressed Submit in the form above — this window closes on its own once your response is recorded (it can take a few seconds)."); } catch { diff --git a/client/src/styles.css b/client/src/styles.css index e135100..e6aeb3e 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -463,6 +463,158 @@ input { .search-row { grid-template-columns: 1fr; } } +/* --- SP Investment Vault ----------------------------------------------- */ +.vault-form { display: grid; gap: 14px; margin: 16px 0 8px; } +.vault-plans { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.vault-card { + display: grid; + gap: 6px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfdff; + cursor: pointer; +} +.vault-card.selected { + border-color: var(--primary); + background: #edf8fb; + box-shadow: 0 0 0 1px var(--primary); +} +.vault-card > strong { font-size: 16px; color: var(--primary); } +.vault-card-meta { margin: 0; color: var(--text); font-weight: 700; font-size: 13px; } +.vault-card-desc { margin: 2px 0 0; color: var(--muted); font-size: 12px; line-height: 1.4; } +.vault-card-req { margin: 2px 0 0; color: var(--muted); font-size: 12px; } +.vault-card input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; } +.vault-amount-row { margin-top: 6px; } +.vault-active { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border: 1px solid var(--primary); + border-radius: 8px; + background: #edf8fb; + margin: 16px 0; +} +.vault-active .eyebrow { margin: 0 0 4px; } +.vault-active strong { display: block; font-size: 18px; color: var(--primary); } +.vault-active-plan { margin-bottom: 6px; } +.vault-active-details { display: grid; gap: 3px; margin-top: 4px; } +.vault-active-details span { color: var(--text); font-size: 14px; font-weight: 600; line-height: 1.5; } +.vault-status { + display: inline-block; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 850; + text-transform: uppercase; + letter-spacing: 0.04em; + font-style: normal; +} +.vault-status-active { background: var(--primary); color: #fff; } +.vault-status-completed { background: var(--green); color: #fff; } +.vault-status-failed { background: var(--red); color: #fff; } +.vault-status-cancelled { background: #94a3b8; color: #fff; } +.vault-success { + margin: 12px 0 0; + padding: 10px 14px; + border-radius: 8px; + background: #e9f7ee; + color: var(--green); + font-weight: 700; +} +.vault-history { margin-top: 20px; } +.vault-history > h3 { margin: 0 0 8px; } +.vault-history-card { padding: 16px; } +.vault-history-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + gap: 12px; +} +.vault-history-plan { font-size: 15px; color: var(--primary); letter-spacing: 0.02em; } +.vault-history-details { + display: grid; + gap: 6px; +} +.vault-history-details > div { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} +.vault-history-details > div > span { color: var(--muted); font-size: 13px; } +.vault-history-details > div > strong { font-size: 14px; } +.vault-summary { margin-top: 16px; } +.vault-summary > h3 { margin: 0 0 12px; font-size: 18px; } +.vault-summary-rows { + display: grid; + gap: 0; + margin-bottom: 12px; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: #fbfdff; +} +.vault-summary-rows > div { + display: grid; + grid-template-columns: minmax(180px, max-content) 1fr; + gap: 16px; + align-items: baseline; + padding: 10px 14px; + border-bottom: 1px solid var(--line); +} +.vault-summary-rows > div:last-child { border-bottom: none; } +.vault-summary-rows > div > span { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 800; +} +.vault-summary-rows > div > strong { + text-align: right; + color: var(--text); + font-size: 15px; +} +.vault-confirm-modal { width: min(560px, 100%); } +.vault-confirm-amount { text-align: center; margin: 4px 0 18px; } +.vault-confirm-amount strong { + display: block; + font-size: 38px; + color: var(--primary); + font-weight: 850; +} +.vault-confirm-notice { + background: #fff8e6; + border: 1px solid #f3d785; + border-radius: 8px; + padding: 12px 14px; + margin: 16px 0; +} +.vault-confirm-notice p { + margin: 0; + color: #8a5a00; + font-size: 13px; + line-height: 1.5; +} +.vault-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 4px; +} +.vault-note { margin: 18px 0 0; font-size: 12px; line-height: 1.5; } +@media (max-width: 720px) { + .vault-plans { grid-template-columns: 1fr; } +} + /* --- Mandatory survey pop-up ------------------------------------------- */ .survey-overlay { position: fixed; diff --git a/server/config.js b/server/config.js index 3cd11ab..8a0290f 100644 --- a/server/config.js +++ b/server/config.js @@ -63,3 +63,15 @@ export const SESSION_THRESHOLDS_MINUTES = { }; export const SESSION_THRESHOLDS_PCT = 0.75; // default % of session duration to qualify + +// --- SP Investment Vault -------------------------------------------------- +// Configurable plans. Adding a new plan = add a key here, no code changes. +// attendanceRequirement is a fraction (1.0 = 100% of sessions in the window +// must be attended). durationDays and bonusRate are captured at start time so +// changing a plan mid-flight doesn't affect in-progress investments. +export const INVESTMENT_PLANS = { + safe: { label: 'Safe', durationDays: 7, bonusRate: 0.05, attendanceRequirement: 1.0 }, + growth: { label: 'Growth', durationDays: 15, bonusRate: 0.15, attendanceRequirement: 1.0 }, + diamond:{ label: 'Diamond', durationDays: 30, bonusRate: 0.30, attendanceRequirement: 1.0 } +}; +export const INVESTMENT_MIN_PRINCIPAL = 10; diff --git a/server/models/InvestmentRecord.js b/server/models/InvestmentRecord.js new file mode 100644 index 0000000..7f65a8b --- /dev/null +++ b/server/models/InvestmentRecord.js @@ -0,0 +1,28 @@ +import mongoose from 'mongoose'; + +const investmentRecordSchema = new mongoose.Schema({ + email: { type: String, required: true, lowercase: true, trim: true, index: true }, + studentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Student', index: true }, + planKey: { type: String, required: true }, + principal: { type: Number, required: true }, + bonusRate: { type: Number, required: true }, + durationDays: { type: Number, required: true }, + attendanceRequirement: { type: Number, required: true }, + startDate: { type: Date, required: true, index: true }, + endDate: { type: Date, required: true, index: true }, + status: { + type: String, + enum: ['active', 'completed', 'failed', 'cancelled'], + default: 'active', + index: true + }, + resolvedAt: { type: Date, default: null }, + attendedSessions: { type: Number, default: null }, + requiredSessions: { type: Number, default: null }, + transactionIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'SPTransaction' }] +}, { timestamps: true }); + +investmentRecordSchema.index({ email: 1, status: 1 }); +investmentRecordSchema.index({ endDate: 1, status: 1 }); + +export default mongoose.model('InvestmentRecord', investmentRecordSchema); diff --git a/server/server.js b/server/server.js index 11f6e4f..1c8ceb6 100644 --- a/server/server.js +++ b/server/server.js @@ -3,6 +3,7 @@ import cors from 'cors'; import mongoose from 'mongoose'; import path from 'path'; import fs from 'fs'; +import crypto from 'crypto'; import { fileURLToPath } from 'url'; import { ALLOW_STUDENT_SEARCH, MONGO_URI, PORT, SAMAGAMA_AUTH_URL } from './config.js'; @@ -13,6 +14,14 @@ import PollRecord from './models/PollRecord.js'; import SPTransaction from './models/SPTransaction.js'; import SessionEvent from './models/SessionEvent.js'; import { leagueBand, levelFor, legendBadge, leaderboardGroup, groupLabel } from './services/levels.js'; +import { + getInvestmentPlans, + startInvestment, + resolveDueInvestmentsForStudent, + resolveAllDueInvestments, + getStudentInvestments, + getAllInvestments +} from './services/investmentService.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); @@ -153,6 +162,91 @@ async function studentEmailFromRequest(req) { return normalizeEmail(email); } +// --- Search-session cookie (local-dev + admin fallback) ------------------- +// Issued by /api/confirm on successful email-match verification. The cookie +// is HMAC-signed with SPURTI_AUTH_SECRET, httpOnly, and short-lived (8h). +// It is NOT a primary auth path — primary is still Samagama. It exists only +// so the search-confirm flow can support write endpoints (e.g. Vault) in +// deployments where Samagama is unavailable. In production +// (ALLOW_STUDENT_SEARCH=false) this cookie is never issued, so no student can +// reach Vault through this path; they still go through Samagama. +const SEARCH_SESSION_COOKIE = 'spurti_search_session'; +const SEARCH_SESSION_MAX_AGE_MS = 8 * 60 * 60 * 1000; + +function getSearchSessionSecret() { + return process.env.SPURTI_AUTH_SECRET || ''; +} + +function makeSearchSessionToken(email, expiresAt) { + const secret = getSearchSessionSecret(); + if (!secret) return null; // refuse to sign without a configured secret + const payload = `${normalizeEmail(email)}|${expiresAt}`; + const sig = crypto.createHmac('sha256', secret).update(payload).digest('hex'); + return Buffer.from(payload).toString('base64url') + '.' + sig; +} + +function verifySearchSessionToken(token) { + if (!token) return null; + const secret = getSearchSessionSecret(); + if (!secret) return null; + const dotIndex = token.indexOf('.'); + if (dotIndex < 0) return null; + const encoded = token.slice(0, dotIndex); + const sig = token.slice(dotIndex + 1); + let payload; + try { + payload = Buffer.from(encoded, 'base64url').toString('utf8'); + } catch { + return null; + } + const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex'); + // Constant-time signature comparison + if (sig.length !== expected.length) return null; + let mismatch = 0; + for (let i = 0; i < sig.length; i++) { + mismatch |= sig.charCodeAt(i) ^ expected.charCodeAt(i); + } + if (mismatch !== 0) return null; + const [email, expiresAtStr] = payload.split('|'); + const expiresAt = Number(expiresAtStr); + if (!Number.isFinite(expiresAt) || Date.now() > expiresAt) return null; + return normalizeEmail(email); +} + +function setSearchSessionCookie(res, email) { + const token = makeSearchSessionToken(email, Date.now() + SEARCH_SESSION_MAX_AGE_MS); + if (!token) return; // no SPURTI_AUTH_SECRET configured — skip silently + const secure = String(process.env.SPURTI_COOKIE_SECURE || '').toLowerCase() === 'true'; + res.cookie(SEARCH_SESSION_COOKIE, token, { + httpOnly: true, + secure, + sameSite: 'lax', + path: '/', + maxAge: SEARCH_SESSION_MAX_AGE_MS + }); +} + +function clearSearchSessionCookie(res) { + const secure = String(process.env.SPURTI_COOKIE_SECURE || '').toLowerCase() === 'true'; + res.cookie(SEARCH_SESSION_COOKIE, '', { + httpOnly: true, + secure, + sameSite: 'lax', + path: '/', + maxAge: 0 + }); +} + +// Auth resolution for write endpoints: tries Samagama first, then the signed +// search-session cookie. Production paths still go through Samagama (the cookie +// is never issued when ALLOW_STUDENT_SEARCH=false). +async function resolveStudentEmail(req) { + const fromSamagama = await studentEmailFromRequest(req); + if (fromSamagama) return fromSamagama; + const cookies = parseCookies(req.headers.cookie || ''); + return verifySearchSessionToken(cookies[SEARCH_SESSION_COOKIE]); +} + async function rankFor(email) { const student = await Student.findOne({ email }).lean(); if (!student || student.status === 'excused') return null; @@ -278,8 +372,8 @@ api.get('/search', async (req, res) => { if (q.includes('@')) { const email = normalizeEmail(q); const student = await Student.findOne({ $or: [{ email }, { alternateEmail: email }] }).lean(); - if (student?.status === 'excused') return res.json(excusedPayload(student)); - if (student) return res.json({ exact: true, profile: await studentPayload(student) }); + if (student?.status === 'excused') { setSearchSessionCookie(res, student.email); return res.json(excusedPayload(student)); } + if (student) { setSearchSessionCookie(res, student.email); return res.json({ exact: true, profile: await studentPayload(student) }); } } const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -303,6 +397,7 @@ api.post('/confirm', async (req, res) => { if (typed !== normalizeEmail(student.email) && typed !== normalizeEmail(student.alternateEmail)) { return res.status(403).json({ error: 'Email did not match this record' }); } + setSearchSessionCookie(res, student.email); if (student.status === 'excused') return res.json(excusedPayload(student)); res.json(await studentPayload(student)); }); @@ -613,6 +708,52 @@ api.get('/admin/analytics', adminGuard, async (_req, res) => { }); }); +// --- SP Investment Vault -------------------------------------------------- +// Public — list available plans. +api.get('/investments/plans', (_req, res) => { + res.json({ plans: getInvestmentPlans() }); +}); + +// Session-authenticated — start an investment. +api.post('/investments', async (req, res) => { + const email = await resolveStudentEmail(req); + if (!email) return res.status(401).json({ error: 'Not authenticated' }); + const { planKey, principal } = req.body || {}; + const result = await startInvestment(email, planKey, principal); + if (result.error) { + const status = result.error === 'INVALID_PLAN' || result.error === 'BELOW_MIN_PRINCIPAL' ? 400 : 409; + return res.status(status).json(result); + } + res.json(result); +}); + +// Session-authenticated — list the caller's investments. Resolves any due +// investments first so the student always sees the current state. +api.get('/investments/mine', async (req, res) => { + const email = await resolveStudentEmail(req); + if (!email) return res.status(401).json({ error: 'Not authenticated' }); + await resolveDueInvestmentsForStudent(email); + const investments = await getStudentInvestments(email); + res.json({ investments }); +}); + +// Session-authenticated — explicit resolve trigger (idempotent). +api.post('/investments/resolve', async (req, res) => { + const email = await resolveStudentEmail(req); + if (!email) return res.status(401).json({ error: 'Not authenticated' }); + const resolved = await resolveDueInvestmentsForStudent(email); + res.json({ resolved }); +}); + +// Admin — list all investments AND resolve any due investments so the admin +// never sees stale `active` records past their `endDate`. Closes the +// admin-view staleness gap: resolution is triggered by read. +api.get('/admin/investments', adminGuard, async (_req, res) => { + await resolveAllDueInvestments(); + const investments = await getAllInvestments(); + res.json(investments); +}); + function last24Hours(now) { return new Date(now.getTime() - 24 * 60 * 60 * 1000); } diff --git a/server/services/investmentService.js b/server/services/investmentService.js new file mode 100644 index 0000000..d48c2da --- /dev/null +++ b/server/services/investmentService.js @@ -0,0 +1,310 @@ +/** + * SP Investment Vault Service + * + * Concurrency model: + * - startInvestment: atomic conditional debit on Student (filter includes + * `totalSp: { $gte: principal }`), then create record, then create + * SPTransaction. Each step has a compensating refund if the next fails. + * - resolveInvestment: atomic status flip `active -> completed|failed`, + * then atomic credit on Student. Idempotent — concurrent calls are safe. + * + * Logging discipline (grep-able tags, no silent failures): + * - investment_refund_failed_after_investment_create + * - investment_refund_failed_after_transaction_create + * - investment_credit_failed_after_resolution + * - investment_status_flip_failed + * - investment_resolution_failed + */ + +import Student from '../models/Student.js'; +import SPTransaction from '../models/SPTransaction.js'; +import Session from '../models/Session.js'; +import AttendanceRecord from '../models/AttendanceRecord.js'; +import InvestmentRecord from '../models/InvestmentRecord.js'; +import { INVESTMENT_PLANS, INVESTMENT_MIN_PRINCIPAL } from '../config.js'; + +function normalizeEmail(value) { + return String(value || '').trim().toLowerCase(); +} + +function publicInvestment(record) { + const bonus = record.status === 'completed' + ? Math.round(record.principal * record.bonusRate) + : 0; + return { + _id: String(record._id), + planKey: record.planKey, + principal: record.principal, + bonusRate: record.bonusRate, + durationDays: record.durationDays, + startDate: record.startDate, + endDate: record.endDate, + status: record.status, + resolvedAt: record.resolvedAt, + attendedSessions: record.attendedSessions, + requiredSessions: record.requiredSessions, + bonus, + totalReturn: record.status === 'completed' ? record.principal + bonus : 0 + }; +} + +export function getInvestmentPlans() { + return Object.entries(INVESTMENT_PLANS).map(([key, plan]) => ({ + key, + label: plan.label, + durationDays: plan.durationDays, + bonusRate: plan.bonusRate, + attendanceRequirement: plan.attendanceRequirement, + minPrincipal: INVESTMENT_MIN_PRINCIPAL + })); +} + +export async function startInvestment(emailRaw, planKey, principal) { + const email = normalizeEmail(emailRaw); + if (!email) return { error: 'INVALID_EMAIL' }; + const plan = INVESTMENT_PLANS[planKey]; + if (!plan) return { error: 'INVALID_PLAN' }; + const p = Number(principal); + if (!Number.isFinite(p) || p < INVESTMENT_MIN_PRINCIPAL) { + return { error: 'BELOW_MIN_PRINCIPAL', minPrincipal: INVESTMENT_MIN_PRINCIPAL }; + } + + // Max 1 concurrent investment per student. + const existing = await InvestmentRecord.findOne({ email, status: 'active' }); + if (existing) return { error: 'ALREADY_ACTIVE', investment: publicInvestment(existing) }; + + // Step 1: atomic conditional debit. The `totalSp: { $gte: p }` filter is + // evaluated atomically by Mongo — no double-spend on concurrent requests. + const updated = await Student.findOneAndUpdate( + { email, status: { $ne: 'excused' }, totalSp: { $gte: p } }, + { $inc: { totalSp: -p } }, + { new: true } + ); + if (!updated) return { error: 'INSUFFICIENT_BALANCE_OR_INACTIVE' }; + + const now = new Date(); + const endDate = new Date(now.getTime() + plan.durationDays * 24 * 60 * 60 * 1000); + + // Step 2: create the InvestmentRecord. On failure → refund the debit. + let record; + try { + record = await InvestmentRecord.create({ + email, + studentId: updated._id, + planKey, + principal: p, + bonusRate: plan.bonusRate, + durationDays: plan.durationDays, + attendanceRequirement: plan.attendanceRequirement, + startDate: now, + endDate, + status: 'active' + }); + } catch (err) { + try { + await Student.updateOne({ email }, { $inc: { totalSp: p } }); + } catch (refundErr) { + console.error('investment_refund_failed_after_investment_create', { + email, principal: p, + error: err.message, + refundError: refundErr.message + }); + } + return { error: 'INVESTMENT_RECORD_FAILED' }; + } + + // Step 3: create the debit SPTransaction. On failure → refund + delete record. + let txn; + try { + txn = await SPTransaction.create({ + email, + studentId: updated._id, + category: 'manual', + sessionLabel: `investment:${planKey}`, + deltaMode: 'absolute', + deltaValue: -p, + appliedDelta: -p, + balanceAfter: Number(updated.totalSp || 0), + reason: `Locked ${p} SP into ${plan.label} vault (${plan.durationDays} days, ${Math.round(plan.bonusRate * 100)}% bonus).`, + dateTime: now + }); + } catch (err) { + try { + await Student.updateOne({ email }, { $inc: { totalSp: p } }); + await InvestmentRecord.deleteOne({ _id: record._id }); + } catch (refundErr) { + console.error('investment_refund_failed_after_transaction_create', { + email, principal: p, investmentId: String(record._id), + error: err.message, + refundError: refundErr.message + }); + } + return { error: 'TRANSACTION_CREATE_FAILED' }; + } + + await InvestmentRecord.updateOne({ _id: record._id }, { $push: { transactionIds: txn._id } }); + + return { + investment: publicInvestment(record), + newBalance: Number(updated.totalSp), + transaction: { _id: String(txn._id), balanceAfter: txn.balanceAfter } + }; +} + +async function evaluateAttendance(record) { + const sessions = await Session.find({ + endDateTime: { $gte: record.startDate, $lte: record.endDate } + }).lean(); + const requiredSessions = sessions.length; + let attendedSessions = 0; + if (requiredSessions > 0) { + attendedSessions = await AttendanceRecord.countDocuments({ + email: record.email, + qualified: true, + sessionLabel: { $in: sessions.map(s => s.label) } + }); + } + // No sessions in window = trivially satisfied (caller still gets original back). + const ratio = requiredSessions > 0 ? attendedSessions / requiredSessions : 1; + const success = ratio >= record.attendanceRequirement; + return { success, attendedSessions, requiredSessions }; +} + +export async function resolveInvestment(record) { + const plan = INVESTMENT_PLANS[record.planKey]; + if (!plan) return null; + + const { success, attendedSessions, requiredSessions } = await evaluateAttendance(record); + const newStatus = success ? 'completed' : 'failed'; + + // Atomic status flip — guards against concurrent resolution. + let flipped; + try { + flipped = await InvestmentRecord.findOneAndUpdate( + { _id: record._id, status: 'active' }, + { $set: { status: newStatus, resolvedAt: new Date(), attendedSessions, requiredSessions } }, + { new: true } + ); + } catch (err) { + console.error('investment_status_flip_failed', { + investmentId: String(record._id), email: record.email, + error: err.message + }); + return null; + } + if (!flipped) return null; // already resolved by another call + + if (!success) { + return { investment: publicInvestment(flipped), credit: 0 }; + } + + const bonus = Math.round(record.principal * record.bonusRate); + const totalCredit = record.principal + bonus; + + const updated = await Student.findOneAndUpdate( + { email: record.email }, + { $inc: { totalSp: totalCredit } }, + { new: true } + ); + if (!updated) { + console.error('investment_credit_failed_after_resolution', { + email: record.email, principal: record.principal, bonus, + investmentId: String(record._id), + error: 'Student update returned null' + }); + return { investment: publicInvestment(flipped), credit: 0, creditFailed: true }; + } + + let txn; + try { + txn = await SPTransaction.create({ + email: record.email, + studentId: record.studentId, + category: 'manual', + sessionLabel: `investment:${record.planKey}`, + deltaMode: 'absolute', + deltaValue: totalCredit, + appliedDelta: totalCredit, + balanceAfter: Number(updated.totalSp), + reason: `Vault matured: ${plan.label} returned ${record.principal} SP + ${bonus} SP bonus (${attendedSessions}/${requiredSessions} sessions attended).`, + dateTime: new Date() + }); + } catch (err) { + console.error('investment_credit_failed_after_resolution', { + email: record.email, principal: record.principal, bonus, + investmentId: String(record._id), + error: err.message + }); + return { investment: publicInvestment(flipped), credit: totalCredit, creditFailed: true }; + } + + await InvestmentRecord.updateOne({ _id: record._id }, { $push: { transactionIds: txn._id } }); + + return { investment: publicInvestment(flipped), credit: totalCredit, newBalance: Number(updated.totalSp) }; +} + +export async function resolveDueInvestmentsForStudent(email) { + const normalized = normalizeEmail(email); + if (!normalized) return []; + const due = await InvestmentRecord.find({ + email: normalized, status: 'active', endDate: { $lte: new Date() } + }); + const results = []; + for (const record of due) { + const result = await resolveInvestment(record); + if (result) results.push(result); + } + return results; +} + +export async function resolveAllDueInvestments() { + const due = await InvestmentRecord.find({ + status: 'active', endDate: { $lte: new Date() } + }); + const results = []; + for (const record of due) { + try { + const result = await resolveInvestment(record); + if (result) results.push(result); + } catch (err) { + console.error('investment_resolution_failed', { + investmentId: String(record._id), email: record.email, + error: err.message + }); + } + } + return results; +} + +export async function getStudentInvestments(email) { + const normalized = normalizeEmail(email); + if (!normalized) return []; + const records = await InvestmentRecord.find({ email: normalized }) + .sort({ createdAt: -1 }) + .lean(); + return records.map(publicInvestment); +} + +export async function getAllInvestments() { + const records = await InvestmentRecord.find({}) + .sort({ createdAt: -1 }) + .limit(500) + .lean(); + return records.map(r => ({ + _id: String(r._id), + email: r.email, + planKey: r.planKey, + principal: r.principal, + bonusRate: r.bonusRate, + durationDays: r.durationDays, + startDate: r.startDate, + endDate: r.endDate, + status: r.status, + resolvedAt: r.resolvedAt, + attendedSessions: r.attendedSessions, + requiredSessions: r.requiredSessions, + totalReturn: r.status === 'completed' + ? r.principal + Math.round(r.principal * r.bonusRate) + : 0 + })); +} \ No newline at end of file From ef32bf08a0f2dd860b2d1c8fbac47a9e97b6a748 Mon Sep 17 00:00:00 2001 From: rishisaini1403-ship-it Date: Fri, 31 Jul 2026 02:17:27 +0530 Subject: [PATCH 2/4] chore: trigger PR refresh --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d24b5ff..786550b 100644 --- a/README.md +++ b/README.md @@ -149,3 +149,4 @@ This direction can be evaluated through: This is a general educational motivation engine. It is not only for internships, and it is not only a points table. It is a self-regulated learning support system and research direction that helps students see their progress, stay encouraged, recover from setbacks, and complete any meaningful learning journey. Displaying PRODUCT.md. + From bf2ff8223a727988fa399991b9a3448b5a53334d Mon Sep 17 00:00:00 2001 From: rishisaini1403-ship-it Date: Fri, 31 Jul 2026 13:57:48 +0530 Subject: [PATCH 3/4] Restore SP Investment Vault after upstream merge --- client/src/main.jsx | 541 +++++++++++++++++++++++++----------------- client/src/styles.css | 176 +------------- server/server.js | 130 ++-------- 3 files changed, 342 insertions(+), 505 deletions(-) diff --git a/client/src/main.jsx b/client/src/main.jsx index 667eb37..0d3cfd7 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -85,13 +85,6 @@ function App() { completedKey="poll2Completed" onDone={() => setProfile(prev => ({ ...prev, student: { ...prev.student, poll2Completed: true } }))} /> - setProfile(prev => ({ ...prev, student: { ...prev.student, poll3Completed: true } }))} - /> ); } @@ -273,9 +266,9 @@ function SearchModal({ onClose, onStudent }) { function StudentView({ profile, onBack }) { const [tab, setTab] = useState('bank'); - const [commitPhase, setCommitPhase] = useState('vibe'); const { student } = profile; - const goToCommitment = ph => { setCommitPhase(ph); setTab('vibe'); }; + const badges = useMemo(() => buildBadges(profile), [profile]); + const nextActions = useMemo(() => buildNextActions(profile), [profile]); return (
@@ -288,99 +281,15 @@ function StudentView({ profile, onBack }) {
- + {tab === 'bank' && } - {tab === 'journey' && } - {tab === 'vibe' && student.eligibleForVibeGoals && } - {tab === 'spa' && } + {tab === 'polls' && } {tab === 'leaderboard' && } {tab === 'vault' && }
); } -// SPA → SP (display only). SP is scored + credited by the pipeline rubric -// (+5 per validated question learned, +8 per validated peer taught, capped 50/30, -// minus a one-time audit/fraud penalty) and lands in the SP Bank automatically. -// This tab just reads the rubric's `spaprogresses` summary. Universal across cohorts. -function SpaModule({ student }) { - const email = student.email; - const [data, setData] = useState(null); - - useEffect(() => { - (async () => { - const r = await fetch(`${API}/spa/state?email=${encodeURIComponent(email)}`); - setData(await r.json()); - })(); - }, [email]); - - if (!data) return
Loading your SPA points…
; - if (!data.hasActivity) return ( -
-

SPA — Peer Teaching Points

-

No validated SPA endorsements on record yet for {data.activity}. Learn a question and get endorsed, or endorse a peer — SP lands in your SP Bank automatically as each is validated.

-
- ); - - const { learn, teach, penalty, creditedSp, maxSp, config } = data; - - return ( -
-
-

SPA — Peer Teaching Points

-

For {data.activity}, SP is credited to your SP Bank automatically as each endorsement is validated — +{config.learnUnit} SP per question you learn, +{config.teachUnit} SP per peer you teach. No claiming needed.

-
- -
- {/* Track A — Learning */} -
-
A

Learning

+{learn.sp} SP
-

Questions you were validly endorsed on

-
-
{learn.validated}validated
-
{learn.credited}credited
-
×{learn.unit}SP each
-
- {learn.validated > learn.cap &&
Capped at {learn.cap} — extra {learn.validated - learn.cap} not counted
} -
- - {/* Track B — Teaching */} -
-
B

Teaching

+{teach.sp} SP
-

Peers you validly endorsed

-
-
{teach.validated}validated
-
{teach.credited}credited
-
×{teach.unit}SP each
-
- {teach.validated > teach.cap &&
Capped at {teach.cap} — extra {teach.validated - teach.cap} not counted
} -
-
- -
-

SPA SP summary

- - - - - - {penalty.done && penalty.applied > 0 && ( - - - - - )} - -
Learning (Track A) — {learn.credited} × {config.learnUnit}+{learn.sp} SP
Teaching (Track B) — {teach.credited} × {config.teachUnit}+{teach.sp} SP
Total credited to SP Bank (max {maxSp})+{creditedSp} SP
{penalty.fraud ? '⚠️ Fraud penalty' : '⚠️ Audit-failure penalty'} — −{Math.round(penalty.rate * 100)}% of current SP{penalty.at ? ` on ${new Date(penalty.at).toLocaleDateString()}` : ''}−{penalty.applied} SP
-

- ✅ Auto-credited to your SP Bank — current balance {data.totalSp} SP. - {penalty.done && penalty.applied > 0 ? ' An integrity penalty was applied (see the debit row in your SP Bank).' : ''} -

-
-
- ); -} - function LevelStatus({ student }) { const tier = String(student.trophyLeague || 'Bronze').split(' ')[0].toLowerCase(); return ( @@ -441,98 +350,49 @@ function LeaderboardTabs({ overall = [], group = [], groupLabel }) { ); } -// SP trajectory modal — the student's weekly cumulative SP vs cohort + onboarding-group -// means (reference lines cached in TrajectorySnapshot; own line built live from the ledger). -function TrajectoryModal({ student, onClose }) { - const [data, setData] = useState(null); - useEffect(() => { - fetch(`${API}/trajectory/state?email=${encodeURIComponent(student.email)}`).then(r => r.json()).then(setData); - }, [student.email]); - - const series = data ? [ - { key: 'you', label: 'You', color: 'var(--primary)', points: data.you, width: 3, dots: true }, - { key: 'cohort', label: 'Cohort average', color: '#94a3b8', points: data.cohort, width: 2, dash: '5 4' }, - { key: 'group', label: data.groupLabel ? `Your group (${data.groupLabel})` : 'Your group', color: '#8b5cf6', points: data.group, width: 2 } - ].filter(s => s.points && s.points.length) : []; - - const weeks = data?.weeks || 10; - const yMax = Math.max(10, ...series.flatMap(s => s.points.map(p => p.sp))); - const W = 760, H = 400, padL = 52, padR = 18, padT = 18, padB = 42; - const plotW = W - padL - padR, plotH = H - padT - padB; - const sx = wk => padL + (weeks <= 1 ? 0 : (wk - 1) / (weeks - 1) * plotW); - const sy = sp => padT + (1 - sp / yMax) * plotH; - const yTicks = [0, 0.25, 0.5, 0.75, 1].map(f => Math.round(yMax * f)); - const xTicks = Array.from({ length: weeks }, (_, i) => i + 1); - +function StudentPulse({ profile, badges, nextActions }) { + const { student, cohort, attendance, polls, transactions } = profile; + const qualified = attendance.filter(a => a.qualified).length; + const pollAttempted = polls.reduce((sum, p) => sum + p.attemptedQuestions, 0); + const pollTotal = polls.reduce((sum, p) => sum + p.totalQuestions, 0); + const trend = transactions.map(tx => ({ label: tx.sessionLabel || 'Start', value: tx.balanceAfter })); return ( -
-
e.stopPropagation()}> -
-
-

Your trajectory

-

SP over your internship — you vs cohort

-
- +
+
+ Standing + Rank {student.rank} +

{cohort.pointsToTop50 === 0 ? 'You are in the Top 50.' : `${cohort.pointsToTop50} SP needed to enter Top 50.`}

+

{cohort.pointsToNextRank === 0 ? 'You are leading your comparison group.' : `${cohort.pointsToNextRank} SP needed for next rank.`}

+
+
+ Cohort comparison +
+ Your SP: {student.totalSp} + Cohort avg: {cohort.averageSp} + Top 50 cutoff: {cohort.top50Cutoff ?? '-'} + Top 10 cutoff: {cohort.top10Cutoff ?? '-'}
- {!data ?

Loading…

: series.length === 0 ? ( -

Not enough data yet — check back after your first week.

- ) : ( - <> -
- {series.map(s => {s.label})} -
-
- - {yTicks.map(v => ( - - - {v} - - ))} - {xTicks.map(w => W{w})} - Weeks since you joined - {series.map(s => ( - - `${sx(p.week)},${sy(p.sp)}`).join(' ')} - fill="none" stroke={s.color} strokeWidth={s.width} strokeDasharray={s.dash || ''} - strokeLinejoin="round" strokeLinecap="round" /> - {s.dots && s.points.map(p => )} - - ))} - -
-

Cumulative SP, aligned to each student's own join week so everyone is compared fairly regardless of start date.{data.computedAt ? ` Cohort lines updated ${new Date(data.computedAt).toLocaleDateString()}.` : ''}

- - )}
-
- ); -} - -function StudentPulse({ profile }) { - const { student, cohort, transactions } = profile; - const [showTraj, setShowTraj] = useState(false); - const trend = transactions.map(tx => ({ label: tx.sessionLabel || 'Start', value: tx.balanceAfter })); - return ( - <> -
-
- Standing - Rank {student.rank} -

{cohort.pointsToTop50 === 0 ? 'You are in the Top 50.' : `${cohort.pointsToTop50} SP to enter Top 50.`}

-
- Cohort avg: {cohort.averageSp} - Top 50: {cohort.top50Cutoff ?? '—'} - Top 10: {cohort.top10Cutoff ?? '—'} -
+
+ Session health +
+ {qualified}/{attendance.length} attendance qualified + {pollAttempted}/{pollTotal} polls attempted
- -
- {showTraj && setShowTraj(false)} />} - +
+
+ Badges +
{badges.map(badge => {badge})}
+
+
+ SP trend + +
+
+ What to do next +
    {nextActions.map(action =>
  • {action}
  • )}
+
+ ); } @@ -550,45 +410,38 @@ function Sparkline({ points }) { ); } +function buildBadges(profile) { + const badges = []; + const qualifiedPct = profile.attendance.length ? profile.attendance.filter(a => a.qualified).length / profile.attendance.length : 0; + const pollAttempted = profile.polls.reduce((sum, p) => sum + p.attemptedQuestions, 0); + const pollTotal = profile.polls.reduce((sum, p) => sum + p.totalQuestions, 0); + if (profile.student.rank <= 50) badges.push('Top 50'); + if (qualifiedPct >= 0.75) badges.push('Consistent Attendee'); + if (pollTotal && pollAttempted / pollTotal >= 0.75) badges.push('Poll Champion'); + if (profile.student.totalSp >= profile.cohort.averageSp) badges.push('Above Average'); + return badges.length ? badges : ['Getting Started']; +} + +function buildNextActions(profile) { + const actions = []; + if (profile.cohort.pointsToTop50 > 0) actions.push(`Earn ${profile.cohort.pointsToTop50} more SP to enter Top 50.`); + if (profile.attendance.some(a => !a.qualified)) actions.push('Attend at least 75% of upcoming sessions to avoid attendance debit.'); + if (profile.polls.some(p => p.missedQuestions > 0)) actions.push('Attempt every poll question to avoid poll debit.'); + actions.push('Check your SP Bank after each session to understand every credit and debit.'); + return actions.slice(0, 4); +} + function Tabs({ tab, setTab, tabs }) { return ; } function SpBank({ transactions }) { - const [size, setSize] = useState(10); - // Server sends oldest→newest (sorted dateTime asc); show newest first. - const rows = useMemo(() => [...transactions].reverse(), [transactions]); - const shown = rows.slice(0, size); - const downloadCsv = () => { - const esc = v => `"${String(v ?? '').replace(/"/g, '""')}"`; - const lines = [['Date & time', 'Credit', 'Debit', 'Balance', 'Reason'].join(',')].concat( - rows.map(tx => [ - new Date(tx.dateTime).toLocaleString(), - tx.appliedDelta > 0 ? tx.appliedDelta : '', - tx.appliedDelta < 0 ? tx.appliedDelta : '', - tx.balanceAfter, tx.reason - ].map(esc).join(','))); - const url = URL.createObjectURL(new Blob([lines.join('\n')], { type: 'text/csv' })); - const a = document.createElement('a'); - a.href = url; a.download = 'sp-bank-statement.csv'; a.click(); - URL.revokeObjectURL(url); - }; return (
-
-

SP Bank

-
- - -
-
+

SP Bank Statement

Date & timeCreditDebitBalanceReason
- {shown.map(tx => ( + {transactions.map(tx => (
{new Date(tx.dateTime).toLocaleString()} {tx.appliedDelta > 0 ? `+${tx.appliedDelta}` : ''} @@ -598,7 +451,6 @@ function SpBank({ transactions }) {
))}
-

Showing {Math.min(size, rows.length)} of {rows.length} — download CSV for the full statement.

); } @@ -656,6 +508,267 @@ function Leaderboard({ rows }) { ); } +function formatDate(date) { + return new Date(date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); +} + +const PLAN_DESCRIPTIONS = { + safe: 'Recommended for short-term commitment.', + growth: 'Balanced reward and commitment.', + diamond: 'Highest reward for committed students.' +}; + +function Vault({ student }) { + const [plans, setPlans] = useState([]); + const [investments, setInvestments] = useState([]); + const [loading, setLoading] = useState(true); + const [planKey, setPlanKey] = useState(''); + const [principal, setPrincipal] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [confirming, setConfirming] = useState(false); + + const loadData = async () => { + try { + const [plansRes, mineRes] = await Promise.all([ + fetch(`${API}/investments/plans`, { credentials: 'include' }), + fetch(`${API}/investments/mine`, { credentials: 'include' }) + ]); + if (plansRes.ok) { + const data = await plansRes.json(); + setPlans(data.plans || []); + if (!planKey && data.plans?.length) setPlanKey(data.plans[0].key); + } + if (mineRes.ok) { + const data = await mineRes.json(); + setInvestments(data.investments || []); + } + } catch { + setError('Failed to load vault data.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadData(); }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const actuallySubmit = async () => { + setError(''); + setSuccess(''); + setSubmitting(true); + setConfirming(false); + try { + const res = await fetch(`${API}/investments`, { + credentials: 'include', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ planKey, principal: Number(principal) }) + }); + const data = await res.json(); + if (!res.ok) { + const message = ({ + INSUFFICIENT_BALANCE_OR_INACTIVE: 'Insufficient SP balance.', + ALREADY_ACTIVE: 'You already have an active investment.', + INVALID_PLAN: 'Please select an investment plan.', + BELOW_MIN_PRINCIPAL: `Minimum investment is ${minPrincipal} SP.` + })[data.error] || data.error || 'Failed to create investment. Please try again.'; + setError(message); + return; + } + setSuccess(`Investment created successfully. ${data.investment.principal} SP locked in the ${planKey} vault.`); + setPrincipal(''); + await loadData(); + } catch { + setError('Network error — please try again.'); + } finally { + setSubmitting(false); + } + }; + + const onInvestClick = (e) => { + if (e && e.preventDefault) e.preventDefault(); + if (submitting) return; + if (!principalValid || !selectedPlan) return; + setConfirming(true); + }; + + if (loading) return
Loading vault…
; + + const active = investments.find(i => i.status === 'active'); + const history = investments.filter(i => i.status !== 'active'); + const selectedPlan = plans.find(p => p.key === planKey); + const minPrincipal = selectedPlan?.minPrincipal ?? plans[0]?.minPrincipal ?? 10; + const principalNum = Number(principal); + const principalValid = Number.isFinite(principalNum) && principalNum >= minPrincipal && principalNum <= student.totalSp; + const expectedProfit = (selectedPlan && principalValid) + ? Math.round(principalNum * selectedPlan.bonusRate) + : 0; + const expectedReturn = principalValid ? principalNum + expectedProfit : 0; + + return ( +
+

SP Investment Vault

+

Lock your Spurti Points to earn a bonus — but only if you attend every session during the lock period. If attendance slips, the invested SP is forfeited.

+ + {active && ( +
+
+ Active Investment + {active.planKey.charAt(0).toUpperCase() + active.planKey.slice(1)} Plan +
+ {active.principal} SP locked + Bonus: +{Math.round(active.bonusRate * 100)}% + Expected Return: {Math.round(active.principal * active.bonusRate) + active.principal} SP + Matures: {formatDate(active.endDate)} +
+
+ active +
+ )} + + {!active && ( +
+
+ {plans.map(plan => ( + + ))} +
+
+ setPrincipal(e.target.value)} + placeholder={`Amount in SP (min ${minPrincipal}, you have ${student.totalSp})`} + /> + +
+
+ )} + + {!active && selectedPlan && principalValid && ( +
+

SP Investment Calculator

+
+
Selected Plan{selectedPlan.label}
+
Investment Amount{principalNum} SP
+
Duration{selectedPlan.durationDays} Days
+
Attendance Requirement{Math.round(selectedPlan.attendanceRequirement * 100)}%
+
Bonus Percentage{Math.round(selectedPlan.bonusRate * 100)}%
+
Expected Profit+{expectedProfit} SP
+
Expected Return{expectedReturn} SP
+
+

+ If you successfully maintain the required attendance during the investment period, you will receive {expectedReturn} SP when your investment matures. +

+
+ )} + + {confirming && selectedPlan && ( + setConfirming(false)} + onConfirm={actuallySubmit} + /> + )} + + {error &&

{error}

} + {success &&

{success}

} + + {investments.length > 0 && ( +
+ {history.length > 0 &&

History

} + {history.map(inv => { + const planLabel = plans.find(p => p.key === inv.planKey)?.label || inv.planKey; + return ( +
+
+ {planLabel.toUpperCase()} PLAN + {inv.status.toUpperCase()} +
+
+
Invested: {inv.principal} SP
+ {inv.status === 'completed' && ( + <> +
Bonus Earned: +{inv.bonus} SP
+
Received: {inv.totalReturn} SP
+
Matured on: {formatDate(inv.endDate)}
+ + )} + {inv.status === 'failed' && ( +
Reason: Attendance requirement not met.
+ )} + {inv.status === 'cancelled' && ( +
Status: Cancelled
+ )} +
+
+ ); + })} + {active && history.length === 0 && ( +

No completed investments yet.

+ )} +
+ )} +

Note: Investments are automatically resolved when you visit the Vault after the maturity date. If you meet the attendance requirements, your invested SP and eligible bonus will be credited to your SP balance automatically.

+
+ ); +} + +function VaultConfirmModal({ plan, principal, expectedProfit, expectedReturn, submitting, onCancel, onConfirm }) { + return ( +
e.target === e.currentTarget && !submitting && onCancel()}> +
+
+

Confirm Your Investment

+ +
+

You are about to invest:

+

{principal} SP

+
+
Selected Plan{plan.label}
+
Duration{plan.durationDays} Days
+
Attendance Requirement{Math.round(plan.attendanceRequirement * 100)}%
+
Bonus Percentage{Math.round(plan.bonusRate * 100)}%
+
Expected Profit+{expectedProfit} SP
+
Expected Return{expectedReturn} SP
+
+
+

+ Important Notice: Your SP will remain locked until the investment matures. If you fail to maintain the required attendance during the investment period, your investment may fail and you may lose the invested SP. +

+
+
+ + +
+
+
+ ); +} + function AdminView({ admin, auth, onBack }) { const [tab, setTab] = useState('leaderboard'); const [leaderLimit, setLeaderLimit] = useState(50); diff --git a/client/src/styles.css b/client/src/styles.css index d0372ab..e6aeb3e 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -223,7 +223,7 @@ input { @media (max-width: 720px) { .level-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .pulse-grid { display: grid; - grid-template-columns: 1fr 2fr; + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; } @@ -250,45 +250,6 @@ input { } .pulse-card p { color: var(--muted); margin-bottom: 6px; } .wide-pulse { grid-column: span 2; } -.pulse-clickable { cursor: pointer; text-align: left; border: none; font: inherit; width: 100%; } -.pulse-clickable:hover { box-shadow: 0 0 0 2px var(--primary) inset; } -.expand-hint { color: var(--primary); font-style: normal; font-size: 11px; font-weight: 700; margin-left: 6px; } -.traj-modal { width: min(880px, 100%); } -.traj-legend { display: flex; flex-wrap: wrap; gap: 16px; margin: 6px 0 4px; } -.traj-key { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700; color: var(--text); } -.traj-key i { width: 16px; height: 3px; border-radius: 2px; display: inline-block; } -.traj-chart { width: 100%; overflow-x: auto; } -.traj-chart svg { width: 100%; height: auto; min-width: 480px; } -.traj-grid { stroke: var(--line); stroke-width: 1; } -.traj-axis { fill: var(--muted); font-size: 11px; } -.traj-axis-title { fill: var(--muted); font-size: 12px; font-weight: 700; } -.traj-foot { margin-top: 10px; font-size: 12px; } - -/* ---- My Journey: goal setup + pace bars --------------------------------- */ -.jr-goalset { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; padding: 10px 0; border-top: 1px solid var(--line); } -.jr-goalset:first-of-type { border-top: none; padding-top: 4px; } -.jr-goalset label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 700; color: var(--muted); } -.jr-goalset input { min-height: 36px; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; } -.jr-missed { color: var(--red); font-size: 12px; font-weight: 800; } -.jr-progress i.done { background: var(--green); } -.jr-pill.green { background: #e9f7ee; color: var(--green); } -.jr-trajlink { display: flex; align-items: center; justify-content: space-between; gap: 16px; } -.jr-trajlink h2 { margin: 0; } -.jr-intro h2 { margin-bottom: 4px; } -.jr-card { display: flex; flex-direction: column; } -.jr-goal { margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line); } -.jr-goal-label { display: block; font-size: 12.5px; font-weight: 800; color: var(--muted); margin-bottom: 6px; } -.jr-goal-label.done { color: var(--green); } -.jr-goal-label.miss { color: var(--red); } -.jr-goal-meta { display: block; font-size: 13px; font-weight: 800; color: var(--text); margin-bottom: 6px; } -.jr-goal-foot { display: block; font-size: 12px; color: var(--muted); margin-top: 6px; } -.jr-goal-row { display: flex; gap: 8px; align-items: center; } -.jr-goal-row input { flex: 1; min-height: 34px; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; } -.jr-goal-row button { min-height: 34px; white-space: nowrap; } -.jr-cardfoot { margin-top: auto; padding-top: 12px; display: flex; justify-content: flex-end; } -.jr-stake { background: none; border: 1px solid var(--primary); color: var(--primary); border-radius: 999px; padding: 6px 12px; font-weight: 800; font-size: 12px; cursor: pointer; } -.jr-stake:hover { background: var(--primary); color: #fff; } -.jr-goal-hint { display: block; font-size: 11.5px; color: var(--muted); margin-top: 6px; } .compare-list { display: grid; gap: 8px; } .compare-list b { font-size: 14px; } .badge-row { display: flex; flex-wrap: wrap; gap: 8px; } @@ -321,10 +282,6 @@ input { .empty { color: var(--muted); } .bank { display: grid; gap: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } -.bank-controls { display: flex; gap: 10px; align-items: center; } -.bank-controls label { display: flex; gap: 6px; align-items: center; font-size: 13px; color: var(--muted); font-weight: 700; } -.bank-controls select { min-height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 6px; } -.bank-foot { margin-top: 10px; font-size: 12.5px; } .bank-header, .bank-row { display: grid; grid-template-columns: 180px 80px 80px 80px minmax(260px, 1fr); @@ -709,134 +666,3 @@ input { .survey-primary:disabled { opacity: 0.6; cursor: default; } .survey-ghost { background: #fff; color: #475569; border-color: #cbd5e1; } .survey-note { margin: 0 24px 16px; font-size: 0.85rem; color: #b91c1c; } - -/* --- ViBe Goals tab -------------------------------------------------------- */ -.vg .muted { color: var(--muted); font-size: 13px; } -.vg .hint { font-size: 12px; color: var(--muted); } -.vg-ladder { display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; margin-top: 12px; } -.vg-step { flex: 1; min-width: 150px; border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; background: #fafdff; position: relative; } -.vg-step .n { position: absolute; top: 10px; right: 12px; width: 20px; height: 20px; border-radius: 50%; background: #e2e8f0; color: var(--muted); font-size: 12px; font-weight: 900; display: grid; place-items: center; } -.vg-step b { display: block; font-size: 15px; margin-bottom: 2px; } -.vg-step em { font-style: normal; font-size: 12px; color: var(--muted); } -.vg-step.done { background: #eefaf3; border-color: #bbe7cf; } -.vg-step.done .n { background: var(--green); color: #fff; } -.vg-step.current { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(23,107,135,.18); } -.vg-step.current .n { background: var(--primary); color: #fff; } -.vg-step.locked { opacity: .7; } -.vg-arrow { display: grid; place-items: center; color: var(--muted); font-size: 20px; font-weight: 900; } -.vg-tiles { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; } -.vg-tile { border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: #fafdff; } -.vg-tile > span { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; font-weight: 800; } -.vg-tile > strong { display: block; font-size: 22px; margin: 6px 0 2px; color: var(--primary); } -.vg-tile > em { display: block; color: var(--muted); font-style: normal; font-size: 12px; } -.vg-tile.done { background: #eefaf3; border-color: #bbe7cf; } -.vg-tile.done > strong { color: var(--green); } -.vg-pill { display: inline-block; border-radius: 999px; padding: 2px 8px; font-size: 12px; font-weight: 800; } -.vg-pill.green { background: #e9f7ee; color: var(--green); } -.vg-pill.amber { background: #fef3e2; color: var(--amber); } -.vg-progress { height: 12px; background: #e2e8f0; border-radius: 999px; overflow: hidden; margin-top: 8px; } -.vg-progress i { display: block; height: 100%; background: var(--primary); } -.vg-form { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } -.vg-field { display: grid; gap: 6px; } -.vg-field label { font-size: 13px; font-weight: 800; color: #334155; } -.vg-field input { width: 100%; border: 1px solid var(--line); border-radius: 7px; padding: 10px 12px; background: #fff; color: var(--text); } -.vg-field input[type=range] { padding: 0; accent-color: var(--primary); } -.vg-row { display: flex; align-items: center; gap: 8px; } -.vg-row input { max-width: 120px; } -.vg-wide { grid-column: 1 / -1; } -.vg-mult { display: flex; gap: 8px; } -.vg-mult button { flex: 1; border: 1px solid var(--line); background: #fff; border-radius: 7px; padding: 10px 0; font-weight: 850; color: var(--text); } -.vg-mult button.active { background: var(--primary); border-color: var(--primary); color: #fff; } -.vg-readout { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; border-top: 1px solid var(--line); padding-top: 14px; } -.vg-readout .r { border: 1px solid var(--line); border-radius: 8px; padding: 10px; background: #fbfdff; text-align: center; } -.vg-readout .r span { display: block; font-size: 12px; color: var(--muted); font-weight: 800; } -.vg-readout .r strong { display: block; font-size: 20px; margin-top: 4px; } -.vg-readout .win strong { color: var(--green); } -.vg-readout .lose strong { color: var(--red); } -.vg-actions { grid-column: 1 / -1; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } -.vg-warn { color: var(--red); font-weight: 800; font-size: 13px; } -.vg-ok { color: var(--green); font-weight: 800; font-size: 13px; } -.vg-lock { border: 1px dashed var(--primary); background: #f0f8fb; color: var(--primary-dark); border-radius: 8px; padding: 12px 14px; font-weight: 700; font-size: 14px; margin-bottom: 14px; } -.vg-bet { border: 1px solid var(--line); border-left: 4px solid var(--primary); border-radius: 8px; padding: 14px; background: #fff; display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; } -.vg-bet h4 { margin: 0 0 4px; font-size: 15px; } -.vg-bet .meta { color: var(--muted); font-size: 13px; } -.vg-bet .side { text-align: right; } -.vg-bet .side .win { color: var(--green); font-weight: 850; } -.vg-bet .side .lose { color: var(--red); font-weight: 850; } -.vg-betbtns { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; flex-wrap: wrap; } -.vg-betbtns button { min-height: 34px; padding: 0 10px; } -.vg-hit { color: var(--green); font-weight: 850; } -.vg-miss { color: var(--red); font-weight: 850; } -@media (max-width: 820px) { .vg-form { grid-template-columns: 1fr; } .vg-readout { grid-template-columns: 1fr 1fr; } .vg-tiles { grid-template-columns: 1fr; } } -.vg-readout .net { display: block; font-size: 11px; color: var(--muted); font-weight: 700; margin-top: 2px; } - -/* ---- My Journey (phase-by-phase progress + SP) ---------------------------- */ -.jr { display: grid; gap: 16px; } -.jr-plan-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-end; margin-top: 6px; } -.jr-plan-row label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 700; color: var(--muted); } -.jr-plan-row input { min-height: 36px; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; } -.jr-saved { color: var(--green); font-weight: 800; font-size: 13px; } - -.jr-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; } -@media (max-width: 720px) { .jr-grid { grid-template-columns: 1fr; } } - -.jr-card { background: var(--panel, #fff); border: 1px solid var(--line); border-radius: 12px; padding: 16px; border-top: 4px solid var(--primary); box-shadow: var(--shadow, 0 1px 2px rgba(0,0,0,.04)); } -.jr-card.phase-standups { border-top-color: #3b82f6; } -.jr-card.phase-vibe { border-top-color: #8b5cf6; } -.jr-card.phase-spa { border-top-color: #f59e0b; } -.jr-card.phase-project { border-top-color: #10b981; } - -.jr-head { display: flex; align-items: center; gap: 8px; } -.jr-head h3 { margin: 0; font-size: 16px; flex: 1; } -.jr-n { width: 22px; height: 22px; border-radius: 50%; background: var(--text); color: #fff; font-size: 12px; font-weight: 800; display: grid; place-items: center; } -.jr-sp { font-weight: 850; color: var(--green); font-size: 15px; } -.jr-sp.neg { color: var(--red); } -.jr-soon { font-size: 11px; font-weight: 800; color: var(--muted); background: #f1f5f9; border-radius: 999px; padding: 3px 8px; } -.jr-sub { color: var(--muted); font-size: 13px; margin: 6px 0 12px; } - -.jr-stats { display: flex; gap: 18px; flex-wrap: wrap; } -.jr-stats div { display: flex; flex-direction: column; } -.jr-stats strong { font-size: 22px; line-height: 1.1; } -.jr-stats span { font-size: 12px; color: var(--muted); } -.jr-big { display: flex; align-items: baseline; gap: 6px; } -.jr-big strong { font-size: 30px; } -.jr-big span { color: var(--muted); font-size: 13px; } - -.jr-dots { display: flex; gap: 8px; } -.jr-dot { flex: 1; text-align: center; border: 1px solid var(--line); border-radius: 8px; padding: 8px 4px; } -.jr-dot.done { background: #ede9fe; border-color: #8b5cf6; } -.jr-dot.current { background: #f5f3ff; border-color: #8b5cf6; box-shadow: inset 0 0 0 1px #8b5cf6; } -.jr-dot b { display: block; font-size: 15px; } -.jr-dot span { font-size: 10px; color: var(--muted); } - -.jr-progress { height: 12px; background: #e2e8f0; border-radius: 999px; overflow: hidden; margin: 8px 0; } -.jr-progress i { display: block; height: 100%; background: #f59e0b; } - -.jr-splits { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 12px; } -.jr-pill { display: inline-block; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 700; background: #eef2ff; color: var(--text); } -.jr-pill.amber { background: #fef3e2; color: var(--amber); } -.jr-pill.muted { background: #f1f5f9; color: var(--muted); font-weight: 600; } -.jr-link { background: none; border: none; color: var(--primary); font-weight: 800; font-size: 12px; cursor: pointer; padding: 0; margin-left: auto; } - -/* ---- Commitments hub (sub-tabs, one phase at a time) --------------------- */ -.cm { display: grid; gap: 12px; } -.cm-subtabs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } -.cm-subtab { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); background: #fff; border-radius: 999px; padding: 8px 14px; font: inherit; font-weight: 700; font-size: 13px; color: var(--muted); cursor: pointer; } -.cm-subtab:hover { border-color: var(--primary); color: var(--text); } -.cm-subtab.active { background: var(--primary); border-color: var(--primary); color: #fff; } -.cm-subtab.active .cm-tag { background: rgba(255, 255, 255, 0.24); color: #fff; } -.cm-acc { border: 1px solid var(--line); border-radius: 12px; background: var(--panel, #fff); overflow: hidden; border-left: 4px solid var(--line); } -.cm-acc.open { box-shadow: var(--shadow, 0 1px 3px rgba(0,0,0,.06)); } -.cm-acc.phase-vibe.open { border-left-color: #8b5cf6; } -.cm-acc.phase-standup.open { border-left-color: #3b82f6; } -.cm-acc.phase-spa.open { border-left-color: #f59e0b; } -.cm-acc.phase-project.open { border-left-color: #10b981; } -.cm-accbtn { width: 100%; display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: none; border: none; cursor: pointer; text-align: left; font: inherit; } -.cm-accbtn b { font-size: 15px; } -.cm-caret { color: var(--muted); font-size: 12px; width: 12px; } -.cm-tag { font-size: 11px; font-weight: 800; color: var(--muted); background: #f1f5f9; border-radius: 999px; padding: 2px 8px; } -.cm-blurb { color: var(--muted); font-size: 12.5px; margin-left: auto; text-align: right; max-width: 46%; } -@media (max-width: 620px) { .cm-blurb { display: none; } } -.cm-body { padding: 4px 14px 14px; border-top: 1px solid var(--line); } -.cm-body .vg { margin-top: 8px; } -.cm-soon { color: var(--muted); font-size: 14px; line-height: 1.6; padding: 10px 2px; } diff --git a/server/server.js b/server/server.js index 8354211..1c8ceb6 100644 --- a/server/server.js +++ b/server/server.js @@ -14,18 +14,20 @@ import PollRecord from './models/PollRecord.js'; import SPTransaction from './models/SPTransaction.js'; import SessionEvent from './models/SessionEvent.js'; import { leagueBand, levelFor, legendBadge, leaderboardGroup, groupLabel } from './services/levels.js'; +import { + getInvestmentPlans, + startInvestment, + resolveDueInvestmentsForStudent, + resolveAllDueInvestments, + getStudentInvestments, + getAllInvestments +} from './services/investmentService.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); const clientDist = path.join(rootDir, 'client', 'dist'); -// Admin auth is env-only — NO hardcoded fallback. A committed default would be a -// public credential (anyone reading the repo could authenticate). If either is -// unset, admin endpoints fail closed (see isAdmin) rather than accept a known value. -const ADMIN_EMAIL = normalizeEmail(process.env.ADMIN_EMAIL || ''); -const ADMIN_TOKEN = process.env.ADMIN_TOKEN || ''; -if (!ADMIN_EMAIL || !ADMIN_TOKEN) { - console.warn('[security] ADMIN_EMAIL/ADMIN_TOKEN not set — admin endpoints are DISABLED until both are configured in .env'); -} +const ADMIN_EMAIL = normalizeEmail(process.env.ADMIN_EMAIL || 'dled@iitrpr.ac.in'); +const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'vled-local-admin'; // Survey triangulation pop-up(s). All driven by env so the form link / mode can // change without a client rebuild (the client reads these via /api/config). @@ -56,8 +58,7 @@ function makeSurvey(prefix, completedField) { } const SURVEY = makeSurvey('SURVEY', 'surveyCompleted'); const POLL2 = makeSurvey('POLL2', 'poll2Completed'); -const POLL3 = makeSurvey('POLL3', 'poll3Completed'); -const SURVEYS = [SURVEY, POLL2, POLL3]; +const SURVEYS = [SURVEY, POLL2]; // Cached fetch of the submitted-email set from a survey's Apps Script endpoint. async function getSubmittedEmails(cfg) { @@ -67,17 +68,10 @@ async function getSubmittedEmails(cfg) { const u = cfg.responsesUrl + (cfg.responsesUrl.includes('?') ? '&' : '?') + 'secret=' + encodeURIComponent(cfg.responsesSecret); const r = await fetch(u, { redirect: 'follow' }); - // Apps Script intermittently serves an HTML error/redirect page (esp. under - // load) instead of JSON; parse defensively so it fails cleanly instead of - // throwing an opaque "Unexpected token '<'". - const body = await r.text(); - let j; - try { j = JSON.parse(body); } - catch { throw new Error(`non-JSON response (HTTP ${r.status}, ${body.length}B)`); } + const j = await r.json(); cfg._subs = { at: Date.now(), set: new Set((j.emails || []).map(e => normalizeEmail(e))) }; return cfg._subs.set; } catch (err) { - cfg._subs.at = Date.now(); // back off 60s on failure too — don't hammer Apps Script / spam logs console.error(`${cfg.key} responses fetch failed:`, err?.message); return cfg._subs.set; // serve last good cache on failure } @@ -325,9 +319,7 @@ async function studentPayload(student) { leaderboardGroup: myGroup, leaderboardGroupLabel: groupLabel(myGroup), surveyCompleted: Boolean(student.surveyCompleted), - poll2Completed: Boolean(student.poll2Completed), - poll3Completed: Boolean(student.poll3Completed), - eligibleForVibeGoals: isVibeEligible(student) + poll2Completed: Boolean(student.poll2Completed) }, transactions, polls, @@ -345,7 +337,6 @@ async function studentPayload(student) { } function isAdmin(req) { - if (!ADMIN_EMAIL || !ADMIN_TOKEN) return false; // fail closed when admin creds aren't configured const emailOk = normalizeEmail(req.headers['x-admin-email']) === ADMIN_EMAIL; const tokenOk = String(req.headers['x-admin-token'] || '') === ADMIN_TOKEN; return emailOk && tokenOk; @@ -361,8 +352,7 @@ api.get('/health', (_req, res) => res.json({ status: 'ok' })); api.get('/config', (_req, res) => res.json({ allowStudentSearch: ALLOW_STUDENT_SEARCH, survey: surveyPublic(SURVEY), - poll2: surveyPublic(POLL2), - poll3: surveyPublic(POLL3) + poll2: surveyPublic(POLL2) })); api.get('/me', async (req, res) => { @@ -374,97 +364,6 @@ api.get('/me', async (req, res) => { res.json({ authenticated: true, profile: await studentPayload(student) }); }); -// ---- ViBe Goals (commitment-SP module; 16 July cohort onward) ---------------- -async function vibeStudent(req) { - const email = normalizeEmail(req.body?.email || req.query.email) || await studentEmailFromRequest(req); - if (!email) return null; - return Student.findOne({ $or: [{ email }, { alternateEmail: email }] }).lean(); -} - -api.get('/vibe/state', async (req, res) => { - const student = await vibeStudent(req); - if (!student) return res.status(404).json({ error: 'Student not found' }); - if (!isVibeEligible(student)) return res.json({ eligible: false }); - res.json(await buildVibeState(student)); -}); - -api.post('/vibe/bet', async (_req, res) => { - // ON HOLD: ViBe commitments are paused — the ViBe completion feed (leaderboard API) - // is unavailable, so bets can't be verified or settled. No new bets can be placed - // (nothing is staked/debited) until the feed is restored. - return res.status(403).json({ error: 'ViBe commitments are on hold and will be back up soon.' }); -}); - -api.put('/vibe/bet/:id', async (_req, res) => { - // ON HOLD: see POST /vibe/bet. - return res.status(403).json({ error: 'ViBe commitments are on hold and will be back up soon.' }); -}); - -// DEMO: resolve a bet (no live settlement cron locally). result = 'won' | 'lost'. -api.post('/vibe/bet/:id/settle', async (_req, res) => { - // LOCKED DOWN (security): client-controlled self-settlement is removed. This route - // trusted req.body.result (defaulting to "won") and granted SP with NO check against - // real ViBe course completion — students could place a bet and instantly self-declare - // a win to mint SP. There is no real completion feed (VibeProgress.pct was written by - // settleBetDemo itself), so settlement cannot be verified yet; disabled until a - // server-side/automatic settlement against real completion data is built. - return res.status(403).json({ error: 'Bets are settled automatically, not on request. Self-settlement is disabled.' }); -}); - -// ---- SPA → SP (peer-teaching endorsement points; ALL cohorts) ---------------- -// DISPLAY ONLY: SP is scored + credited by the pipeline rubric; this just reads -// the `spaprogresses` summary + student total. Universal, no cohort gate. -api.get('/spa/state', async (req, res) => { - const student = await vibeStudent(req); - if (!student) return res.status(404).json({ error: 'Student not found' }); - res.json(await buildSpaState(student)); -}); - -// ---- SP trajectory (You vs cohort vs onboarding-group; open to all students) -- -// The student's own weekly line is built live from their ledger; the cohort/group -// reference lines come from the cached TrajectorySnapshot (buildTrajectories.js). -api.get('/trajectory/state', async (req, res) => { - const student = await vibeStudent(req); - if (!student) return res.status(404).json({ error: 'Student not found' }); - res.json(await buildTrajectoryState(student)); -}); - -// ---- Standup commitments (weekly, attendance-only; keep-the-stake) ----------- -api.get('/standup/state', async (req, res) => { - const student = await vibeStudent(req); - if (!student) return res.status(404).json({ error: 'Student not found' }); - if (!isVibeEligible(student)) return res.json({ eligible: false }); - res.json(await buildStandupState(student)); -}); - -api.post('/standup/commit', async (_req, res) => { - // PAUSED: standups moved to YouTube Live and the attendance module is being - // reworked — no new standup commitments until the new attendance tracking lands. - return res.status(403).json({ error: 'Standup commitments are paused while attendance is reworked for YouTube Live.' }); -}); - -// DEMO: resolve a standup commitment (no live weekly settlement cron yet). -api.post('/standup/commit/:id/settle', async (_req, res) => { - // LOCKED DOWN (security): same self-settlement exploit as /vibe/bet/:id/settle — - // client-declared "won" minted SP with no verification. Disabled until server-side - // settlement against real attendance/completion is built. - return res.status(403).json({ error: 'Commitments are settled automatically, not on request. Self-settlement is disabled.' }); -}); - -// ---- My Journey (phase-by-phase progress + SP; 16 July cohort onward) --------- -api.get('/journey/state', async (req, res) => { - const student = await vibeStudent(req); - if (!student) return res.status(404).json({ error: 'Student not found' }); - res.json(await buildJourneyState(student)); // My Journey is universal (Phase 1); Commitments stays gated -}); - -api.put('/journey/plan', async (req, res) => { - const student = await vibeStudent(req); - if (!student) return res.status(404).json({ error: 'Student not found' }); // My Journey goals are universal (Phase 1) - await saveJourneyPlan(student.email, req.body || {}); - res.json(await buildJourneyState(student)); -}); - api.get('/search', async (req, res) => { if (!ALLOW_STUDENT_SEARCH) return res.status(403).json({ error: 'Student search is disabled. Please login from Samagama to view your Spurti Points.' }); const q = String(req.query.q || '').trim(); @@ -593,7 +492,6 @@ function registerSurveyRoutes(base, cfg) { } registerSurveyRoutes('/survey', SURVEY); registerSurveyRoutes('/poll2', POLL2); -registerSurveyRoutes('/poll3', POLL3); api.get('/admin/stats', adminGuard, async (_req, res) => { const [yetToOnboard, excusedStudents, sessions, txns, activeStudents] = await Promise.all([ From e376d15d8f565b52650be5ee302db4445df53c77 Mon Sep 17 00:00:00 2001 From: rishisaini1403-ship-it Date: Sat, 1 Aug 2026 17:50:44 +0530 Subject: [PATCH 4/4] fix(vault): add hourly resolver cron + detailed investment status timestamps - Add env-gated hourly cron (INVESTMENT_RESOLVER_ENABLED) to close the lazy-resolution gap between maturity and credit - Add 3-level investment status (active / matured-awaiting-credit / resolved) with exact invested/matured/credited timestamps on history cards - Fix stale SP balance not refreshing after resolution - Fix negative 'matures in -N days' edge case on active card - Remove unreachable dead-code branches (cancelled status, active-in-history) --- client/src/main.jsx | 119 +++++++++++++++++++++++++++++++++++--------- server/server.js | 21 ++++++++ 2 files changed, 116 insertions(+), 24 deletions(-) diff --git a/client/src/main.jsx b/client/src/main.jsx index 0d3cfd7..d30971d 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -70,7 +70,11 @@ function App() { if (view === 'student' && profile) { return ( <> - setView('landing') : null} /> + setView('landing') : null} + onStudentUpdate={(updatedStudent) => setProfile(prev => ({ ...prev, student: updatedStudent }))} + /> buildBadges(profile), [profile]); @@ -285,7 +289,7 @@ function StudentView({ profile, onBack }) { {tab === 'bank' && } {tab === 'polls' && } {tab === 'leaderboard' && } - {tab === 'vault' && } + {tab === 'vault' && } ); } @@ -512,13 +516,35 @@ function formatDate(date) { return new Date(date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); } +function formatDateTime(date) { + if (!date) return ''; + const d = new Date(date); + const datePart = d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); + const timePart = d.toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit', hour12: true }); + return `${datePart}, ${timePart}`; +} + +function formatRelativeMaturity(endDate) { + if (!endDate) return ''; + const now = new Date(); + const end = new Date(endDate); + const sameDay = now.toDateString() === end.toDateString(); + if (sameDay) return 'Matures today'; + const tomorrow = new Date(now); + tomorrow.setDate(now.getDate() + 1); + if (tomorrow.toDateString() === end.toDateString()) return 'Matures tomorrow'; + const msPerDay = 24 * 60 * 60 * 1000; + const days = Math.ceil((end.getTime() - now.getTime()) / msPerDay); + return `Matures in ${days} day${days === 1 ? '' : 's'}`; +} + const PLAN_DESCRIPTIONS = { safe: 'Recommended for short-term commitment.', growth: 'Balanced reward and commitment.', diamond: 'Highest reward for committed students.' }; -function Vault({ student }) { +function Vault({ student, onStudentUpdate }) { const [plans, setPlans] = useState([]); const [investments, setInvestments] = useState([]); const [loading, setLoading] = useState(true); @@ -543,6 +569,22 @@ function Vault({ student }) { if (mineRes.ok) { const data = await mineRes.json(); setInvestments(data.investments || []); + // Refresh the header SP balance so a freshly-resolved investment + // (credit posted server-side during /investments/mine) reflects in + // the score-card without a full page reload. Fail-soft on /me. + if (onStudentUpdate) { + try { + const meRes = await fetch(`${API}/me`, { credentials: 'include' }); + if (meRes.ok) { + const meData = await meRes.json(); + if (meData.authenticated && meData.profile?.student) { + onStudentUpdate(meData.profile.student); + } + } + } catch { + // skip — header will keep its existing totalSp + } + } } } catch { setError('Failed to load vault data.'); @@ -611,21 +653,34 @@ function Vault({ student }) {

SP Investment Vault

Lock your Spurti Points to earn a bonus — but only if you attend every session during the lock period. If attendance slips, the invested SP is forfeited.

- {active && ( -
-
- Active Investment - {active.planKey.charAt(0).toUpperCase() + active.planKey.slice(1)} Plan -
- {active.principal} SP locked - Bonus: +{Math.round(active.bonusRate * 100)}% - Expected Return: {Math.round(active.principal * active.bonusRate) + active.principal} SP - Matures: {formatDate(active.endDate)} + {active && (() => { + const activeIsMatured = new Date(active.endDate) <= new Date(); + return ( +
+
+ Active Investment + {active.planKey.charAt(0).toUpperCase() + active.planKey.slice(1)} Plan +
+ {active.principal} SP locked + Bonus: +{Math.round(active.bonusRate * 100)}% + Expected Return: {Math.round(active.principal * active.bonusRate) + active.principal} SP + {activeIsMatured ? ( + <> + Matured on: {formatDateTime(active.endDate)} + SP will be credited shortly — resolution pending + + ) : ( + <> + Matures on: {formatDateTime(active.endDate)} + {formatRelativeMaturity(active.endDate)} + + )} +
+ active
- active -
- )} + ); + })()} {!active && (
@@ -700,6 +755,8 @@ function Vault({ student }) { {history.length > 0 &&

History

} {history.map(inv => { const planLabel = plans.find(p => p.key === inv.planKey)?.label || inv.planKey; + const isResolved = inv.resolvedAt != null; + const level = isResolved ? 'RESOLVED' : 'MATURED_AWAITING_CREDIT'; return (
@@ -708,18 +765,32 @@ function Vault({ student }) {
Invested: {inv.principal} SP
- {inv.status === 'completed' && ( + {level === 'MATURED_AWAITING_CREDIT' && ( + <> +
Matured on: {formatDateTime(inv.endDate)}
+

SP will be credited shortly — resolution pending

+ + )} + {level === 'RESOLVED' && inv.status === 'completed' && ( <>
Bonus Earned: +{inv.bonus} SP
Received: {inv.totalReturn} SP
-
Matured on: {formatDate(inv.endDate)}
+
Invested on: {formatDateTime(inv.startDate)}
+
Matured on: {formatDateTime(inv.endDate)}
+
SP Credit: {inv.resolvedAt ? `Done — ${formatDateTime(inv.resolvedAt)}` : 'Pending'}
)} - {inv.status === 'failed' && ( -
Reason: Attendance requirement not met.
- )} - {inv.status === 'cancelled' && ( -
Status: Cancelled
+ {level === 'RESOLVED' && inv.status === 'failed' && ( + <> +
Invested on: {formatDateTime(inv.startDate)}
+
Resolved on: {formatDateTime(inv.resolvedAt)}
+

+ {inv.attendedSessions != null && inv.requiredSessions != null + ? `Failed — attendance requirement not met (${inv.attendedSessions}/${inv.requiredSessions} sessions attended)` + : 'Failed — attendance requirement not met'} +

+
SP Credit: 0
+ )}
diff --git a/server/server.js b/server/server.js index 1c8ceb6..314728c 100644 --- a/server/server.js +++ b/server/server.js @@ -772,6 +772,27 @@ if (fs.existsSync(clientDist)) { mongoose.connect(MONGO_URI).then(() => { app.listen(PORT, () => console.log(`Spurti app running at http://localhost:${PORT}/`)); + + // --- Server-side investment resolver (lazy-resolution fallback) ------- + // Closes the gap where a matured investment stays un-credited until the + // student next opens the Vault. Env-gated so it can be disabled without + // code changes. Runs at most once per hour; in-memory overlap guard. + if (process.env.INVESTMENT_RESOLVER_ENABLED === 'true') { + const RESOLVER_INTERVAL_MS = 60 * 60 * 1000; + let isRunning = false; + setInterval(async () => { + if (isRunning) return; + isRunning = true; + try { + const resolved = await resolveAllDueInvestments(); + console.log(`investment_resolver: resolved ${resolved.length} due record(s) at ${new Date().toISOString()}`); + } catch (err) { + console.error('investment_resolver_failed', err?.message); + } finally { + isRunning = false; + } + }, RESOLVER_INTERVAL_MS); + } }).catch((error) => { console.error(error); process.exit(1);