neobhodimo-sozdat-onlayn-say/server.js

406 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));