/** * examRepository — the SINGLE module that talks to the /api/exam backend (spec R2.1 seam). * * All exam-marker persistence flows through here so a later dual-write / offline cache can slot * in without touching feature code. Mirrors the auth pattern of timetableService: take the * Supabase session JWT and send it as a Bearer token; the API enforces RLS as the user. */ import axios from 'axios'; import { API_BASE } from '../../config/apiConfig'; import { logger } from '../../debugConfig'; import { supabase } from '../../supabaseClient'; import type { BatchQueueResponse, BatchResultsResponse, CreateBatchPayload, CreateTemplatePayload, ExamBoundary, ExamQuestion, ExamResponseArea, ExamTemplate, ExamTemplateDetail, ExamTemplateLayout, MarkingBatch, MarkUpsertPayload, Neo4jSyncResult, PatchQuestionPayload, SpecPoint, TemplateReplacePayload, UpdateTemplateMetaPayload, } from '../../types/exam.types'; const EXAM_BASE = `${API_BASE}/api/exam`; async function authHeaders(): Promise> { const { data: { session } } = await supabase.auth.getSession(); if (!session?.access_token) { throw new Error('No authentication token available'); } return { Authorization: `Bearer ${session.access_token}` }; } function newUuid(): string { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = Math.floor(Math.random() * 16); const v = c === 'x' ? r : (r % 4) + 8; return v.toString(16); }); } function questionPayload(q: ExamQuestion, idMap?: Map) { return { id: idMap?.get(q.id) ?? q.id, parent_id: q.parent_id ? (idMap?.get(q.parent_id) ?? q.parent_id) : null, label: q.label, order: q.order, max_marks: q.max_marks, answer_type: q.answer_type, mcq_options: q.mcq_options, mark_scheme: q.mark_scheme ?? {}, is_container: q.is_container, spec_ref: q.spec_ref, bounds: q.bounds ?? null, page: q.page ?? null, source: q.source ?? 'manual', confirmed: q.confirmed ?? true, confidence: q.confidence ?? null, derivation: q.derivation ?? null, }; } function responseAreaPayload(r: ExamResponseArea, idMap?: Map, duplicate = false) { return { id: duplicate ? newUuid() : r.id, question_id: idMap?.get(r.question_id) ?? r.question_id, page: r.page, bounds: r.bounds, kind: r.kind, response_form: r.response_form, context_type: r.context_type ?? null, source: r.source, confirmed: r.confirmed, confidence: r.confidence, mark_subtype: r.mark_subtype ?? null, derivation: r.derivation ?? null, }; } function boundaryPayload(b: ExamBoundary, idMap?: Map, duplicate = false) { return { id: duplicate ? newUuid() : b.id, question_id: b.question_id ? (idMap?.get(b.question_id) ?? b.question_id) : null, label: b.label, page_index: b.page_index, y: b.y, bounds: b.bounds, source: b.source, confirmed: b.confirmed, confidence: b.confidence ?? null, derivation: b.derivation ?? null, }; } function layoutPayload(layout: ExamTemplateLayout, duplicate = false) { return { id: duplicate ? newUuid() : layout.id, page_index: layout.page_index, role: layout.role ?? null, margin_left: layout.margin_left ?? null, margin_right: layout.margin_right ?? null, margin_top: layout.margin_top ?? null, margin_bottom: layout.margin_bottom ?? null, margins_enabled: layout.margins_enabled ?? true, source: layout.source ?? 'manual', confirmed: layout.confirmed ?? true, confidence: layout.confidence ?? null, derivation: layout.derivation ?? null, meta: layout.meta ?? {}, }; } async function replaceTemplate( templateId: string, detail: ExamTemplateDetail, meta?: UpdateTemplateMetaPayload, duplicateIds = false, ): Promise { const headers = await authHeaders(); const idMap = new Map(); if (duplicateIds) { detail.questions.forEach((q) => idMap.set(q.id, newUuid())); } const res = await axios.put( `${EXAM_BASE}/templates/${templateId}`, { meta, questions: detail.questions.map((q) => questionPayload(q, idMap)), response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)), boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)), layout: (detail.layout ?? []).map((layout) => layoutPayload(layout, duplicateIds)), }, { headers }, ); return res.data; } export const examRepository = { async listTemplates(includeArchived = false): Promise { const headers = await authHeaders(); const res = await axios.get<{ templates: ExamTemplate[] }>( `${EXAM_BASE}/templates`, { headers, params: { include_archived: includeArchived } }, ); return res.data.templates ?? []; }, async getTemplate(templateId: string): Promise { const headers = await authHeaders(); const res = await axios.get(`${EXAM_BASE}/templates/${templateId}`, { headers }); return res.data; }, async getTemplateSourcePdf(templateId: string): Promise { const headers = await authHeaders(); const res = await axios.get(`${EXAM_BASE}/templates/${templateId}/source-pdf`, { headers, responseType: 'arraybuffer', }); return res.data; }, async createTemplate(payload: CreateTemplatePayload): Promise { const headers = await authHeaders(); const res = await axios.post(`${EXAM_BASE}/templates`, payload, { headers }); return res.data; }, async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise { const headers = await authHeaders(); const res = await axios.patch(`${EXAM_BASE}/templates/${templateId}`, meta, { headers }); return res.data; }, async duplicateTemplate(templateId: string, title: string): Promise { const detail = await this.getTemplate(templateId); const created = await this.createTemplate({ title, subject: detail.subject ?? undefined, exam_id: detail.exam_id ?? undefined, exam_code: detail.exam_code ?? undefined, source_file_id: detail.source_file_id ?? undefined, page_count: detail.page_count ?? undefined, institute_id: detail.institute_id, }); try { return await replaceTemplate(created.id, { ...detail, id: created.id }, { title, status: 'draft' }, true); } catch (error) { try { await this.archiveTemplate(created.id); } catch (archiveError) { logger.error('cc-exam-marker', 'Failed to archive incomplete duplicate template', { templateId: created.id, message: archiveError instanceof Error ? archiveError.message : String(archiveError), }); } throw error; } }, async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise { const headers = await authHeaders(); const res = await axios.put(`${EXAM_BASE}/templates/${templateId}`, payload, { headers }); return res.data; }, async archiveTemplate(templateId: string): Promise { const headers = await authHeaders(); await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers }); }, async patchQuestion(questionId: string, payload: PatchQuestionPayload) { const headers = await authHeaders(); const res = await axios.patch(`${EXAM_BASE}/questions/${questionId}`, payload, { headers }); return res.data; }, async listSpecPoints(specCode: string, search?: string): Promise { const headers = await authHeaders(); const res = await axios.get<{ points?: SpecPoint[] } | SpecPoint[]>( `${EXAM_BASE}/specs/${encodeURIComponent(specCode)}/points`, { headers, params: search ? { q: search } : undefined }, ); if (Array.isArray(res.data)) return res.data; return res.data.points ?? []; }, async syncTemplateToGraph(templateId: string): Promise { const headers = await authHeaders(); const res = await axios.post(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers }); return res.data; }, async createBatch(payload: CreateBatchPayload): Promise { const headers = await authHeaders(); const res = await axios.post(`${EXAM_BASE}/batches`, payload, { headers }); return res.data; }, async listBatches(params: { includeArchived?: boolean; templateId?: string } = {}): Promise { const headers = await authHeaders(); const res = await axios.get<{ batches: MarkingBatch[] }>(`${EXAM_BASE}/batches`, { headers, params: { include_archived: params.includeArchived ?? false, template_id: params.templateId, }, }); return res.data.batches ?? []; }, async getBatchQueue(batchId: string): Promise { const headers = await authHeaders(); const res = await axios.get(`${EXAM_BASE}/batches/${batchId}/queue`, { headers }); return res.data; }, async getBatchResults(batchId: string): Promise { const headers = await authHeaders(); const res = await axios.get(`${EXAM_BASE}/batches/${batchId}/results`, { headers }); return res.data; }, async getBatchCsv(batchId: string): Promise { const headers = await authHeaders(); const res = await axios.get(`${EXAM_BASE}/batches/${batchId}/csv`, { headers, responseType: 'text' }); return res.data; }, async upsertMark(markId: string, payload: MarkUpsertPayload): Promise { const headers = await authHeaders(); const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers }); return res.data; }, }; export default examRepository;