190 lines
6.3 KiB
TypeScript
190 lines
6.3 KiB
TypeScript
/**
|
|
* 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 {
|
|
CreateTemplatePayload,
|
|
ExamBoundary,
|
|
ExamQuestion,
|
|
ExamResponseArea,
|
|
ExamTemplate,
|
|
ExamTemplateDetail,
|
|
TemplateReplacePayload,
|
|
UpdateTemplateMetaPayload,
|
|
} from '../../types/exam.types';
|
|
|
|
const EXAM_BASE = `${API_BASE}/api/exam`;
|
|
|
|
async function authHeaders(): Promise<Record<string, string>> {
|
|
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<string, string>) {
|
|
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,
|
|
};
|
|
}
|
|
|
|
function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, 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,
|
|
};
|
|
}
|
|
|
|
function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, 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,
|
|
};
|
|
}
|
|
|
|
async function replaceTemplate(
|
|
templateId: string,
|
|
detail: ExamTemplateDetail,
|
|
meta?: UpdateTemplateMetaPayload,
|
|
duplicateIds = false,
|
|
): Promise<ExamTemplateDetail> {
|
|
const headers = await authHeaders();
|
|
const idMap = new Map<string, string>();
|
|
if (duplicateIds) {
|
|
detail.questions.forEach((q) => idMap.set(q.id, newUuid()));
|
|
}
|
|
const res = await axios.put<ExamTemplateDetail>(
|
|
`${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)),
|
|
},
|
|
{ headers },
|
|
);
|
|
return res.data;
|
|
}
|
|
|
|
export const examRepository = {
|
|
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
|
|
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<ExamTemplateDetail> {
|
|
const headers = await authHeaders();
|
|
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
|
return res.data;
|
|
},
|
|
|
|
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
|
|
const headers = await authHeaders();
|
|
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
|
|
headers,
|
|
responseType: 'arraybuffer',
|
|
});
|
|
return res.data;
|
|
},
|
|
|
|
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
|
const headers = await authHeaders();
|
|
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
|
return res.data;
|
|
},
|
|
|
|
async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise<ExamTemplate> {
|
|
const headers = await authHeaders();
|
|
const res = await axios.patch<ExamTemplate>(`${EXAM_BASE}/templates/${templateId}`, meta, { headers });
|
|
return res.data;
|
|
},
|
|
|
|
async duplicateTemplate(templateId: string, title: string): Promise<ExamTemplateDetail> {
|
|
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<ExamTemplateDetail> {
|
|
const headers = await authHeaders();
|
|
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
|
|
return res.data;
|
|
},
|
|
async archiveTemplate(templateId: string): Promise<void> {
|
|
const headers = await authHeaders();
|
|
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
|
},
|
|
};
|
|
|
|
export default examRepository;
|