PageSourceSearch

https://boussole.up.railway.app/backoffice/session-admin-save.js?v=1b043de98383f93686e3814b69bbbb0272dad8d2

js boussole.up.railway.app collected 2026-10-03 12:11:41 UTC 8,479 bytes, 199 lines download raw bytes

1// =============================================================
2// SESSION ADMIN — PERSISTANCE (save handlers)
3// =============================================================
4//
5// Module extrait de session-admin.js le 2026-07-22 (2ème extraction).
6// Regroupe les 2 handlers de persistance qui sérialisent l'état de
7// l'éditeur admin et POSTent à l'API.
8//
9// Contenu :
10//   1. saveAdminSkills(sessionId) — collecte les compétences depuis
11//      #admin-skills-list .admin-skill-row et POSTe /api/bo/sessions/
12//      :id/skills. Affiche le résultat dans #admin-skills-msg.
13//   2. saveAdminQuestions(sessionId) — collecte les questions depuis
14//      #admin-questions-list .admin-question-row et POSTe /api/bo/
15//      sessions/:id/questions. Gère les questions conditionnelles et
16//      les valeurs des enums (radio/checkbox). Affiche le résultat
17//      dans #admin-questions-msg.
18//
19// Les 2 fonctions sont câblées depuis renderSessionAdmin (boutons
20// "Enregistrer" dans les onglets Compétences et Questions).
21//
22// Dépendances externes (globales JS déjà chargées) :
23//   - safeInt (core.js) — parsing d'entier avec fallback
24//   - fetch / setTimeout (browser standard)
25
26/**
27 * Collecte les compétences depuis l'éditeur admin et les envoie à l'API.
28 * @param {string} sessionId
29 */
30async function saveAdminSkills(sessionId) {
31  const rows = document.querySelectorAll("#admin-skills-list .admin-skill-row");
32  const skills = [];
33
34  rows.forEach((row, i) => {
35    const name = row.querySelector(".ask-name").value.trim();
36    if (!name) return;
37    skills.push({
38      name,
39      category: row.querySelector(".ask-category").value.trim() || undefined,
40      priorityWeight: safeInt(row.querySelector(".ask-weight").value, 5),
41      order: i,
42    });
43  });
44
45  const msgSpan = document.getElementById("skills-save-msg");
46  const res = await fetch(`/api/bo/sessions/${sessionId}/skills`, {
47    method: "POST",
48    headers: { "Content-Type": "application/json" },
49    credentials: "same-origin",
50    body: JSON.stringify(skills),
51  });
52
53  if (res.ok) {
54    msgSpan.textContent = `${skills.length} compétence(s) enregistrée(s)`;
55    msgSpan.style.color = "var(--color-success)";
56  } else {
57    const d = await res.json();
58    msgSpan.textContent = d.error || "Erreur";
59    msgSpan.style.color = "var(--color-error)";
60  }
61  setTimeout(() => { msgSpan.textContent = ""; }, 3000);
62}
63
64/**
65 * Collecte les questions depuis l'éditeur admin et les envoie à l'API.
66 * @param {string} sessionId
67 */
68async function saveAdminQuestions(sessionId) {
69  const rows = document.querySelectorAll("#admin-questions-list .admin-question-row");
70  const questions = [];
71
72  // Snapshot de toutes les rows présentes (incluant les vides) — utilisé
73  // pour résoudre l'index de la question parente d'une condition. On
74  // s'appuie sur cet ordre, pas sur l'index de la boucle filtrée
75  // (qui exclut les rows sans label).
76  const allRowsSnapshot = Array.from(rows);
77  const msgSpan = document.getElementById("questions-save-msg");
78
79  // --- Pré-validation des conditions d'affichage ---
80  //
81  // Avant ce contrôle, si le toggle "Cette question est conditionnelle"
82  // était coché mais que la parente ou les valeurs déclenchantes étaient
83  // vides, la condition était silencieusement ignorée à la sauvegarde.
84  // Conséquence : la question apparaissait toujours côté candidat alors
85  // que l'utilisateur pensait l'avoir conditionnée.
86  //
87  // On collecte ici toutes les conditions invalides et on bloque la
88  // sauvegarde avec un message clair + une bordure rouge sur la ligne
89  // fautive (réinitialisée au prochain save).
90  const condErrors = [];
91  rows.forEach((row, i) => {
92    row.style.borderLeft = ""; // reset visuel d'une éventuelle erreur précédente
93    const label = row.querySelector(".aq-label").value.trim() || "(sans titre)";
94    const condToggle = row.querySelector(".aq-cond-toggle");
95    if (!condToggle || !condToggle.checked) return;
96
97    const parentSelect = row.querySelector(".aq-cond-parent");
98    const parentId = parentSelect ? parentSelect.value : "";
99    const valueChecks = row.querySelectorAll(".aq-cond-value:checked");
100
101    if (!parentId) {
102      condErrors.push(`"${label}" : aucune question parente choisie`);
103      row.style.borderLeft = "3px solid var(--color-error)";
104      return;
105    }
106    if (valueChecks.length === 0) {
107      condErrors.push(`"${label}" : aucune valeur déclenchante cochée`);
108      row.style.borderLeft = "3px solid var(--color-error)";
109      return;
110    }
111
112    // Vérifier que la parente est bien AVANT cette question (sinon la
113    // condition sera silencieusement ignorée par le backend). Cas
114    // typique : l'utilisateur a déplacé la parente APRÈS la fille.
115    const parentRow = document.querySelector(`#admin-questions-list .admin-question-row[data-question-id="${parentId}"]`);
116    const parentIdx = allRowsSnapshot.indexOf(parentRow);
117    if (parentIdx < 0 || parentIdx >= i) {
118      condErrors.push(`"${label}" : la question parente doit être placée AVANT cette question (utilise les flèches ▲▼)`);
119      row.style.borderLeft = "3px solid var(--color-error)";
120    }
121  });
122
123  if (condErrors.length > 0) {
124    msgSpan.textContent = "Conditions invalides — " + condErrors.join(" ; ");
125    msgSpan.style.color = "var(--color-error)";
126    setTimeout(() => { msgSpan.textContent = ""; }, 8000);
127    return; // on ne sauvegarde pas tant que les conditions ne sont pas valides
128  }
129
130  // --- Construction du payload ---
131  rows.forEach((row, i) => {
132    const label = row.querySelector(".aq-label").value.trim();
133    if (!label) return;
134
135    const type = row.dataset.type;
136    const required = row.querySelector(".aq-required").checked;
137    const question = { label, type, required, order: i };
138
139    if (type === "radio" || type === "checkbox") {
140      const optionsText = row.querySelector(".aq-options").value.trim();
141      if (optionsText) {
142        question.options = optionsText.split("\n").map((o) => o.trim()).filter(Boolean);
143      }
144      // Bonus scoring : disponible uniquement pour radio (pas checkbox
145      // dans l'app actuelle, le scoring multi-valeur n'est pas géré)
146      if (type === "radio") {
147        const bonusEl = row.querySelector(".aq-bonus");
148        const bonusText = bonusEl ? bonusEl.value.trim() : "";
149        if (bonusText) {
150          try {
151            question.bonusRules = JSON.parse(bonusText);
152          } catch (e) {
153            bonusEl.style.borderColor = "var(--color-error)";
154            console.warn(`[Questions] Bonus JSON invalide pour "${label}":`, bonusText, e.message);
155          }
156        }
157      }
158    } else {
159      question.maxLength = safeInt(row.querySelector(".aq-maxlength").value, 1500);
160    }
161
162    // Condition d'affichage — la pré-validation ci-dessus garantit que
163    // si on entre ici, parentId / values / parentIdx sont tous valides.
164    const condToggle = row.querySelector(".aq-cond-toggle");
165    if (condToggle && condToggle.checked) {
166      const parentId = row.querySelector(".aq-cond-parent").value;
167      const values = Array.from(row.querySelectorAll(".aq-cond-value:checked")).map((c) => c.value);
168      const parentRow = document.querySelector(`#admin-questions-list .admin-question-row[data-question-id="${parentId}"]`);
169      const parentIdx = allRowsSnapshot.indexOf(parentRow);
170      question.conditionalOn = { questionIndex: parentIdx, values };
171    }
172
173    questions.push(question);
174  });
175
176  // Log pour debug — visible dans la console navigateur (F12)
177  console.log("[Questions] Payload envoyé:", JSON.stringify(questions, null, 2));
178
179  // msgSpan a déjà été récupéré au début de la fonction pour la
180  // pré-validation des conditions ; on le réutilise ici.
181  const res = await fetch(`/api/bo/sessions/${sessionId}/questions`, {
182    method: "POST",
183    headers: { "Content-Type": "application/json" },
184    credentials: "same-origin",
185    body: JSON.stringify(questions),
186  });
187
188  if (res.ok) {
189    msgSpan.textContent = `${questions.length} question(s) enregistrée(s)`;
190    msgSpan.style.color = "var(--color-success)";
191  } else {
192    const d = await res.json();
193    // Afficher le détail de l'erreur de validation si disponible
194    const details = d.details ? ` (${d.details.join(", ")})` : "";
195    msgSpan.textContent = (d.error || "Erreur") + details;
196    msgSpan.style.color = "var(--color-error)";
197  }
198  setTimeout(() => { msgSpan.textContent = ""; }, 5000);
199}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.