import { db, admin } from "../config/firebase.js"; import { isValidSubject } from "../config/constants.js"; const EVALUATIONS_COLLECTION = "evaluations"; const toIso = (value) => { if (!value) return null; if (typeof value.toDate === "function") { return value.toDate().toISOString(); } const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date.toISOString(); }; /** One shape for an evaluation, used by every read path so they cannot drift. */ const shape = (doc) => { const data = doc.data(); return { evaluationId: doc.id, question: data.question || "", answer: data.answer || "", feedback: data.feedback || "", scores: data.scores || {}, average: Number(data.average || 0), relevanceScore: Number(data.relevanceScore || 0), scoredBy: data.scoredBy || "", rubricId: data.rubricId || null, rubricCriteria: Array.isArray(data.rubricCriteria) ? data.rubricCriteria : null, rubricTotal: data.rubricTotal ?? null, rubricMaxTotal: data.rubricMaxTotal ?? null, isRelated: Boolean(data.isRelated), createdAt: toIso(data.createdAt), subject: data.subject || null, department: data.department || null, studentName: data.studentName || null, userUid: data.userUid || null, aiDetection: data.aiDetection || null, duplicateCheck: data.duplicateCheck || null, // Teacher override, when one has been applied. override: data.override ? { scores: data.override.scores || {}, average: Number(data.override.average || 0), reason: data.override.reason || "", byName: data.override.byName || "", byUid: data.override.byUid || "", at: toIso(data.override.at), } : null, }; }; export const saveEvaluation = async (evaluation) => { const payload = { ...evaluation, createdAt: admin.firestore.FieldValue.serverTimestamp(), }; const doc = await db.collection(EVALUATIONS_COLLECTION).add(payload); return { evaluationId: doc.id }; }; export const getEvaluationLogsByUser = async ({ userUid, limit = 50, subject = null }) => { const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200); const useSubject = Boolean(subject && isValidSubject(subject)); let q = db.collection(EVALUATIONS_COLLECTION).where("userUid", "==", userUid); if (useSubject) q = q.where("subject", "==", subject); try { const snap = await q.orderBy("createdAt", "desc").limit(safeLimit).get(); return snap.docs.map(shape); } catch (error) { // Adding the subject filter needs a composite index (userUid + subject + createdAt). // Rather than failing the student's own log view, fetch by userUid alone - which is // already indexed - and filter and sort in memory. if (!useSubject) throw error; const snap = await db .collection(EVALUATIONS_COLLECTION) .where("userUid", "==", userUid) .orderBy("createdAt", "desc") .limit(safeLimit * 3) .get(); return snap.docs .map(shape) .filter((d) => d.subject === subject) .slice(0, safeLimit); } }; /** * Teacher view: every student's submissions, optionally narrowed by subject or * department. Students never reach this - the route is role-gated. */ export const getEvaluationsForTeacher = async ({ limit = 100, subject = null, department = null, } = {}) => { const safeLimit = Math.min(Math.max(Number(limit) || 100, 1), 500); let q = db.collection(EVALUATIONS_COLLECTION); if (subject && isValidSubject(subject)) q = q.where("subject", "==", subject); if (department) q = q.where("department", "==", String(department).toUpperCase()); try { const snap = await q.orderBy("createdAt", "desc").limit(safeLimit).get(); return { logs: snap.docs.map(shape), degraded: false }; } catch (error) { // A filtered + ordered query needs a composite index. Rather than showing the // teacher an error, fall back to an unordered fetch and sort in memory. const snap = await q.limit(safeLimit).get(); const logs = snap.docs.map(shape) .sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || ""))); return { logs, degraded: true, indexHint: error.message }; } }; export const getEvaluationById = async (evaluationId) => { const doc = await db.collection(EVALUATIONS_COLLECTION).doc(evaluationId).get(); if (!doc.exists) throw new Error("Evaluation not found"); return shape(doc); }; /** * Apply a teacher's score adjustment. * * The model's original scores are never overwritten - the override is stored alongside * them with who changed it and why. That keeps the audit trail intact, which matters * both for fairness to the student and for your own data if you analyse how often * teachers disagreed with the model. */ export const overrideScores = async ({ evaluationId, scores, reason, byUid, byName }) => { const ref = db.collection(EVALUATIONS_COLLECTION).doc(evaluationId); const doc = await ref.get(); if (!doc.exists) throw new Error("Evaluation not found"); const clean = {}; for (const [k, v] of Object.entries(scores || {})) { const n = Number(v); if (!Number.isFinite(n)) throw new Error(`Score for "${k}" must be a number`); if (n < 0 || n > 100) throw new Error(`Score for "${k}" is out of range`); clean[k] = Math.round(n * 100) / 100; } if (!Object.keys(clean).length) throw new Error("No scores provided"); const values = Object.values(clean); const average = Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100; await ref.set( { override: { scores: clean, average, reason: String(reason || "").trim().slice(0, 500), byUid, byName: String(byName || ""), at: admin.firestore.FieldValue.serverTimestamp(), }, }, { merge: true } ); return getEvaluationById(evaluationId); };