const http = require("http"); const fs = require("fs"); const path = require("path"); const PORT = process.env.PORT || 3000; const DATA = path.join(__dirname, "data.json"); const SEED_QUESTIONS = [ { id: "q1", category: "Тарифы", text: "При каком условии тариф «Комфорт» считается активным для абонента?", options: [ "С момента подачи заявки на подключение", "С момента подписания договора", "С даты, указанной в договоре как дата вступления в силу" ], correct: 2, explanation: "Тариф активен с даты вступления договора в силу, указанной в самом договоре. Заявка и подписание — этапы до активации." }, { id: "q2", category: "Контракты", text: "Кто несёт ответственность за надлежащее исполнение обязательств по договору с абонентом?", options: [ "Абонент со стороны подключения", "Оператор связи", "Дилер, продавший услугу" ], correct: 1, explanation: "Ответственность перед абонентом несёт оператор, заключивший договор. Дилер — лишь канал продажи, обязательства остаются за оператором." }, { id: "q3", category: "Правила", text: "В течение какого срока с момента обращения абонент должен получить ответ на жалобу?", options: ["3 рабочих дня", "10 рабочих дней", "30 календарных дней"], correct: 1, explanation: "Согласно правилам обслуживания, ответ на жалобу готовится не позднее 10 рабочих дней." }, { id: "q4", category: "Услуги", text: "Какой документ подтверждает факт заключения дополнительных услуг, оказанных на дому у абонента?", options: [ "Электронное письмо отдела сбыта", "Акт оказания услуг с подписью абонента", "Скриншот переписки в мессенджере" ], correct: 1, explanation: "Факт предоставления услуги подтверждается актом с подписью абонента. Бумажный или электронный след без подписи не является подтверждением." }, { id: "q5", category: "Тарифы", text: "Что происходит со сверхлимитным трафиком в тарифном плане с фиксированным пакетом?", options: [ "Услуга блокируется до конца месяца", "Трафик оплачивается по тарифу сверхлимитной услуги", "Трафик просто не передаётся, абонент ничего не платит" ], correct: 1, explanation: "Сверх пакета трафик тарифицируется по ставке сверхлимитной услуги, если абонент не подключил блокировку после исчерпания." }, { id: "q6", category: "Контракты", text: "Какое действие не требуется для перехода абонента на новый договор при продлении?", options: [ "Новая заявка на продление", "Подписание нового договора", "Присвоение нового номера абонента" ], correct: 2, explanation: "При продлении договора номер абонента сохраняется. Новые заявка и договор — да, новый номер — не требуется." }, { id: "q7", category: "Правила", text: "Кто вправе требовать от абонента документы, подтверждающие личность, при личном визите в офис?", options: [ "Только бухгалтерия", "Любой сотрудник, принявший обращение", "Только руководитель подразделения" ], correct: 1, explanation: "Документ о личности запрашивает сотрудник, непосредственно принимающий обращение, чтобы идентифицировать заявителя." }, { id: "q8", category: "Услуги", text: "Какая минимальная запись должна быть в акте при передаче оборудования абоненту?", options: [ "Серийные номера переданного оборудования", "Табель о прибытии", "Фото оборудования" ], correct: 0, explanation: "Акт должен содержать серийные номера переданного оборудования — это то, что привязывает технику именно к абоненту." } ]; function defaultData() { return { questions: SEED_QUESTIONS, attempts: [], curators: [] }; } function load() { try { const d = JSON.parse(fs.readFileSync(DATA, "utf8")); if (!Array.isArray(d.questions)) d.questions = SEED_QUESTIONS; if (!Array.isArray(d.attempts)) d.attempts = []; if (!Array.isArray(d.curators)) d.curators = []; return d; } catch (_) { return defaultData(); } } function save(d) { fs.writeFileSync(DATA, JSON.stringify(d, null, 2)); } function send(res, code, obj) { res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(obj)); } function readBody(req) { return new Promise((resolve) => { let s = ""; req.on("data", (c) => (s += c)); req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); }); } function normalizePersonName(v) { return String(v || "").replace(/\s+/g, " ").trim(); } const server = http.createServer(async (req, res) => { const url = req.url.split("?")[0]; // ── API ────────────────────────────────────────────────────────── if (req.method === "GET" && url === "/api/questions") { const d = load(); const safe = d.questions.map((q) => ({ id: q.id, category: q.category, text: q.text, options: q.options })); return send(res, 200, { questions: safe }); } if (req.method === "GET" && url === "/api/categories") { const d = load(); const cats = {}; d.questions.forEach((q) => { if (q.category && q.text && q.options && q.options.length >= 2) cats[q.category] = (cats[q.category] || 0) + 1; }); return send(res, 200, { categories: Object.keys(cats).sort() }); } if (req.method === "POST" && url === "/api/attempts") { const b = await readBody(req); const fio = normalizePersonName(b.fio); const dept = String(b.dept || "").replace(/\s+/g, " ").trim(); const choices = b.choices && typeof b.choices === "object" ? b.choices : {}; // { qid: idx } const tryNo = Math.max(1, parseInt(b.tryNo, 10) || 1); const d = load(); if (!fio || !dept) return send(res, 400, { ok: false, error: "Укажите ФИО и подразделение" }); if (!d.questions.length) return send(res, 400, { ok: false, error: "Вопросы ещё не добавлены" }); const wrong = []; const correctCount = { ok: 0, total: 0 }; d.questions.forEach((q) => { const chosen = typeof choices[q.id] === "number" ? choices[q.id] : -1; if (typeof q.correct === "number" && q.correct >= 0) { correctCount.total += 1; if (chosen === q.correct) correctCount.ok += 1; else wrong.push({ qid: q.id, question: q.text, options: q.options, correct: q.correct, chosen, explanation: q.explanation || "" }); } }); const percent = correctCount.total ? Math.round((correctCount.ok / correctCount.total) * 100) : 0; const passed = percent >= 70; // Попытки: 1-я всегда доступна. 2-я — только если 1-я не пройдена (70%). const prior = d.attempts.filter((a) => a.fio === fio && a.dept === dept); if (tryNo === 2 && prior.some((a) => a.passed)) { return send(res, 400, { ok: false, error: "У вас уже есть сданный результат — повторное прохождение не требуется" }); } // Если эту попытку уже заполняли — перезаписываем её (лучший результат остаётся). const prevSame = d.attempts.find((a) => a.fio === fio && a.dept === dept && a.tryNo === tryNo); if (prevSame) d.attempts = d.attempts.filter((a) => a !== prevSame); const rec = { id: "at" + Date.now(), fio, dept, tryNo, percent, correct: correctCount.ok, total: correctCount.total, passed, date: new Date().toISOString(), wrong }; d.attempts.push(rec); save(d); return send(res, 201, { ok: true, percent, passed, tryNo, wrongCount: wrong.length, id: rec.id }); } if (req.method === "GET" && url.startsWith("/api/stat")) { const d = load(); const byPerson = {}; d.attempts.forEach((a) => { const key = a.fio + "‖" + a.dept; if (!byPerson[key]) byPerson[key] = { fio: a.fio, dept: a.dept, count: 0, best: -1, lastPct: 0, lastDate: "", lastTry: 1, passed: 0 }; const p = byPerson[key]; p.count += 1; if (a.percent > p.best) p.best = a.percent; if (a.percent >= 70) p.passed += 1; if (new Date(a.date) > new Date(p.lastDate)) { p.lastPct = a.percent; p.lastDate = a.date; p.lastTry = a.tryNo; } }); const people = Object.keys(byPerson).map((k) => byPerson[k]).sort((a, b) => b.best - a.best); const byCat = {}; d.attempts.forEach((a) => { (a.wrong || []).forEach((w) => { const q = d.questions.find((x) => x.id === w.qid); if (!q) return; if (!byCat[q.category]) byCat[q.category] = { misses: 0, attempts: 0 }; byCat[q.category].misses += 1; byCat[q.category].attempts += 1; }); }); d.questions.forEach((q) => { if (!byCat[q.category]) byCat[q.category] = { misses: 0, attempts: 0 }; }); return send(res, 200, { people, totalAttempts: d.attempts.length, totalPassed: d.attempts.filter((a) => a.passed).length, byCategory: byCat, questionCount: d.questions.length }); } if (req.method === "GET" && url.startsWith("/api/person/")) { const key = url.replace("/api/person/", "").split("‖"); const fio = key[0] || ""; const dept = key[1] || ""; const d = load(); const attempts = d.attempts .filter((a) => a.fio === fio && a.dept === dept) .sort((a, b) => new Date(b.date) - new Date(a.date)); return send(res, 200, { attempts, questions: d.questions }); } // Рецензенты (curators) if (req.method === "GET" && url === "/api/curators") { const d = load(); return send(res, 200, { curators: d.curators }); } if (req.method === "POST" && url === "/api/curators") { const b = await readBody(req); const fio = normalizePersonName(b.fio); const code = String(b.code || "").trim().toLowerCase(); if (!fio || !code) return send(res, 400, { ok: false, error: "Дайте ФИО и код" }); const d = load(); d.curators = d.curators.filter((c) => c.code !== code); d.curators.push({ fio, code }); save(d); return send(res, 200, { ok: true }); } if (req.method === "POST" && url === "/api/curators/check") { const b = await readBody(req); const fio = normalizePersonName(b.fio); const code = String(b.code || "").trim().toLowerCase(); const d = load(); const ok = d.curators.some((c) => c.fio === fio && c.code === code); return send(res, 200, { ok }); } if (req.method === "DELETE" && url === "/api/curators") { const b = await readBody(req); const fio = normalizePersonName(b.fio); const code = String(b.code || "").trim().toLowerCase(); const d = load(); d.curators = d.curators.filter((c) => !(c.fio === fio && c.code === code)); save(d); return send(res, 200, { ok: true }); } // Управление вопросами (куратор) if (req.method === "GET" && url === "/api/admin/questions") { const d = load(); return send(res, 200, { questions: d.questions }); } if (req.method === "POST" && url === "/api/admin/questions") { const b = await readBody(req); const text = String(b.text || "").trim(); const category = String(b.category || "").trim() || "Тарифы"; const options = Array.isArray(b.options) ? b.options.map((o) => String(o).trim()).filter(Boolean) : []; const correct = parseInt(b.correct, 10) || 0; const explanation = String(b.explanation || "").trim(); if (!text || options.length < 2 || correct < 0 || correct >= options.length) { return send(res, 400, { ok: false, error: "Нужен вопрос, не менее 2 вариантов и выбранный правильный ответ" }); } const d = load(); const q = { id: "q" + Date.now(), category, text, options, correct, explanation }; d.questions.push(q); save(d); return send(res, 201, { ok: true, q }); } if (req.method === "POST" && url.match(/^\/api\/admin\/questions\/.+$/)) { const id = url.split("/").pop(); const b = await readBody(req); const d = load(); const q = d.questions.find((x) => x.id === id); if (!q) return send(res, 404, { ok: false, error: "Вопрос не найден" }); if (b.text) q.text = String(b.text).trim(); if (b.category) q.category = String(b.category).trim(); if (Array.isArray(b.options) && b.options.length >= 2) { q.options = b.options.map((o) => String(o).trim()).filter(Boolean); if (typeof b.correct === "number") q.correct = Math.min(b.correct, q.options.length - 1); } if (b.explanation) q.explanation = String(b.explanation).trim(); save(d); return send(res, 200, { ok: true, q }); } if (req.method === "DELETE" && url.match(/^\/api\/admin\/questions\/.+$/)) { const id = url.split("/").pop(); const d = load(); const before = d.questions.length; d.questions = d.questions.filter((q) => q.id !== id); save(d); return send(res, 200, { ok: true, deleted: before - d.questions.length }); } // ── Статика ────────────────────────────────────────────────────── if (url === "/" || url === "/index.html") { return serveFile(path.join(__dirname, "index.html"), "text/html; charset=utf-8"); } return serveStatic(req, res); function serveFile(p, type) { if (fs.existsSync(p) && fs.statSync(p).isFile()) { res.writeHead(200, { "Content-Type": type }); return res.end(fs.readFileSync(p)); } res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not found: " + p); } function serveStatic(req, res) { const p = req.url.split("?")[0]; const full = path.join(__dirname, p); if (p.startsWith("/api/")) { res.writeHead(404, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ ok: false, error: "No route" })); } if (!full.startsWith(__dirname)) { res.writeHead(403); return res.end("Forbidden"); } if (fs.existsSync(full) && fs.statSync(full).isFile()) { const ext = path.extname(full); const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : ext === ".json" ? "application/json" : ext === ".png" ? "image/png" : ext === ".svg" ? "image/svg+xml" : "text/html"; return serveFile(full, type + "; charset=utf-8"); } // SPA-fallback return serveFile(path.join(__dirname, "index.html"), "text/html; charset=utf-8"); } }); server.listen(PORT, () => console.log("Сервер на порту " + PORT));