// ============ AcademIA8 · Consola — cliente API real (arq_05) ============ // Adaptador entre el modelo del front (curso con unidades de lista mixta) y los // contratos del backend (arq_05). MAPEOS: // - Ítem de texto → PASO (texto; orden = posición en la lista). // - Pregunta → PASO con checkpoint embebido (opcion_multiple, bloqueante, // reorientacion) + CONCEPTO autogenerado 1:1 (invisible). // - Reordenar la lista → re-PUT de los pasos con su nuevo `orden`. // Los fragmentos clásicos / items_recall sueltos de cursos backfilleados se // preservan y muestran read-only (no se editan desde la consola en v1). const A8Api = (() => { const CFG = () => window.A8_CONFIG || {}; const GENERIC_REORIENT = "Repasemos ese punto con calma antes de seguir."; const DEFAULT_CRITERIO = { tipo: "intento_recall_correcto_minimo", parametros: { minimo_intentos_correctos: 1 }, }; // Migración del textarea único viejo (bug: todo se guardaba en si_incorrecto, // ver commit 9a71ac0). Si el autor pegó texto con separadores tipo "Si // correcto:", "Correcto:" o "*Correcto:*" (con o sin "Si", con o sin // asteriscos alrededor de la palabra o los dos puntos, con o sin bullet/guion // inicial, insensible a mayúsculas — variantes reales vistas en curso_ywprl6), // lo separamos en los tres campos reales. Si ningún separador matchea, todo // se queda en si_incorrecto — mismo comportamiento que hasta ahora, no se // inventa nada. function parseReorientLegacy(text) { const empty = { si_correcto: null, si_parcial: null, si_incorrecto: null }; if (!text || !text.trim()) return empty; const re = /\*?\b(?:si\s+)?(correcto|parcial|incorrecto)\b\*?\s*:\*?/gi; const matches = [...text.matchAll(re)]; if (!matches.length) return { ...empty, si_incorrecto: text.trim() }; const out = { ...empty }; const keyOf = { correcto: "si_correcto", parcial: "si_parcial", incorrecto: "si_incorrecto" }; for (let i = 0; i < matches.length; i++) { const key = keyOf[matches[i][1].toLowerCase()]; const start = matches[i].index + matches[i][0].length; const end = i + 1 < matches.length ? matches[i + 1].index : text.length; // El próximo bloque puede empezar con un bullet ("- Si parcial: ..." o // "- *Parcial:*..."); ese "-"/"•"/"*" queda pegado al final de ESTE valor // al cortar por índice. Lo sacamos junto con el trim final, sin tocar el // texto real de la guía. const value = text.slice(start, end).trim().replace(/[-•*]\s*$/, "").trim(); if (key) out[key] = value || null; } return out; } // Campos de reorientación ya separados (autoría YAML, o guardado con el front // nuevo) vs. legado (todo en si_incorrecto, posiblemente con prefijos pegados // a mano) — decide cuál rama aplicar. Devuelve los nombres que usa el ítem del // front (reorientCorrecto/reorientParcial/reorientIncorrecto), no los del // backend (si_correcto/si_parcial/si_incorrecto). function reorientFields(reor) { if (reor.si_correcto || reor.si_parcial) { return { reorientCorrecto: reor.si_correcto || "", reorientParcial: reor.si_parcial || "", reorientIncorrecto: reor.si_incorrecto || "", }; } const parsed = parseReorientLegacy(reor.si_incorrecto || ""); return { reorientCorrecto: parsed.si_correcto || "", reorientParcial: parsed.si_parcial || "", reorientIncorrecto: parsed.si_incorrecto || "", }; } // ---- request de bajo nivel ---- class ApiError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } } async function request(method, path, body) { const cfg = CFG(); const resp = await fetch(cfg.apiBase + path, { method, headers: { "Content-Type": "application/json", ...window.A8Auth.authHeader() }, body: body === undefined ? undefined : JSON.stringify(body), }); if (resp.status === 401) { // token vencido/ inválido → reautenticar window.A8Auth.logout(); throw new ApiError("unauthorized", "Sesión expirada", 401); } let data = null; const text = await resp.text(); if (text) { try { data = JSON.parse(text); } catch (e) { data = null; } } if (!resp.ok) { const err = (data && data.error) || {}; throw new ApiError(err.code || "server", err.message || ("HTTP " + resp.status), resp.status); } return data; } // ============ traducción backend → modelo del front ============ // Paso "propio" de la consola → ítem editable (texto o pregunta). // Pregunta de recall en 2 sabores (arq_07-adyacente, editor de C7): opcion_multiple // (con opciones + índice correcto) o abierta (con rúbrica, sin opciones — el tutor // evalúa la respuesta libre del alumno contra la rúbrica). function pasoToItem(p) { if (p.checkpoint_id) { const tipoPregunta = p.checkpoint_tipo === "abierta" ? "abierta" : "opcion_multiple"; const reor = p.checkpoint_reorientacion || {}; const base = { id: p.id, kind: "chk", tipoPregunta, question: p.checkpoint_enunciado, blocking: !!p.checkpoint_bloqueante, ...reorientFields(reor), _conceptId: p.checkpoint_concepto_id, }; if (tipoPregunta === "abierta") { base.rubrica = p.checkpoint_rubrica || ""; } else { const opts = p.checkpoint_opciones || []; base.options = opts; base.correct = Math.max(0, opts.indexOf(p.checkpoint_respuesta_referencia)); } return base; } if (p.media_tipo) { // C4 (arq_06): paso solo-media o media+texto. `s3Key`/`caption` viajan // tal cual al editor; `text` puede estar vacío (paso solo-media). // C4bis (arq_06bis): `textoComoCaption`/`esperaRespuesta`, default false // (paso sin flags = comportamiento de hoy). return { id: p.id, kind: "frag", type: p.media_tipo, title: p.media_caption || p.id, text: p.texto || "", s3Key: p.media_s3_key || "", caption: p.media_caption || "", textoComoCaption: !!p.texto_como_caption, esperaRespuesta: !!p.espera_respuesta, }; } return { id: p.id, kind: "frag", type: "texto", text: p.texto, esperaRespuesta: !!p.espera_respuesta }; } // ¿El paso vino de un curso IMPORTADO (backfill del formato original) o lo creó // la consola? La consola solo produce pasos con forma canónica: la pregunta es // `opcion_multiple` o `abierta` (C7-editor), autor "consola", su enunciado ES el // texto del paso, y nunca setea `clase`. Cualquier paso fuera de esa forma es de // un guion conversacional importado: su `texto` es material de enseñanza que NO // hay que descartar (ese descarte era el bug: un paso con checkpoint se mostraba // solo como cuestionario). function pasoIsImported(p) { if (p.clase !== undefined && p.clase !== null) return true; if (p.checkpoint_id) { if ( p.checkpoint_tipo && p.checkpoint_tipo !== "opcion_multiple" && p.checkpoint_tipo !== "abierta" ) return true; if (p.checkpoint_autor && p.checkpoint_autor !== "consola") return true; if ((p.texto || "") !== (p.checkpoint_enunciado || "")) return true; } return false; } // Una unidad es importada (solo lectura en v1) si trae entidades clásicas // (fragmento / item_recall sueltos) o cualquier paso con forma importada. function unitIsImported(u) { if ((u.fragmentos || []).length) return true; if ((u.items_recall || []).length) return true; return (u.pasos || []).some(pasoIsImported); } function readonlyFrag(f) { return { id: f.id, kind: "frag", type: "texto", text: f.texto, _readonly: true }; } function readonlyItem(it) { return { id: it.id, kind: "chk", question: it.enunciado, options: it.opciones || [], correct: Math.max(0, (it.opciones || []).indexOf(it.respuesta_referencia)), blocking: true, reorient: "", _readonly: true, }; } // Un paso importado se muestra FIEL a lo que recibe el alumno: su texto de // enseñanza como contenido de texto (visible) y, si lleva checkpoint, la // pregunta como cuestionario. Ambos en solo lectura. Un mismo paso puede rendir // dos ítems (texto + cuestionario). function importedPasoItems(p) { const out = []; if ((p.texto || "").trim()) { out.push({ id: p.id, kind: "frag", type: "texto", text: p.texto, _readonly: true }); } if (p.checkpoint_id) { const opts = p.checkpoint_opciones || []; out.push({ id: p.checkpoint_id, kind: "chk", question: p.checkpoint_enunciado || "", options: opts, correct: Math.max(0, opts.indexOf(p.checkpoint_respuesta_referencia)), blocking: !!p.checkpoint_bloqueante, reorient: "", _readonly: true, }); } return out; } function unitToFront(u) { if (unitIsImported(u)) { // Solo lectura: cada paso → texto (visible) + su pregunta; luego cualquier // fragmento / item_recall clásico suelto. Nada editable en v1. const items = []; (u.pasos || []) .slice() .sort((a, b) => (a.orden || 0) - (b.orden || 0)) .forEach((p) => items.push(...importedPasoItems(p))); (u.fragmentos || []) .slice() .sort((a, b) => (a.posicion || 0) - (b.posicion || 0)) .forEach((f) => items.push(readonlyFrag(f))); (u.items_recall || []).forEach((it) => items.push(readonlyItem(it))); return { id: u.id, title: u.titulo, posicion: u.posicion, items, readonly: true }; } // Unidad "propia" de la consola: pasos editables (texto o pregunta). const items = (u.pasos || []) .slice() .sort((a, b) => (a.orden || 0) - (b.orden || 0)) .map(pasoToItem); return { id: u.id, title: u.titulo, posicion: u.posicion, items, readonly: false }; } function courseToFront(cid, borrador) { const meta = borrador.curso || {}; const crono = borrador.cronograma || {}; const units = (borrador.unidades || []) .slice() .sort((a, b) => (a.posicion || 0) - (b.posicion || 0)) .map(unitToFront); const trayPasos = (borrador.bandeja && borrador.bandeja.paso) || []; const tray = { items: trayPasos.map(pasoToItem) }; return { id: cid, meta_draft: { name: meta.titulo || "", description: meta.descripcion || "", welcome: meta.bienvenida || "", // Media real adjunta a la bienvenida (video, etc. — uploader v2). welcomeMediaTipo: meta.bienvenida_media_tipo || "", welcomeMediaS3Key: meta.bienvenida_media_s3_key || "", welcomeMediaCaption: meta.bienvenida_media_caption || "", welcomeComoCaption: !!meta.bienvenida_como_caption, schedule: crono.ritmo_sugerido || "Entrega diaria de contenido", }, draft: { units, tray }, _loaded: true, }; } // ============ traducción front → cuerpo del backend ============ function itemToPasoBody(item, orden, unidadId) { if (item.kind === "chk") { const tipoPregunta = item.tipoPregunta || "opcion_multiple"; const checkpoint = { id: item.id, concepto_id: "k-" + item.id, enunciado: item.question || "", tipo: tipoPregunta, bloqueante: !!item.blocking, reorientacion: { si_correcto: item.reorientCorrecto ? item.reorientCorrecto.trim() || null : null, si_parcial: item.reorientParcial ? item.reorientParcial.trim() || null : null, si_incorrecto: (item.reorientIncorrecto || "").trim() || GENERIC_REORIENT, }, estado_ciclo_vida: "borrador", autor: "consola", }; if (tipoPregunta === "abierta") { checkpoint.rubrica = item.rubrica || "Evaluar según el criterio del tutor."; } else { const opts = item.options || []; const correct = opts[item.correct] !== undefined ? opts[item.correct] : (opts[0] || ""); checkpoint.opciones = opts; checkpoint.respuesta_referencia = correct; checkpoint.rubrica = "Respuesta correcta: " + correct; } return { orden, texto: item.question || "(pregunta)", unidad_id: unidadId, checkpoint, }; } // ítem de media (C4, arq_06): tipo/s3_key/caption estructurados; el texto // que lo acompaña (si hay) sigue viajando en `texto`. if (item.type && item.type !== "texto") { const body = { orden, texto: item.text || "", unidad_id: unidadId, texto_como_caption: !!item.textoComoCaption, espera_respuesta: !!item.esperaRespuesta, }; if (item.s3Key) { body.media = { tipo: item.type, s3_key: item.s3Key, caption: item.caption || null }; } return body; } // ítem de texto return { orden, texto: item.text || "", unidad_id: unidadId, espera_respuesta: !!item.esperaRespuesta, }; } // ============ API de alto nivel (una por action del front) ============ const enc = encodeURIComponent; async function listCourses() { return request("GET", "/cursos"); } async function getCourse(id, copia) { // copia opcional: "publicado" trae el snapshot que ven los alumnos (solo lectura). const qs = copia === "publicado" ? "?copia=publicado" : ""; const b = await request("GET", "/cursos/" + enc(id) + qs); return courseToFront(id, b); } async function createCourse(id, meta) { await request("POST", "/cursos", { id, titulo: meta.name || "Curso nuevo", descripcion: meta.description || "", estado: "borrador", bienvenida: meta.welcome || "", cronograma: { ritmo_sugerido: meta.schedule || "Entrega diaria de contenido", politica_repaso: { intervalos_dias: [1] }, }, }); } async function deleteCourse(id) { await request("DELETE", "/cursos/" + enc(id)); } async function updateMeta(id, meta) { const body = { titulo: meta.name, descripcion: meta.description, bienvenida: meta.welcome, }; if (meta.welcomeMediaS3Key) { body.bienvenida_media = { tipo: meta.welcomeMediaTipo, s3_key: meta.welcomeMediaS3Key, caption: meta.welcomeMediaCaption || null, }; body.bienvenida_como_caption = !!meta.welcomeComoCaption; } await request("PATCH", "/cursos/" + enc(id), body); } async function createUnit(id, unitId, title, posicion) { await request("POST", "/cursos/" + enc(id) + "/unidades", { id: unitId, curso_id: id, posicion, titulo: title, criterio_completitud: DEFAULT_CRITERIO, }); } async function renameUnit(id, unitId, title) { await request("PATCH", "/cursos/" + enc(id) + "/unidades/" + enc(unitId), { titulo: title }); } async function deleteUnit(id, unitId) { await request("DELETE", "/cursos/" + enc(id) + "/unidades/" + enc(unitId)); } async function reorderUnits(id, orderedIds) { const posiciones = {}; orderedIds.forEach((uid, i) => { posiciones[uid] = i + 1; }); await request("POST", "/cursos/" + enc(id) + "/unidades/reorder", { posiciones }); } // ítems: texto o pregunta → paso (+ concepto si es pregunta) async function saveItem(id, unitId, item, orden) { if (item.kind === "chk") { await request("PUT", "/cursos/" + enc(id) + "/items/concepto/k-" + enc(item.id), { id: "k-" + item.id, nombre: (item.question || "pregunta").slice(0, 80), unidad_id: unitId, }); } await request( "PUT", "/cursos/" + enc(id) + "/items/paso/" + enc(item.id), itemToPasoBody(item, orden, unitId) ); } async function deleteItem(id, itemId) { await request("DELETE", "/cursos/" + enc(id) + "/items/paso/" + enc(itemId)); } async function moveItemToUnit(id, itemId, unitId) { // unitId null = bandeja await request("POST", "/cursos/" + enc(id) + "/items/paso/" + enc(itemId) + "/move", { unidad_id: unitId, }); } // Mueve el concepto autogenerado de una pregunta junto con su paso, para que la // referencia checkpoint→concepto siga viviendo en la misma unidad (si no, la // publicación fallaría: el concepto quedaría en la bandeja / otra unidad). async function moveConcepto(id, itemId, unitId) { await request("POST", "/cursos/" + enc(id) + "/items/concepto/k-" + enc(itemId) + "/move", { unidad_id: unitId, }); } // reordenar pasos = re-PUT con nuevo orden (arq_05: el `orden` del paso es atributo) async function reorderItems(id, unitId, orderedItems) { for (let i = 0; i < orderedItems.length; i++) { const it = orderedItems[i]; if (it._readonly) continue; await request( "PUT", "/cursos/" + enc(id) + "/items/paso/" + enc(it.id), itemToPasoBody(it, i + 1, unitId) ); } } async function publish(id) { return request("POST", "/cursos/" + enc(id) + "/publish"); } async function estado(id) { const r = await request("GET", "/cursos/" + enc(id) + "/estado"); return r.estado; } async function diff(id) { return request("GET", "/cursos/" + enc(id) + "/diff"); } async function disclaimer(id) { return request("GET", "/cursos/" + enc(id) + "/disclaimer"); } async function getConocimiento(id) { return request("GET", "/cursos/" + enc(id) + "/conocimiento"); } // ---- Alumnos (gestión desde la consola) ---- async function listAlumnos(id) { return request("GET", "/cursos/" + enc(id) + "/alumnos"); } async function inscribirAlumno(id, phone) { return request("POST", "/cursos/" + enc(id) + "/alumnos", { phone }); } async function pausarAlumno(id, alumnoId) { await request("POST", "/cursos/" + enc(id) + "/alumnos/" + enc(alumnoId) + "/pausar"); } async function reactivarAlumno(id, alumnoId) { await request("POST", "/cursos/" + enc(id) + "/alumnos/" + enc(alumnoId) + "/reactivar"); } // ---- Duplicar curso ---- async function duplicarCurso(id, destino, titulo) { return request("POST", "/cursos/" + enc(id) + "/duplicar", { destino, titulo }); } // ---- Uploader real v2 (C4): presigned PUT directo a S3 ---- // 1) pedimos al backend una URL firmada (valida extensión + tamaño ANTES de // firmar, arq_06 uploader v2); 2) subimos el archivo directo a S3 con esa // URL. El backend nunca ve los bytes — pasan browser → S3 directo. async function getMediaUploadUrl(id, { filename, sizeBytes, unidadId }) { return request("POST", "/cursos/" + enc(id) + "/media-upload-url", { filename, size_bytes: sizeBytes, unidad_id: unidadId || null, }); } // XMLHttpRequest (no fetch) porque necesitamos el evento de progreso de subida // real para la barra de progreso — fetch no lo expone. function uploadFileToS3(uploadUrl, file, contentType, onProgress) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("PUT", uploadUrl); // Content-Type TIENE que ser exactamente el que se firmó (mismo valor que // devolvió getMediaUploadUrl) — S3 rechaza la firma si no coincide. xhr.setRequestHeader("Content-Type", contentType); xhr.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) resolve(); else reject(new Error("Error al subir el archivo (HTTP " + xhr.status + ")")); }; xhr.onerror = () => reject(new Error("Error de red al subir el archivo")); xhr.send(file); }); } // Estado de compresión de un video (C4bis compresor): sidecar que escribe // la Lambda de compresión, `{status: null|"procesando"|"listo"|"error", ...}`. async function getMediaStatus(id, s3Key) { return request("GET", "/cursos/" + enc(id) + "/media-status?s3_key=" + enc(s3Key)); } // Polling simple: consulta cada `intervalMs` hasta un estado terminal // (listo/error) o `status:null` (nunca hubo nada que comprimir — no-op del // guard <=16MB). `onUpdate` se llama en cada tick, incluida la primera. // Sin librerías: setTimeout recursivo, se corta solo al llegar a terminal // o al vencer `timeoutMs`. function pollMediaStatus(id, s3Key, onUpdate, { intervalMs = 4000, timeoutMs = 15 * 60 * 1000 } = {}) { const start = Date.now(); const tick = async () => { let st; try { st = await getMediaStatus(id, s3Key); } catch (e) { onUpdate({ status: "error", motivo: (e && e.message) || String(e) }); return; } onUpdate(st); if (!st || st.status === "listo" || st.status === "error" || !st.status) return; if (Date.now() - start < timeoutMs) setTimeout(tick, intervalMs); }; tick(); } return { ApiError, listCourses, getCourse, createCourse, deleteCourse, updateMeta, createUnit, renameUnit, deleteUnit, reorderUnits, saveItem, deleteItem, moveItemToUnit, moveConcepto, reorderItems, publish, estado, diff, disclaimer, getConocimiento, listAlumnos, inscribirAlumno, pausarAlumno, reactivarAlumno, duplicarCurso, getMediaUploadUrl, uploadFileToS3, getMediaStatus, pollMediaStatus, // Derivación de tipos, pura y sin I/O: se exporta para tests (Node). _derive: { pasoToItem, pasoIsImported, unitIsImported, importedPasoItems, unitToFront, courseToFront, itemToPasoBody, parseReorientLegacy, reorientFields, }, }; })(); window.A8Api = A8Api;