From 6dc54e49b8a9e1f998da685a6731e4b88efb6bc6 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 5 Sep 2026 18:31:28 +0300 Subject: [PATCH] batch add students: add optional group selection in batch modal --- .dockerignore | 4 + Dockerfile | 13 + db/init.sql | 45 ++++ db/migration.sql | 32 +++ docker-compose.yml | 39 +++ package.json | 15 ++ public/admin.css | 147 +++++++++++ public/admin.html | 10 + public/admin.js | 118 +++++++++ public/dashboard.html | 39 +++ public/groups.html | 134 ++++++++++ public/index.html | 230 +++++++++++++++++ public/journal.html | 383 ++++++++++++++++++++++++++++ public/links.html | 156 ++++++++++++ public/login.html | 52 ++++ public/settings.html | 107 ++++++++ public/share.html | 97 +++++++ public/students.html | 285 +++++++++++++++++++++ scripts/backup.sh | 22 ++ scripts/restore.sh | 37 +++ server.js | 570 ++++++++++++++++++++++++++++++++++++++++++ 21 files changed, 2535 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 db/init.sql create mode 100644 db/migration.sql create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 public/admin.css create mode 100644 public/admin.html create mode 100644 public/admin.js create mode 100644 public/dashboard.html create mode 100644 public/groups.html create mode 100644 public/index.html create mode 100644 public/journal.html create mode 100644 public/links.html create mode 100644 public/login.html create mode 100644 public/settings.html create mode 100644 public/share.html create mode 100644 public/students.html create mode 100755 scripts/backup.sh create mode 100755 scripts/restore.sh create mode 100644 server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..672f7ad --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +uploads +.git +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8da1e09 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine +ENV TZ=Europe/Moscow +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev +COPY . . +RUN mkdir -p uploads certs +RUN apk add --no-cache openssl && \ + openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \ + -keyout certs/key.pem -out certs/cert.pem \ + -subj "/CN=whatido.local" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" +EXPOSE 3000 3443 +CMD ["node", "server.js"] \ No newline at end of file diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..13dd7dd --- /dev/null +++ b/db/init.sql @@ -0,0 +1,45 @@ +CREATE TABLE IF NOT EXISTS groups ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS students ( + id SERIAL PRIMARY KEY, + name VARCHAR(150) NOT NULL UNIQUE, + group_id INT REFERENCES groups(id), + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS entries ( + id SERIAL PRIMARY KEY, + student_name VARCHAR(150) NOT NULL, + group_id INT NOT NULL REFERENCES groups(id), + description TEXT NOT NULL, + photo_path VARCHAR(255), + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); + +INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30') +ON CONFLICT (key) DO NOTHING; + +CREATE TABLE IF NOT EXISTS share_links ( + id SERIAL PRIMARY KEY, + token VARCHAR(40) UNIQUE NOT NULL, + name VARCHAR(200) NOT NULL, + group_id INT REFERENCES groups(id), + date_from DATE, + date_to DATE, + created_at TIMESTAMPTZ DEFAULT now() +); + +INSERT INTO groups (name) VALUES + ('Beginner'), + ('Intermediate'), + ('Advanced') +ON CONFLICT (name) DO NOTHING; diff --git a/db/migration.sql b/db/migration.sql new file mode 100644 index 0000000..8343c6e --- /dev/null +++ b/db/migration.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS students ( + id SERIAL PRIMARY KEY, + name VARCHAR(150) NOT NULL UNIQUE, + created_at TIMESTAMPTZ DEFAULT now() +); + +ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); + +INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30') +ON CONFLICT (key) DO NOTHING; + +CREATE TABLE IF NOT EXISTS share_links ( + id SERIAL PRIMARY KEY, + token VARCHAR(40) UNIQUE NOT NULL, + name VARCHAR(200) NOT NULL, + group_id INT REFERENCES groups(id), + date_from DATE, + date_to DATE, + created_at TIMESTAMPTZ DEFAULT now() +); + +DO $$ +BEGIN + INSERT INTO students (name) + SELECT DISTINCT student_name FROM entries WHERE student_name IS NOT NULL AND student_name <> '' + ON CONFLICT (name) DO NOTHING; +END $$; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..73dec6a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +name: whatido + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: whereldo + POSTGRES_USER: app + POSTGRES_PASSWORD: app + TZ: Europe/Moscow + volumes: + - pgdata:/var/lib/postgresql/data + - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d whereldo"] + interval: 2s + timeout: 3s + retries: 10 + + app: + build: . + ports: + - "3000:3000" + - "3443:3443" + environment: + DATABASE_URL: postgres://app:app@db:5432/whereldo + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + TZ: Europe/Moscow + depends_on: + db: + condition: service_healthy + volumes: + - uploads:/app/uploads + +volumes: + pgdata: + uploads: diff --git a/package.json b/package.json new file mode 100644 index 0000000..d41b4d2 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "whereldo", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.0", + "multer": "^1.4.5-lts.1", + "pg": "^8.13.0", + "tar": "^7.4.3" + } +} diff --git a/public/admin.css b/public/admin.css new file mode 100644 index 0000000..0e88e75 --- /dev/null +++ b/public/admin.css @@ -0,0 +1,147 @@ +*{margin:0;padding:0;box-sizing:border-box} +:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px;--sidebar:240px} +body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh} + +.layout{display:flex;min-height:100dvh} +.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column} +.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border)} +.sidebar nav{flex:1;padding:12px 0} +.sidebar nav a{display:flex;align-items:center;gap:10px;padding:10px 20px;color:var(--muted);text-decoration:none;font-size:.9rem;transition:all .15s;border-left:3px solid transparent} +.sidebar nav a:hover{color:var(--text);background:var(--bg)} +.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.06);border-left-color:var(--accent);font-weight:500} +.sidebar .bottom{padding:12px 20px;border-top:1px solid var(--border)} +.sidebar .bottom a{display:block;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0} +.sidebar .bottom a:hover{color:var(--text)} +.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px} + +.main{flex:1;margin-left:var(--sidebar);padding:32px} +.main h2{font-size:1.3rem;font-weight:600;margin-bottom:20px} + +.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:32px} +.stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px} +.stat .num{font-size:2rem;font-weight:700;color:var(--accent)} +.stat .label{color:var(--muted);font-size:.8rem;margin-top:4px} + +.filters{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end} +.filters select,.filters input[type=date]{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem} +.filters label{font-size:.8rem;color:var(--muted);display:block;margin-bottom:4px} +.search-input{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none} +.search-input:focus{border-color:var(--accent)} + +.pager{display:flex;gap:6px;align-items:center;justify-content:center;margin-top:20px;flex-wrap:wrap} +.pager button{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px 12px;font-size:.85rem;cursor:pointer} +.pager button:hover{border-color:var(--accent)} +.pager button.active{background:var(--accent);color:#fff;border-color:var(--accent)} +.pager button:disabled{opacity:.4;cursor:not-allowed} +.pager .info{color:var(--muted);font-size:.8rem} + +.grid{display:flex;flex-direction:column;gap:12px} +.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;display:flex;gap:16px;align-items:flex-start} +.card img{width:80px;height:80px;border-radius:8px;object-fit:cover;flex-shrink:0;cursor:pointer} +.card .info{flex:1;min-width:0} +.card .name{font-weight:600;font-size:.95rem} +.card .group{color:var(--accent);font-size:.8rem;margin-top:2px} +.card .desc{color:var(--muted);font-size:.85rem;margin-top:8px;line-height:1.4;white-space:pre-wrap} +.card .time{color:var(--muted);font-size:.75rem;margin-top:8px} +.card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px;flex-shrink:0} +.card .del:hover{color:#ef4444} +.card .edit{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px;flex-shrink:0} +.card .edit:hover{color:var(--accent)} +.empty{text-align:center;color:var(--muted);padding:48px 0} + +.view-toggle{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden} +.view-toggle button{background:var(--card);border:none;color:var(--muted);padding:9px 14px;font-size:.85rem;font-weight:600;cursor:pointer} +.view-toggle button:hover{color:var(--text)} +.view-toggle button.active{background:var(--accent);color:#fff} +.journal-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:16px} +.journal-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column} +.journal-card img{width:100%;aspect-ratio:4/3;object-fit:cover;cursor:pointer;display:block;flex-shrink:0} +.journal-card .no-photo{aspect-ratio:4/3;background:var(--bg);display:flex;align-items:center;justify-content:center;flex-shrink:0} +.journal-card .no-photo svg{width:64%;height:64%;color:#c7cbd0} +.journal-card .no-photo svg *{fill:currentColor} +.journal-card .body{padding:12px 14px;display:flex;flex-direction:column;gap:6px;flex:1;min-width:0} +.journal-card .name{font-weight:600;font-size:.95rem} +.journal-card .group{color:var(--accent);font-size:.8rem} +.journal-card .desc{color:var(--muted);font-size:.85rem;line-height:1.4;white-space:pre-wrap;flex:1} +.journal-card .foot{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--border);color:var(--muted);font-size:.75rem} +.journal-card .foot .edit,.journal-card .foot .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1rem;padding:4px} +.journal-card .foot .edit:hover{color:var(--accent)} +.journal-card .foot .del:hover{color:#ef4444} +@media(max-width:1200px){.journal-cards{grid-template-columns:repeat(3,1fr)}} +@media(max-width:900px){.journal-cards{grid-template-columns:repeat(2,1fr)}} +@media(max-width:640px){.journal-cards{grid-template-columns:1fr}} + +.add-row{display:flex;gap:8px;margin-bottom:24px} +.add-row input{flex:1;background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:var(--radius);padding:12px 16px;font-size:.95rem;outline:none} +.add-row input:focus{border-color:var(--accent)} +.add-row button{background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:12px 20px;font-weight:600;cursor:pointer} +.add-row .btn-ghost{background:var(--card);border:1px solid var(--border);color:var(--accent)} +.add-row .btn-ghost:hover{border-color:var(--accent)} +.btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer;transition:opacity .15s} +.btn-link:hover{border-color:var(--accent);opacity:.85} +.list{display:flex;flex-direction:column;gap:8px} +.item{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;display:flex;justify-content:space-between;align-items:center;gap:8px} +.item .name{font-weight:500} +.item .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.2rem;padding:4px} +.item .del:hover{color:#ef4444} +.item .name-input{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.95rem;font-weight:500;outline:none;width:100%;max-width:300px} +.item .name-input:focus{border-color:var(--accent)} +.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px} +.item .group-select:focus{border-color:var(--accent)} +.attach-list{max-height:280px;overflow-y:auto;display:flex;flex-direction:column;gap:4px;border:1px solid var(--border);border-radius:8px;padding:8px} +.attach-list label{display:flex;gap:8px;align-items:center;font-size:.85rem;color:var(--text);margin:0;cursor:pointer} +.attach-list .cur{color:var(--muted);font-size:.75rem;margin-left:auto} +.student-actions{display:flex;gap:6px;align-items:center;flex-shrink:0} +.student-actions button{border-radius:8px;padding:8px 14px;font-size:.8rem;font-weight:600;cursor:pointer;border:none;transition:opacity .15s} +.student-actions button:hover{opacity:.85} +.student-actions .btn-save{background:var(--accent);color:#fff} +.student-actions .btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent)} +.student-actions .btn-link:hover{border-color:var(--accent)} +.student-actions .btn-del{background:none;color:var(--muted);font-size:1.2rem;padding:4px 8px} +.student-actions .btn-del:hover{color:#ef4444;opacity:1} + +.group-schedule{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:8px} +.group-schedule select,.group-schedule input[type=time]{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem} +.group-schedule label{font-size:.75rem;color:var(--muted)} +.group-schedule .sched-status{font-size:.75rem;color:var(--muted)} +.group-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px} +.group-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;display:flex;flex-direction:column;gap:12px} +.group-card .group-head{display:flex;justify-content:space-between;align-items:center;gap:8px} +.group-card .name-input{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 10px;font-size:.95rem;font-weight:600;outline:none;width:100%} +.group-card .name-input:focus{border-color:var(--accent)} +.group-card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.2rem;padding:4px;flex-shrink:0} +.group-card .del:hover{color:#ef4444} +.group-card .group-schedule{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;align-items:start} +.group-card .group-schedule label{font-size:.75rem;color:var(--muted);display:block;margin-bottom:4px} +.group-card .group-schedule select,.group-card .group-schedule input[type=time]{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem} +.group-card .group-foot{display:flex;justify-content:space-between;align-items:center;gap:8px} +.group-card .save-sched{background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer} +.group-card .save-sched:hover{opacity:.85} +.group-card .btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer} +.group-card .btn-link:hover{border-color:var(--accent)} +.settings-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;max-width:640px} +.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap} +.settings-row label{font-weight:600;display:block;margin-bottom:4px} +.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0} +.settings-card .btn-link{background:var(--accent);color:#fff;border:none;padding:10px 20px;font-size:.9rem} +.settings-card .btn-link:hover{opacity:.85;border-color:transparent} +.group-card .sched-status{font-size:.8rem;color:var(--muted)} +@media(max-width:1024px){.group-grid{grid-template-columns:repeat(2,1fr)}} +@media(max-width:640px){.group-grid{grid-template-columns:1fr}} + +.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center} +.modal-overlay.open{display:flex} +.modal-overlay img{max-width:90%;max-height:90dvh;border-radius:var(--radius)} +.edit-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;max-width:420px;width:90%;display:flex;flex-direction:column;gap:16px} +.edit-modal h3{font-size:1.1rem;font-weight:600} +.edit-modal label{font-size:.8rem;color:var(--muted);margin-bottom:4px} +.edit-modal input,.edit-modal select,.edit-modal textarea{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:10px 12px;font-size:.9rem;outline:none;font-family:inherit} +.edit-modal input:focus,.edit-modal select:focus,.edit-modal textarea:focus{border-color:var(--accent)} +.edit-modal textarea{min-height:100px;resize:vertical} +.edit-modal .actions{display:flex;gap:8px} +.edit-modal .actions button{flex:1;padding:12px;border:none;border-radius:8px;font-size:.9rem;font-weight:600;cursor:pointer} +.edit-modal .save{background:var(--accent);color:#fff} +.edit-modal .cancel{background:var(--border);color:var(--text)} +.edit-modal .actions button:hover{opacity:.85} +.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200} +.toast.show{opacity:1} \ No newline at end of file diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..b8320aa --- /dev/null +++ b/public/admin.html @@ -0,0 +1,10 @@ + + + + +Админ-панель + + + + + \ No newline at end of file diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..a884dc9 --- /dev/null +++ b/public/admin.js @@ -0,0 +1,118 @@ +const API = location.origin; +let token = sessionStorage.getItem('adminToken'); +function hdr() { return { 'X-Admin-Token': token }; } +function hdrJson() { return { 'Content-Type': 'application/json', 'X-Admin-Token': token }; } +function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } + +// --- Auth check --- +async function checkAuth() { + if (!token) { location.href = 'login.html'; return false; } + try { + const res = await fetch(`${API}/api/stats`, { headers: hdr() }); + if (!res.ok) { sessionStorage.removeItem('adminToken'); location.href = 'login.html'; return false; } + return true; + } catch { sessionStorage.removeItem('adminToken'); location.href = 'login.html'; return false; } +} + +function doLogout() { + sessionStorage.removeItem('adminToken'); + location.href = 'login.html'; +} + +// --- Sidebar --- +const NAV = [ + { page: 'dashboard', label: 'Дашборд' }, + { page: 'journal', label: 'Журнал' }, + { page: 'links', label: 'Ссылки' }, + { page: 'students', label: 'Ученики' }, + { page: 'groups', label: 'Группы' }, + { page: 'settings', label: 'Настройки' } +]; +function buildSidebar(active) { + const el = document.getElementById('sidebar'); + if (!el) return; + el.innerHTML = ` + + +
+ На главную + Выйти +
`; +} + +// --- Common UI --- +function showToast(msg) { + const t = document.getElementById('toast'); + t.textContent = msg; + t.classList.add('show'); + setTimeout(() => t.classList.remove('show'), 3000); +} + +function showImg(src) { + const img = document.getElementById('imgModalSrc'); + const m = document.getElementById('imgModal'); + if (img && m) { img.src = src; m.classList.add('open'); } +} + +async function fillAutocomplete() { + const res = await fetch(`${API}/api/students`); + const students = await res.json(); + const names = students.map(s => s.name); + const dlEdit = document.getElementById('studentEditList'); + if (dlEdit) { + dlEdit.innerHTML = ''; + names.forEach(n => { const o = document.createElement('option'); o.value = n; dlEdit.appendChild(o); }); + } + const dlAdd = document.getElementById('studentAddList'); + if (dlAdd) { + dlAdd.innerHTML = ''; + names.forEach(n => { const o = document.createElement('option'); o.value = n; dlAdd.appendChild(o); }); + } +} + +function copyStudentLink(name) { + const url = `${location.origin}/?nameInput=${encodeURIComponent(name)}`; + const done = () => showToast('Ссылка скопирована: ' + url); + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(url).then(done).catch(() => fallbackCopy(url, done)); + } else { + fallbackCopy(url, done); + } +} + +function fallbackCopy(text, done) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); done(); } catch { prompt('Скопируйте вручную:', text); } + document.body.removeChild(ta); +} + +function copyText(text) { + const done = () => showToast('Скопировано: ' + text); + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done)); + } else { + fallbackCopy(text, done); + } +} + +function renderPager(el, page, totalPages, onPage) { + if (!el) return; + if (totalPages <= 1) { el.innerHTML = ''; return; } + let html = ``; + for (let i = 1; i <= totalPages; i++) { + if (totalPages > 9 && i > 2 && i < totalPages - 1 && Math.abs(i - page) > 2) { + if (i === 3) html += ``; + continue; + } + html += ``; + } + html += ``; + el.innerHTML = html; +} \ No newline at end of file diff --git a/public/dashboard.html b/public/dashboard.html new file mode 100644 index 0000000..fc8550e --- /dev/null +++ b/public/dashboard.html @@ -0,0 +1,39 @@ + + + + + +Дашборд — Админ-панель + + + +
+ +
+

Дашборд

+
+
+
+
+ + + + \ No newline at end of file diff --git a/public/groups.html b/public/groups.html new file mode 100644 index 0000000..073d553 --- /dev/null +++ b/public/groups.html @@ -0,0 +1,134 @@ + + + + + +Группы — Админ-панель + + + +
+ +
+

Группы

+
+ + +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..9f8f916 --- /dev/null +++ b/public/index.html @@ -0,0 +1,230 @@ + + + + + +Что мы узнали на занятии + + + + +

Что мы узнали на занятии

+

Отметь, что ты делал(а) на занятии

+ +
+
+ +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ + + +
Готово!
+ + + + diff --git a/public/journal.html b/public/journal.html new file mode 100644 index 0000000..388d63a --- /dev/null +++ b/public/journal.html @@ -0,0 +1,383 @@ + + + + + +Журнал — Админ-панель + + + +
+ +
+

Журнал

+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
Загрузка...
+
+
+
+ + + + + + + +
+ + + + \ No newline at end of file diff --git a/public/links.html b/public/links.html new file mode 100644 index 0000000..b11af46 --- /dev/null +++ b/public/links.html @@ -0,0 +1,156 @@ + + + + + +Ссылки — Админ-панель + + + +
+ +
+

Ссылки

+

Каждая ссылка открывает публичную страницу с карточками записей выбранной группы за указанный период — без входа и выбора параметров.

+ +
+
+ + + +
+ + + + \ No newline at end of file diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..d7baa9b --- /dev/null +++ b/public/login.html @@ -0,0 +1,52 @@ + + + + + +Вход — Админ-панель + + + +
+

Админ-панель

+ + +
Неверный пароль
+ +
+ + + diff --git a/public/settings.html b/public/settings.html new file mode 100644 index 0000000..f56f0e7 --- /dev/null +++ b/public/settings.html @@ -0,0 +1,107 @@ + + + + + +Настройки — Админ-панель + + + +
+ +
+

Настройки

+
+
+
+ +

Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение этого времени. 0 — отключить.

+
+ +
+
+ +
+
+ +
+

Резервная копия

+

Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.

+
+ + + +
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/public/share.html b/public/share.html new file mode 100644 index 0000000..e5d6ba3 --- /dev/null +++ b/public/share.html @@ -0,0 +1,97 @@ + + + + + + +Записи занятий + + + +
+

Загрузка...

+
+
+
+
Карточки загружаются...
+
+ + + + + \ No newline at end of file diff --git a/public/students.html b/public/students.html new file mode 100644 index 0000000..bf9cc1f --- /dev/null +++ b/public/students.html @@ -0,0 +1,285 @@ + + + + + +Ученики — Админ-панель + + + +
+ +
+

Ученики

+
+ + + + + +
+
+ + + +
+
+
+
+
+ + + + + +
+ + + + \ No newline at end of file diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..4a9ca2e --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Создаёт архив переноса: БД (dump) + фотографии (uploads). +# Использование: ./scripts/backup.sh +set -euo pipefail +cd "$(dirname "$0")/.." + +STAMP=$(date +%Y%m%d-%H%M%S) +mkdir -p backups + +echo ">> Экспорт БД" +docker compose exec -T db pg_dump -U app -d whereldo | gzip > backups/db.sql.gz + +echo ">> Копирование фотографий" +rm -rf backups/_uploads +mkdir -p backups/_uploads +docker cp "whatido-app-1:/app/uploads/." "backups/_uploads/" + +echo ">> Сборка архива" +tar -czf "backups/whatido-backup-$STAMP.tar.gz" -C backups db.sql.gz _uploads +rm -rf backups/_uploads backups/db.sql.gz + +echo "Готово: backups/whatido-backup-$STAMP.tar.gz" \ No newline at end of file diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..dbf4983 --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Восстанавливает данные из архива переноса на новом устройстве. +# Порядок: скопировать папку проекта + архив, затем ./scripts/restore.sh <архив> +set -euo pipefail +cd "$(dirname "$0")/.." + +BK="${1:?Использование: $0 }" +[ -f "$BK" ] || { echo "Файл не найден: $BK"; exit 1; } + +echo ">> Сборка и запуск стека" +docker compose up -d --build + +echo ">> Ожидание БД" +for _ in $(seq 1 60); do + docker compose exec -T db pg_isready -U app -d whereldo >/dev/null 2>&1 && break + sleep 1 +done + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +echo ">> Распаковка архива" +tar -xzf "$BK" -C "$TMP" + +echo ">> Остановка приложения" +docker compose stop app + +echo ">> Восстановление БД (чистая схема)" +docker compose exec -T db psql -U app -d whereldo -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null +gzip -dc "$TMP/db.sql.gz" | docker compose exec -T db psql -U app -d whereldo + +echo ">> Восстановление фотографий" +docker cp "$TMP/_uploads/." "whatido-app-1:/app/uploads/" + +echo ">> Запуск приложения" +docker compose start app + +echo "Готово. Проверка: https://localhost:3443 (админка /dashboard.html, пароль из .env)" \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..23634c6 --- /dev/null +++ b/server.js @@ -0,0 +1,570 @@ +const express = require('express'); +const { Pool, types } = require('pg'); +const multer = require('multer'); +const cors = require('cors'); +const https = require('https'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); + +types.setTypeParser(1082, v => v); + +const app = express(); +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin'; + +app.use(cors()); +app.use(express.json()); +app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); +app.use(express.static(path.join(__dirname, 'public'))); + +function requireAdmin(req, res, next) { + const token = req.headers['x-admin-token']; + if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' }); + next(); +} + +const upload = multer({ + storage: multer.diskStorage({ + destination: (_, __, cb) => { + fs.mkdirSync('uploads', { recursive: true }); + cb(null, 'uploads'); + }, + filename: (_, file, cb) => { + const ext = path.extname(file.originalname) || '.jpg'; + cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`); + }, + }), + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (_, file, cb) => { + if (file.mimetype.startsWith('image/')) cb(null, true); + else cb(new Error('Only images')); + }, +}); + +async function getSetting(key, def) { + const { rows } = await pool.query('SELECT value FROM settings WHERE key = $1', [key]); + return rows.length ? rows[0].value : def; +} + +function removeUpload(file) { + if (!file) return; + const fp = path.join(__dirname, file.path); + if (fs.existsSync(fp)) fs.unlinkSync(fp); +} + +// --- Settings --- +app.get('/api/settings', requireAdmin, async (_, res) => { + const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key'); + const out = {}; + rows.forEach(r => { out[r.key] = r.value; }); + res.json(out); +}); + +app.put('/api/settings', requireAdmin, async (req, res) => { + const { settings } = req.body; + if (!settings || typeof settings !== 'object') return res.status(400).json({ error: 'settings required' }); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + for (const [key, value] of Object.entries(settings)) { + await client.query( + `INSERT INTO settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [key, String(value ?? '')] + ); + } + await client.query('COMMIT'); + const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key'); + const out = {}; + rows.forEach(r => { out[r.key] = r.value; }); + res.json(out); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +}); + +// --- Backup / Restore --- +const gunzipAsync = require('util').promisify(require('zlib').gunzip); +const tar = require('tar'); +const os = require('os'); +const uploadBackup = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 300 * 1024 * 1024 }, +}); + +const SAFE_NAME = /^[\w,.()-]+$/; + +app.get('/api/backup', requireAdmin, async (_, res) => { + const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); + try { + const [g, s, e, st] = await Promise.all([ + pool.query('SELECT * FROM groups ORDER BY id'), + pool.query('SELECT * FROM students ORDER BY id'), + pool.query('SELECT * FROM entries ORDER BY id'), + pool.query('SELECT key, value FROM settings'), + ]); + const settings = {}; + st.rows.forEach(r => { settings[r.key] = r.value; }); + const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings }; + fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload)); + fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true }); + const dir = path.join(__dirname, 'uploads'); + if (fs.existsSync(dir)) { + for (const f of fs.readdirSync(dir)) { + const fp = path.join(dir, f); + if (fs.statSync(fp).isFile() && SAFE_NAME.test(f)) fs.copyFileSync(fp, path.join(staging, 'uploads', f)); + } + } + const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-'); + const outPath = path.join(os.tmpdir(), `whatido-backup-${stamp}.tar.gz`); + await tar.c({ gzip: true, file: outPath, cwd: staging }, ['data.json', 'uploads']); + const buf = fs.readFileSync(outPath); + fs.unlinkSync(outPath); + res.setHeader('Content-Type', 'application/gzip'); + res.setHeader('Content-Disposition', `attachment; filename="whatido-backup-${stamp}.tar.gz"`); + res.send(buf); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + } +}); + +app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'backup file required' }); + let data; + let legacyPhotos = []; + const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-rst-')); + try { + const gunz = await gunzipAsync(req.file.buffer).catch(() => null); + if (gunz && gunz[0] === 0x7b) { + data = JSON.parse(gunz.toString('utf8')); + legacyPhotos = data.photos || []; + } else { + const inPath = path.join(staging, 'input.tar.gz'); + fs.writeFileSync(inPath, req.file.buffer); + await tar.x({ file: inPath, cwd: staging }); + data = JSON.parse(fs.readFileSync(path.join(staging, 'data.json'), 'utf8')); + } + } catch { + fs.rmSync(staging, { recursive: true, force: true }); + return res.status(400).json({ error: 'Неверный файл бэкапа' }); + } + if (!data || data.version !== 1 || !Array.isArray(data.groups)) { + fs.rmSync(staging, { recursive: true, force: true }); + return res.status(400).json({ error: 'Неверный формат бэкапа' }); + } + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query('DELETE FROM entries'); + await client.query('DELETE FROM students'); + await client.query('DELETE FROM groups'); + for (const x of data.groups) { + await client.query( + 'INSERT INTO groups (id, name, created_at, day_of_week, time_start, time_end) VALUES ($1,$2,$3,$4,$5,$6)', + [x.id, x.name, x.created_at, x.day_of_week ?? null, x.time_start ?? null, x.time_end ?? null] + ); + } + for (const x of data.students) { + await client.query( + 'INSERT INTO students (id, name, created_at, group_id) VALUES ($1,$2,$3,$4)', + [x.id, x.name, x.created_at, x.group_id ?? null] + ); + } + for (const x of data.entries) { + await client.query( + 'INSERT INTO entries (id, student_name, group_id, description, photo_path, created_at) VALUES ($1,$2,$3,$4,$5,$6)', + [x.id, x.student_name, x.group_id, x.description, x.photo_path ?? null, x.created_at] + ); + } + for (const [k, v] of Object.entries(data.settings || {})) { + await client.query( + 'INSERT INTO settings (key, value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value', + [k, String(v ?? '')] + ); + } + for (const tbl of ['groups', 'students', 'entries']) { + const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl); + await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]); + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + fs.rmSync(staging, { recursive: true, force: true }); + throw e; + } finally { + client.release(); + } + const dir = path.join(__dirname, 'uploads'); + fs.mkdirSync(dir, { recursive: true }); + if (legacyPhotos.length) { + for (const p of legacyPhotos) { + if (!p.path || !SAFE_NAME.test(p.path)) continue; + fs.writeFileSync(path.join(dir, p.path), Buffer.from(p.data, 'base64')); + } + } else { + const src = path.join(staging, 'uploads'); + if (fs.existsSync(src)) { + for (const f of fs.readdirSync(src)) { + if (!SAFE_NAME.test(f)) continue; + const fp = path.join(src, f); + if (fs.statSync(fp).isFile()) fs.copyFileSync(fp, path.join(dir, f)); + } + } + } + fs.rmSync(staging, { recursive: true, force: true }); + res.json({ ok: true }); +}); + +// --- Share links --- +function normDates(o) { + if (o && o.date_from) o.date_from = o.date_from instanceof Date ? o.date_from.toISOString().slice(0, 10) : String(o.date_from).slice(0, 10); + if (o && o.date_to) o.date_to = o.date_to instanceof Date ? o.date_to.toISOString().slice(0, 10) : String(o.date_to).slice(0, 10); + return o; +} +app.get('/api/links', requireAdmin, async (_, res) => { + const { rows } = await pool.query( + `SELECT l.*, g.name AS group_name FROM share_links l + LEFT JOIN groups g ON g.id = l.group_id ORDER BY l.created_at DESC` + ); + rows.forEach(normDates); + res.json(rows); +}); + +app.post('/api/links', requireAdmin, async (req, res) => { + const { name, group_id, date_from, date_to } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' }); + const token = crypto.randomBytes(20).toString('hex'); + const { rows } = await pool.query( + `INSERT INTO share_links (token, name, group_id, date_from, date_to) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [token, name.trim(), group_id || null, date_from || null, date_to || null] + ); + res.status(201).json(normDates(rows[0])); +}); + +app.put('/api/links/:id', requireAdmin, async (req, res) => { + const { name, group_id, date_from, date_to } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' }); + const { rows } = await pool.query( + `UPDATE share_links SET + name = $1, group_id = $2, date_from = $3, date_to = $4 + WHERE id = $5 RETURNING *`, + [name.trim(), group_id || null, date_from || null, date_to || null, req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); + res.json(normDates(rows[0])); +}); + +app.delete('/api/links/:id', requireAdmin, async (req, res) => { + await pool.query('DELETE FROM share_links WHERE id = $1', [req.params.id]); + res.json({ ok: true }); +}); + +app.get('/api/share/:token', async (req, res) => { + const { rows } = await pool.query( + `SELECT l.*, g.name AS group_name FROM share_links l + LEFT JOIN groups g ON g.id = l.group_id WHERE l.token = $1`, + [req.params.token] + ); + if (!rows.length) return res.status(404).json({ error: 'Ссылка не найдена' }); + normDates(rows[0]); + const l = rows[0]; + const conditions = []; + const params = []; + if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); } + if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); } + if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); } + const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''; + const { rows: entries } = await pool.query( + `SELECT e.*, g.name AS group_name FROM entries e + JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`, + params + ); + res.json({ + name: l.name, + group_name: l.group_name, + group_id: l.group_id, + date_from: l.date_from, + date_to: l.date_to, + created_at: l.created_at, + entries, + }); +}); + +app.get('/s/:token', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'share.html')); +}); + +// --- Groups CRUD --- +app.get('/api/groups', async (_, res) => { + const { rows } = await pool.query('SELECT * FROM groups ORDER BY id'); + res.json(rows); +}); + +app.get('/api/groups/active', async (_, res) => { + const { rows } = await pool.query(` + SELECT * FROM groups + WHERE day_of_week IS NOT NULL + AND time_start IS NOT NULL + AND time_end IS NOT NULL + AND day_of_week = EXTRACT(DOW FROM (now() AT TIME ZONE 'Europe/Moscow'))::int + AND (now() AT TIME ZONE 'Europe/Moscow')::time BETWEEN time_start AND time_end + ORDER BY id + `); + res.json(rows); +}); + +app.put('/api/groups/:id', requireAdmin, async (req, res) => { + const { name, day_of_week, time_start, time_end } = req.body; + try { + const { rows } = await pool.query( + `UPDATE groups SET + name = COALESCE($1, name), + day_of_week = $2, + time_start = $3, + time_end = $4 + WHERE id = $5 RETURNING *`, + [name, + day_of_week === undefined || day_of_week === null || day_of_week === '' ? null : day_of_week, + time_start || null, time_end || null, req.params.id] + ); + res.json(rows[0]); + } catch (e) { + if (e.code === '23505') return res.status(409).json({ error: 'Duplicate name' }); + throw e; + } +}); + +app.post('/api/groups', requireAdmin, async (req, res) => { + const { name } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Name required' }); + try { + const { rows } = await pool.query( + 'INSERT INTO groups (name) VALUES ($1) RETURNING *', + [name.trim()] + ); + res.status(201).json(rows[0]); + } catch (e) { + if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' }); + throw e; + } +}); + +app.delete('/api/groups/:id', requireAdmin, async (req, res) => { + await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]); + res.json({ ok: true }); +}); + +// --- Students CRUD --- +app.get('/api/students', async (_, res) => { + const { rows } = await pool.query( + `SELECT s.*, g.name AS group_name FROM students s + LEFT JOIN groups g ON g.id = s.group_id ORDER BY s.name` + ); + res.json(rows); +}); + +app.get('/api/students/names', requireAdmin, async (_, res) => { + const { rows } = await pool.query( + 'SELECT DISTINCT student_name AS name FROM entries WHERE student_name IS NOT NULL AND student_name <> \'\' ORDER BY student_name' + ); + res.json(rows.map(r => r.name)); +}); + +app.post('/api/students', requireAdmin, async (req, res) => { + const { name, group_id } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Name required' }); + const gid = group_id ? Number(group_id) : null; + try { + const { rows } = await pool.query( + 'INSERT INTO students (name, group_id) VALUES ($1, $2) RETURNING *', + [name.trim(), gid] + ); + res.status(201).json(rows[0]); + } catch (e) { + if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' }); + throw e; + } +}); + +app.put('/api/students/:id', requireAdmin, async (req, res) => { + const { name, group_id } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Name required' }); + try { + const { rows } = await pool.query( + `UPDATE students SET + name = $1, + group_id = $2 + WHERE id = $3 RETURNING *`, + [name.trim(), + group_id === undefined || group_id === null || group_id === '' ? null : group_id, + req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(rows[0]); + } catch (e) { + if (e.code === '23505') return res.status(409).json({ error: 'Duplicate' }); + throw e; + } +}); + +app.post('/api/students/batch-group', requireAdmin, async (req, res) => { + const { group_id, student_ids } = req.body; + if (!group_id || !Array.isArray(student_ids) || !student_ids.length) { + return res.status(400).json({ error: 'group_id and student_ids required' }); + } + const ids = [...new Set(student_ids.map(Number).filter(Boolean))]; + if (!ids.length) return res.status(400).json({ error: 'No valid students' }); + const params = [group_id, ...ids]; + const placeholders = ids.map((_, i) => `$${i + 2}`).join(','); + const { rows } = await pool.query( + `UPDATE students SET group_id = $1 WHERE id IN (${placeholders}) RETURNING id`, + params + ); + res.json({ ok: true, updated: rows.length }); +}); + +app.delete('/api/students/:id', requireAdmin, async (req, res) => { + await pool.query('DELETE FROM students WHERE id = $1', [req.params.id]); + res.json({ ok: true }); +}); + +// --- Entries --- +app.get('/api/entries', requireAdmin, async (req, res) => { + const { group_id, date_from, date_to, student_name, search, limit, offset } = req.query; + const conditions = []; + const params = []; + if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); } + if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); } + if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); } + if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); } + if (search) { params.push(`%${search}%`); conditions.push(`(e.student_name ILIKE $${params.length} OR e.description ILIKE $${params.length})`); } + const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''; + const { rows: crows } = await pool.query( + `SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${where}`, + params + ); + const total = crows[0].n; + let q = `SELECT e.*, g.name AS group_name FROM entries e + JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`; + const qparams = params.slice(); + const lim = parseInt(limit, 10); + if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; } + const off = parseInt(offset, 10); + if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; } + const { rows } = await pool.query(q, qparams); + res.json({ entries: rows, total }); +}); + +app.get('/api/stats', requireAdmin, async (_, res) => { + const [entries, groups, students, today] = await Promise.all([ + pool.query('SELECT count(*)::int AS n FROM entries'), + pool.query('SELECT count(*)::int AS n FROM groups'), + pool.query('SELECT count(DISTINCT student_name)::int AS n FROM entries'), + pool.query("SELECT count(*)::int AS n FROM entries WHERE created_at >= now()::date"), + ]); + res.json({ + entries: entries.rows[0].n, + groups: groups.rows[0].n, + students: students.rows[0].n, + today: today.rows[0].n, + }); +}); + +app.post('/api/entries', upload.single('photo'), async (req, res) => { + const { student_name, group_id, description } = req.body; + if (!student_name?.trim() || !group_id || !description?.trim()) { + removeUpload(req.file); + return res.status(400).json({ error: 'All fields required' }); + } + const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0; + if (intervalMin > 0) { + const dup = await pool.query( + 'SELECT count(*)::int AS n FROM entries WHERE student_name = $1 AND created_at >= now() - ($2 || \' minutes\')::interval', + [student_name.trim(), intervalMin] + ); + if (dup.rows[0].n > 0) { + removeUpload(req.file); + return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` }); + } + } + const photo_path = req.file ? `/uploads/${req.file.filename}` : null; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query( + 'INSERT INTO students (name) VALUES ($1) ON CONFLICT (name) DO NOTHING', + [student_name.trim()] + ); + const { rows } = await client.query( + `INSERT INTO entries (student_name, group_id, description, photo_path) + VALUES ($1, $2, $3, $4) RETURNING *`, + [student_name.trim(), group_id, description.trim(), photo_path] + ); + await client.query('COMMIT'); + res.status(201).json(rows[0]); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +}); + +app.put('/api/entries/:id', requireAdmin, async (req, res) => { + const { student_name, group_id, description } = req.body; + const { rows } = await pool.query( + `UPDATE entries SET + student_name = COALESCE($1, student_name), + group_id = COALESCE($2, group_id), + description = COALESCE($3, description) + WHERE id = $4 RETURNING *`, + [ + student_name ? student_name.trim() : null, + group_id || null, + description ? description.trim() : null, + req.params.id, + ] + ); + if (!rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(rows[0]); +}); + +app.delete('/api/entries/:id', requireAdmin, async (req, res) => { + const { rows } = await pool.query( + 'DELETE FROM entries WHERE id = $1 RETURNING photo_path', + [req.params.id] + ); + if (rows[0]?.photo_path) { + const fp = path.join(__dirname, rows[0].photo_path); + if (fs.existsSync(fp)) fs.unlinkSync(fp); + } + res.json({ ok: true }); +}); + +const PORT = process.env.PORT || 3000; +const HTTPS_PORT = process.env.HTTPS_PORT || 3443; + +const certPath = path.join(__dirname, 'certs', 'cert.pem'); +const keyPath = path.join(__dirname, 'certs', 'key.pem'); + +const redirect = express(); +redirect.use((req, res) => { + res.redirect(301, `https://${req.headers.host.split(':')[0]}:${HTTPS_PORT}${req.originalUrl}`); +}); +redirect.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT} (redirect → HTTPS)`)); + +if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { + https.createServer({ key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) }, app) + .listen(HTTPS_PORT, '0.0.0.0', () => console.log(`HTTPS : ${HTTPS_PORT}`)); +} else { + console.log('HTTPS: no certs found, skipping'); +}