feat(api): внешний API и API-ключи для интеграций
Отдельный префикс /api/v1 со своей авторификацией по API-ключам,
чтобы внешние системы могли забирать и менять данные, не получая
доступа к админке.
Что добавлено:
- таблица api_keys (db/init.sql, db/migration.sql, ensureApiKeysTable)
- CRUD ключей: GET/POST /api/api-keys, PUT/DELETE /:id, POST /:id/rotate
- requireApiKey: X-Api-Key или Authorization: Bearer, только для /api/v1/*
- 21 эндпоинт /api/v1: branches, groups, students, modules, entries,
lesson-reports, stats, me; списки в формате {items,total,limit,offset}
- страница управления ключами public/apikeys.html + пункт в меню
Безопасность:
- в БД только sha256(ключ) и префикс, секрет отдаётся один раз
- скоупы read/write: без write мутации дают 403
- branch_ids ключа сужают права и понижают роль до tutor
- per-key rate limit на cache.rateLimitStore, подбор ключей -> бан IP
- аудит мутаций с меткой via_api_key
- ключи не входят в бэкап и удаляются при restore
Проверено: api-keys.selftest.js (45 проверок), api.smoketest.js без
регрессий, работа без Redis через in-memory fallback.
This commit is contained in:
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const { Pool, types } = require('pg');
|
||||
const multer = require('multer');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { ipKeyGenerator } = require('express-rate-limit');
|
||||
const helmet = require('helmet');
|
||||
const bcrypt = require('bcrypt');
|
||||
const heicConvert = require('heic-convert');
|
||||
@@ -119,6 +120,7 @@ function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cach
|
||||
function invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
||||
function invalidateLessonReports() { cacheDrop('lessons:'); cacheDrop('stats:'); cacheDrop('dashboard:'); }
|
||||
function invalidateSessions() { cacheDrop('session:'); }
|
||||
function invalidateApiKeys() { cacheDrop('apikey:'); cacheDrop('rl:apikey'); }
|
||||
|
||||
const EVENTS_CHANNEL = 'whatido:events';
|
||||
const AI_WAKE_CHANNEL = 'whatido:wake:ai';
|
||||
@@ -460,6 +462,7 @@ const BAN_REASON_LABELS = {
|
||||
honeypot: 'Антиспам-поле',
|
||||
'login-bruteforce': 'Подбор пароля входа',
|
||||
'share-password-bruteforce': 'Подбор пароля ссылки',
|
||||
'apikey-bruteforce': 'Подбор API-ключа',
|
||||
manual: 'Вручную',
|
||||
};
|
||||
|
||||
@@ -966,6 +969,150 @@ async function optionalAuth(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
// --- API keys (external access) ---
|
||||
const API_KEY_PREFIX = 'wsk';
|
||||
const API_KEY_SCOPES = { read: 'Чтение данных', write: 'Изменение данных' };
|
||||
const API_KEY_TOUCH_MS = 5 * 60 * 1000;
|
||||
const API_KEY_DEFAULT_RPM = 120;
|
||||
const API_KEY_MAX_RPM = 10000;
|
||||
const API_KEY_MAX_NAME = 150;
|
||||
|
||||
const apiKeyLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: (req) => {
|
||||
const rpm = req.apiKey && req.apiKey.rpm;
|
||||
return Number.isInteger(rpm) && rpm > 0 ? Math.min(rpm, API_KEY_MAX_RPM) : API_KEY_DEFAULT_RPM;
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: cache.rateLimitStore('apikey', 60 * 1000),
|
||||
keyGenerator: (req) => (req.apiKey ? 'k' + req.apiKey.id : 'ip' + ipKeyGenerator(ipOf(req))),
|
||||
message: { error: 'Превышен лимит запросов для API-ключа' },
|
||||
});
|
||||
|
||||
function hashApiKey(raw) {
|
||||
return crypto.createHash('sha256').update(String(raw), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function apiKeyFromRequest(req) {
|
||||
const header = req.headers['x-api-key'];
|
||||
if (typeof header === 'string' && header.trim()) return header.trim();
|
||||
const auth = req.headers.authorization;
|
||||
if (typeof auth === 'string') {
|
||||
const m = auth.match(/^Bearer\s+(\S+)$/i);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeApiScopes(v) {
|
||||
const list = Array.isArray(v) ? v : (v === undefined || v === null ? [] : [v]);
|
||||
const out = [...new Set(list.map(x => String(x).trim().toLowerCase()).filter(x => Object.prototype.hasOwnProperty.call(API_KEY_SCOPES, x)))];
|
||||
return out.length ? out : ['read'];
|
||||
}
|
||||
|
||||
function apiKeyPublic(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
prefix: row.prefix,
|
||||
scopes: row.scopes || ['read'],
|
||||
branch_ids: row.branch_ids || [],
|
||||
rate_limit_per_min: row.rate_limit_per_min,
|
||||
created_at: row.created_at,
|
||||
last_used_at: row.last_used_at,
|
||||
last_used_ip: row.last_used_ip,
|
||||
expires_at: row.expires_at,
|
||||
revoked_at: row.revoked_at,
|
||||
user_id: row.user_id,
|
||||
username: row.username || undefined,
|
||||
user_name: row.user_name || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function apiKeyUser(row) {
|
||||
const owner = {
|
||||
id: row.user_id,
|
||||
username: row.username,
|
||||
name: row.user_name,
|
||||
role: row.role,
|
||||
is_active: true,
|
||||
branch_ids: row.owner_branch_ids || [],
|
||||
};
|
||||
const limit = (row.branch_ids || []).map(Number).filter(Boolean);
|
||||
if (!limit.length) return owner;
|
||||
const ownerScope = branchScope(owner);
|
||||
const allowed = ownerScope.admin ? limit : limit.filter(id => ownerScope.ids.includes(id));
|
||||
return { ...owner, role: 'tutor', branch_ids: allowed };
|
||||
}
|
||||
|
||||
async function loadApiKey(raw) {
|
||||
if (!raw || raw.length < 32 || raw.length > 200) return null;
|
||||
const key = 'apikey:' + hashApiKey(raw);
|
||||
const cached = await cache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT k.id, k.user_id, k.name, k.prefix, k.scopes, k.branch_ids, k.rate_limit_per_min,
|
||||
k.expires_at, k.revoked_at,
|
||||
u.username, u.name AS user_name, u.role, u.is_active,
|
||||
COALESCE(array_agg(ub.branch_id) FILTER (WHERE ub.branch_id IS NOT NULL), '{}') AS owner_branch_ids
|
||||
FROM api_keys k
|
||||
JOIN users u ON u.id = k.user_id
|
||||
LEFT JOIN user_branches ub ON ub.user_id = u.id
|
||||
WHERE k.key_hash = $1
|
||||
GROUP BY k.id, u.id`,
|
||||
[hashApiKey(raw)]
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
const row = rows[0];
|
||||
if (row.revoked_at || !row.is_active) return null;
|
||||
if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) return null;
|
||||
const value = { id: row.id, name: row.name, scopes: row.scopes || ['read'], user: apiKeyUser(row), rpm: row.rate_limit_per_min };
|
||||
await cache.set(key, value, SESSION_CACHE_TTL_MS);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function touchApiKey(id, ip) {
|
||||
try {
|
||||
const marker = 'apikey:touch:' + id;
|
||||
const last = await cache.get(marker);
|
||||
if (last !== undefined && Date.now() - Number(last) < API_KEY_TOUCH_MS) return;
|
||||
await cache.set(marker, Date.now(), API_KEY_TOUCH_MS);
|
||||
await pool.query('UPDATE api_keys SET last_used_at = now(), last_used_ip = $1 WHERE id = $2', [ip, id]);
|
||||
} catch (e) {
|
||||
console.error('api key touch:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function requireApiKey(scope) {
|
||||
return async (req, res, next) => {
|
||||
let apiKey = null;
|
||||
try {
|
||||
const raw = apiKeyFromRequest(req);
|
||||
apiKey = await loadApiKey(raw);
|
||||
if (!apiKey) {
|
||||
if (raw) await recordFailure(req, 'apikey-bruteforce', 30, BAN_TTL_MS);
|
||||
return res.status(401).json({ error: 'Invalid or expired API key' });
|
||||
}
|
||||
if (scope && !apiKey.scopes.includes(scope)) {
|
||||
return res.status(403).json({ error: `API key lacks scope: ${scope}` });
|
||||
}
|
||||
req.apiKey = apiKey;
|
||||
req.user = apiKey.user;
|
||||
touchApiKey(apiKey.id, ipOf(req));
|
||||
} catch (e) {
|
||||
console.error('requireApiKey:', e);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function apiAudit(req, action, target) {
|
||||
const merged = { ...(target || {}), via_api_key: req.apiKey ? req.apiKey.id : null };
|
||||
return logAudit(req, action, merged);
|
||||
}
|
||||
|
||||
function branchWhere(user, alias) {
|
||||
const s = branchScope(user);
|
||||
if (s.admin) return { where: '', params: [] };
|
||||
@@ -1435,6 +1582,27 @@ async function ensureUserTables() {
|
||||
await pool.query('ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL');
|
||||
}
|
||||
|
||||
async function ensureApiKeysTable() {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
prefix VARCHAR(16) NOT NULL,
|
||||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
scopes TEXT[] NOT NULL DEFAULT ARRAY['read']::TEXT[],
|
||||
branch_ids INT[] NOT NULL DEFAULT ARRAY[]::INT[],
|
||||
rate_limit_per_min INT,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
last_used_ip VARCHAR(45),
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
)`);
|
||||
await pool.query('CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash)');
|
||||
await pool.query('CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id)');
|
||||
await pool.query('CREATE INDEX IF NOT EXISTS idx_api_keys_revoked_at ON api_keys(revoked_at)');
|
||||
}
|
||||
|
||||
async function ensureFirstAdmin() {
|
||||
if (!ADMIN_PASSWORD) return;
|
||||
const { rows } = await pool.query('SELECT id FROM users WHERE role = \'admin\' LIMIT 1');
|
||||
@@ -1451,6 +1619,7 @@ async function ensureFirstAdmin() {
|
||||
|
||||
async function ensureUsersAndFirstAdmin() {
|
||||
await ensureUserTables();
|
||||
await ensureApiKeysTable();
|
||||
await ensureFirstAdmin();
|
||||
}
|
||||
|
||||
@@ -1775,6 +1944,7 @@ app.put('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
invalidateSessions();
|
||||
invalidateApiKeys();
|
||||
await logAudit(req, 'user.update', { id, role: newRole, is_active: isActive });
|
||||
const fresh = await pool.query(
|
||||
`SELECT u.id, u.username, u.name, u.role, u.is_active, u.created_at,
|
||||
@@ -1798,10 +1968,144 @@ app.delete('/api/users/:id', requireAuth, requireAdmin, async (req, res) => {
|
||||
}
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||
invalidateSessions();
|
||||
invalidateApiKeys();
|
||||
await logAudit(req, 'user.delete', { id: req.params.id });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- API keys (admin) ---
|
||||
function apiKeyOwnerScope(req) {
|
||||
if (req.user.role === 'admin') return { where: '', params: [] };
|
||||
return { where: ' AND k.user_id = $1', params: [req.user.id] };
|
||||
}
|
||||
|
||||
async function apiKeyAllowedBranches(user, value) {
|
||||
const ids = [...new Set((Array.isArray(value) ? value : []).map(Number).filter(Boolean))];
|
||||
if (!ids.length) return [];
|
||||
const s = branchScope(user);
|
||||
const allowed = s.admin ? ids : ids.filter(id => s.ids.includes(id));
|
||||
if (allowed.length !== ids.length) return null;
|
||||
const { rows } = await pool.query('SELECT id FROM branches WHERE id = ANY($1::int[])', [allowed]);
|
||||
return rows.length === allowed.length ? allowed : null;
|
||||
}
|
||||
|
||||
function apiKeyExpiry(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return undefined;
|
||||
return d;
|
||||
}
|
||||
|
||||
function apiKeyRateValue(value) {
|
||||
if (value === null || value === undefined || value === '') return { ok: true, value: null };
|
||||
const n = Number(value);
|
||||
if (!Number.isInteger(n) || n < 1 || n > API_KEY_MAX_RPM) return { ok: false, value: null };
|
||||
return { ok: true, value: n };
|
||||
}
|
||||
|
||||
app.get('/api/api-keys/meta', requireAdmin, async (_, res) => {
|
||||
res.json({ scopes: API_KEY_SCOPES, default_rpm: API_KEY_DEFAULT_RPM });
|
||||
});
|
||||
|
||||
app.get('/api/api-keys', requireAuth, requireAdmin, async (req, res) => {
|
||||
const scope = apiKeyOwnerScope(req);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT k.*, u.username, u.name AS user_name FROM api_keys k
|
||||
JOIN users u ON u.id = k.user_id
|
||||
WHERE 1=1${scope.where}
|
||||
ORDER BY k.id DESC`,
|
||||
scope.params
|
||||
);
|
||||
res.json(rows.map(apiKeyPublic));
|
||||
});
|
||||
|
||||
app.post('/api/api-keys', requireAuth, requireAdmin, async (req, res) => {
|
||||
const name = reqStr(req.body?.name, API_KEY_MAX_NAME);
|
||||
const scopes = normalizeApiScopes(req.body?.scopes);
|
||||
const expiresAt = apiKeyExpiry(req.body?.expires_at);
|
||||
if (expiresAt === undefined) return res.status(400).json({ error: 'Некорректная дата окончания' });
|
||||
const branchIds = await apiKeyAllowedBranches(req.user, req.body?.branch_ids);
|
||||
if (!branchIds) return res.status(400).json({ error: 'Недопустимый список филиалов' });
|
||||
const rate = apiKeyRateValue(req.body?.rate_limit_per_min);
|
||||
if (!rate.ok) return res.status(400).json({ error: 'Некорректный лимит запросов' });
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
const raw = `${API_KEY_PREFIX}_${secret}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO api_keys (user_id, name, prefix, key_hash, scopes, branch_ids, rate_limit_per_min, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[req.user.id, name, raw.slice(0, 12), hashApiKey(raw), scopes, branchIds, rate.value, expiresAt ? expiresAt.toISOString() : null]
|
||||
);
|
||||
invalidateApiKeys();
|
||||
await logAudit(req, 'api_key.create', { id: rows[0].id, name, scopes, branch_ids: branchIds, expires_at: rows[0].expires_at });
|
||||
res.status(201).json({ ...apiKeyPublic(rows[0]), key: raw });
|
||||
});
|
||||
|
||||
app.put('/api/api-keys/:id', requireAuth, requireAdmin, async (req, res) => {
|
||||
const current = await pool.query('SELECT * FROM api_keys WHERE id = $1', [req.params.id]);
|
||||
if (!current.rows.length) return res.status(404).json({ error: 'Ключ не найден' });
|
||||
if (req.user.role !== 'admin' && current.rows[0].user_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const target = current.rows[0];
|
||||
const name = req.body?.name !== undefined ? reqStr(req.body.name, API_KEY_MAX_NAME) : target.name;
|
||||
const scopes = req.body?.scopes !== undefined ? normalizeApiScopes(req.body.scopes) : target.scopes;
|
||||
let branchIds = target.branch_ids || [];
|
||||
if (req.body?.branch_ids !== undefined) {
|
||||
const allowed = await apiKeyAllowedBranches(req.user, req.body.branch_ids);
|
||||
if (!allowed) return res.status(400).json({ error: 'Недопустимый список филиалов' });
|
||||
branchIds = allowed;
|
||||
}
|
||||
let expiresAt = target.expires_at;
|
||||
if (req.body?.expires_at !== undefined) {
|
||||
const parsed = apiKeyExpiry(req.body.expires_at);
|
||||
if (parsed === undefined) return res.status(400).json({ error: 'Некорректная дата окончания' });
|
||||
expiresAt = parsed ? parsed.toISOString() : null;
|
||||
}
|
||||
let rateValue = target.rate_limit_per_min;
|
||||
if (req.body?.rate_limit_per_min !== undefined) {
|
||||
const rate = apiKeyRateValue(req.body.rate_limit_per_min);
|
||||
if (!rate.ok) return res.status(400).json({ error: 'Некорректный лимит запросов' });
|
||||
rateValue = rate.value;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE api_keys SET name = $1, scopes = $2, branch_ids = $3, rate_limit_per_min = $4, expires_at = $5
|
||||
WHERE id = $6 RETURNING *`,
|
||||
[name, scopes, branchIds, rateValue, expiresAt, req.params.id]
|
||||
);
|
||||
invalidateApiKeys();
|
||||
await logAudit(req, 'api_key.update', { id: rows[0].id, name, scopes, branch_ids: branchIds, expires_at: expiresAt });
|
||||
res.json(apiKeyPublic(rows[0]));
|
||||
});
|
||||
|
||||
app.delete('/api/api-keys/:id', requireAuth, requireAdmin, async (req, res) => {
|
||||
const current = await pool.query('SELECT * FROM api_keys WHERE id = $1', [req.params.id]);
|
||||
if (!current.rows.length) return res.status(404).json({ error: 'Ключ не найден' });
|
||||
if (req.user.role !== 'admin' && current.rows[0].user_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
await pool.query('DELETE FROM api_keys WHERE id = $1', [req.params.id]);
|
||||
invalidateApiKeys();
|
||||
await logAudit(req, 'api_key.delete', { id: req.params.id, name: current.rows[0].name });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/api-keys/:id/rotate', requireAuth, requireAdmin, async (req, res) => {
|
||||
const current = await pool.query('SELECT * FROM api_keys WHERE id = $1', [req.params.id]);
|
||||
if (!current.rows.length) return res.status(404).json({ error: 'Ключ не найден' });
|
||||
if (req.user.role !== 'admin' && current.rows[0].user_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
const raw = `${API_KEY_PREFIX}_${secret}`;
|
||||
const { rows } = await pool.query(
|
||||
'UPDATE api_keys SET prefix = $1, key_hash = $2, revoked_at = NULL, last_used_at = NULL WHERE id = $3 RETURNING *',
|
||||
[raw.slice(0, 12), hashApiKey(raw), req.params.id]
|
||||
);
|
||||
invalidateApiKeys();
|
||||
await logAudit(req, 'api_key.rotate', { id: rows[0].id, name: rows[0].name });
|
||||
res.json({ ...apiKeyPublic(rows[0]), key: raw });
|
||||
});
|
||||
|
||||
// --- Settings ---
|
||||
app.get('/api/settings', requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query('SELECT key, value FROM settings ORDER BY key');
|
||||
@@ -2422,6 +2726,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
await client.query('DELETE FROM groups');
|
||||
await client.query('DELETE FROM user_branches');
|
||||
await client.query('DELETE FROM sessions');
|
||||
await client.query('DELETE FROM api_keys');
|
||||
await client.query('DELETE FROM audit_log');
|
||||
await client.query('DELETE FROM users');
|
||||
await client.query('DELETE FROM branches');
|
||||
@@ -6685,6 +6990,507 @@ app.delete('/api/trash', requireAuth, requireAdmin, async (req, res) => {
|
||||
res.json({ ok: true, entries: e.rowCount, groups: g.rowCount, days });
|
||||
});
|
||||
|
||||
// --- External API v1 ---
|
||||
const apiV1 = express.Router();
|
||||
apiV1.use(requireApiKey('read'));
|
||||
apiV1.use(apiKeyLimiter);
|
||||
|
||||
function apiPage(req) {
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 500);
|
||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||
return { limit, offset };
|
||||
}
|
||||
|
||||
function apiList(rows, total, limit, offset) {
|
||||
return { items: rows, total, limit, offset };
|
||||
}
|
||||
|
||||
app.use('/api/v1', apiV1);
|
||||
|
||||
apiV1.get('/me', async (req, res) => {
|
||||
res.json({
|
||||
key: { id: req.apiKey.id, name: req.apiKey.name, scopes: req.apiKey.scopes },
|
||||
user: safeUser(req.user),
|
||||
server_time: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
apiV1.get('/branches', async (req, res) => {
|
||||
const s = branchScope(req.user);
|
||||
const params = [];
|
||||
let where = '';
|
||||
if (!s.admin) {
|
||||
if (!s.ids.length) return res.json(apiList([], 0, 50, 0));
|
||||
where = ` WHERE b.id = ANY($${params.push(s.ids)}::int[])`;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT b.id, b.name, b.address, b.phone, b.created_at,
|
||||
count(g.id)::int AS groups_count
|
||||
FROM branches b
|
||||
LEFT JOIN groups g ON g.branch_id = b.id
|
||||
${where}
|
||||
GROUP BY b.id
|
||||
ORDER BY b.id`,
|
||||
params
|
||||
);
|
||||
res.json(apiList(rows, rows.length, rows.length, 0));
|
||||
});
|
||||
|
||||
apiV1.get('/groups', async (req, res) => {
|
||||
const { limit, offset } = apiPage(req);
|
||||
const bw = branchWhere(req.user, 'g');
|
||||
const params = bw.params.slice();
|
||||
const active = req.query.deleted === '1' ? 'AND g.deleted_at IS NOT NULL' : 'AND g.deleted_at IS NULL';
|
||||
const { rows: crows } = await pool.query(
|
||||
`SELECT count(*)::int AS n FROM groups g WHERE 1=1 ${active}${bw.where}`,
|
||||
params
|
||||
);
|
||||
const total = crows[0].n;
|
||||
const q = `SELECT g.id, g.name, g.branch_id, b.name AS branch_name, g.day_of_week,
|
||||
g.time_start, g.time_end, g.cover_path, g.tutor_id, g.created_at, g.deleted_at
|
||||
FROM groups g
|
||||
LEFT JOIN branches b ON b.id = g.branch_id
|
||||
WHERE 1=1 ${active}${bw.where}
|
||||
ORDER BY g.id
|
||||
LIMIT $${params.push(limit)} OFFSET $${params.push(offset)}`;
|
||||
const { rows } = await pool.query(q, params);
|
||||
res.json(apiList(rows, total, limit, offset));
|
||||
});
|
||||
|
||||
apiV1.get('/groups/:id', async (req, res) => {
|
||||
if (!(await groupBelongsToBranches(req.user, req.params.id))) {
|
||||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT g.id, g.name, g.branch_id, b.name AS branch_name, g.day_of_week,
|
||||
g.time_start, g.time_end, g.cover_path, g.tutor_id, g.created_at, g.deleted_at
|
||||
FROM groups g LEFT JOIN branches b ON b.id = g.branch_id
|
||||
WHERE g.id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
apiV1.get('/students', async (req, res) => {
|
||||
const { limit, offset } = apiPage(req);
|
||||
const bw = branchWhere(req.user, 'g');
|
||||
const params = bw.params.slice();
|
||||
const search = String(req.query.search || '').trim();
|
||||
if (search) params.push(`%${search}%`);
|
||||
const extra = search ? ` AND s.name ILIKE $${params.length}` : '';
|
||||
const { rows: crows } = await pool.query(
|
||||
`SELECT count(*)::int AS n FROM students s
|
||||
LEFT JOIN groups g ON g.id = s.group_id
|
||||
WHERE 1=1${extra}${bw.where}`,
|
||||
params
|
||||
);
|
||||
const total = crows[0].n;
|
||||
const q = `SELECT s.id, s.name, s.group_id, g.name AS group_name, s.photo_path, s.created_at
|
||||
FROM students s
|
||||
LEFT JOIN groups g ON g.id = s.group_id
|
||||
WHERE 1=1${extra}${bw.where}
|
||||
ORDER BY s.name
|
||||
LIMIT $${params.push(limit)} OFFSET $${params.push(offset)}`;
|
||||
const { rows } = await pool.query(q, params);
|
||||
res.json(apiList(rows, total, limit, offset));
|
||||
});
|
||||
|
||||
apiV1.get('/students/:id', async (req, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.id, s.name, s.group_id, g.name AS group_name, s.photo_path, s.profile, s.created_at
|
||||
FROM students s
|
||||
LEFT JOIN groups g ON g.id = s.group_id
|
||||
WHERE s.id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
if (req.user.role !== 'admin' && rows[0].group_id) {
|
||||
if (!(await groupBelongsToBranches(req.user, rows[0].group_id))) {
|
||||
return res.status(403).json({ error: 'Нет доступа к этому ученику' });
|
||||
}
|
||||
}
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
apiV1.get('/modules', async (req, res) => {
|
||||
const { limit, offset } = apiPage(req);
|
||||
const params = [];
|
||||
const conditions = [];
|
||||
if (req.query.search?.trim()) { params.push(`%${req.query.search.trim()}%`); conditions.push(`m.name ILIKE $${params.length}`); }
|
||||
if (req.query.active === '1' || req.query.active === 'true') conditions.push('m.is_active = true');
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||||
const { rows: crows } = await pool.query(`SELECT count(*)::int AS n FROM modules m${where}`, params);
|
||||
const total = crows[0].n;
|
||||
const q = `SELECT m.id, m.name, m.lessons_count, m.is_active, m.created_at, count(e.id)::int AS entries_count
|
||||
FROM modules m
|
||||
LEFT JOIN entries e ON e.module_id = m.id${where}
|
||||
GROUP BY m.id
|
||||
ORDER BY m.is_active DESC, m.id
|
||||
LIMIT $${params.push(limit)} OFFSET $${params.push(offset)}`;
|
||||
const { rows } = await pool.query(q, params);
|
||||
res.json(apiList(rows, total, limit, offset));
|
||||
});
|
||||
|
||||
apiV1.get('/stats', async (req, res) => {
|
||||
const s = branchScope(req.user);
|
||||
const params = [];
|
||||
let gf = '';
|
||||
if (!s.admin) {
|
||||
if (!s.ids.length) return res.json({ entries: 0, groups: 0, students: 0, today: 0 });
|
||||
gf = ` AND g.branch_id IN (${s.ids.map(id => `$${params.push(id)}`).join(',')})`;
|
||||
}
|
||||
const todayParams = params.slice();
|
||||
const todaySql = `SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id
|
||||
WHERE e.deleted_at IS NULL AND e.created_at >= ${tzWall()}::date${gf}`
|
||||
.replace(/\$TZ\$/g, `$${todayParams.push(await appTimezone())}`);
|
||||
const [entries, groups, students, today] = await Promise.all([
|
||||
pool.query(`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${gf}`, params),
|
||||
pool.query(`SELECT count(*)::int AS n FROM groups g WHERE g.deleted_at IS NULL${gf}`, params),
|
||||
pool.query(`SELECT count(DISTINCT e.student_name)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${gf}`, params),
|
||||
pool.query(todaySql, todayParams),
|
||||
]);
|
||||
res.json({
|
||||
entries: entries.rows[0].n,
|
||||
groups: groups.rows[0].n,
|
||||
students: students.rows[0].n,
|
||||
today: today.rows[0].n,
|
||||
});
|
||||
});
|
||||
|
||||
apiV1.get('/entries', async (req, res) => {
|
||||
const { limit, offset } = apiPage(req);
|
||||
const conditions = ['e.deleted_at IS NULL'];
|
||||
const params = [];
|
||||
if (req.query.group_id) { params.push(req.query.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||||
if (req.query.module_id) { params.push(req.query.module_id); conditions.push(`e.module_id = $${params.length}`); }
|
||||
if (req.query.student_name) { params.push(req.query.student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||||
if (req.query.search) { params.push(`%${req.query.search}%`); conditions.push(`(e.student_name ILIKE $${params.length} OR e.description ILIKE $${params.length})`); }
|
||||
if (req.query.date_from) { params.push(req.query.date_from); conditions.push(`e.created_at >= ${tzDayStart(params.length)}`); }
|
||||
if (req.query.date_to) { params.push(req.query.date_to); conditions.push(`e.created_at < ${tzDayEnd(params.length)}`); }
|
||||
const s = branchScope(req.user);
|
||||
if (!s.admin) {
|
||||
if (!s.ids.length) conditions.push('1 = 0');
|
||||
else conditions.push(`g.branch_id IN (${s.ids.map(id => `$${params.push(id)}`).join(',')})`);
|
||||
}
|
||||
const bound = bindTz(' WHERE ' + conditions.join(' AND '), params, await appTimezone());
|
||||
const { rows: crows } = await pool.query(
|
||||
`SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id${bound.sql}`,
|
||||
bound.params
|
||||
);
|
||||
const total = crows[0].n;
|
||||
const q = `SELECT e.id, e.student_name, e.group_id, g.name AS group_name, e.module_id, m.name AS module_name,
|
||||
e.description, e.photo_path, e.created_at
|
||||
FROM entries e
|
||||
JOIN groups g ON g.id = e.group_id
|
||||
LEFT JOIN modules m ON m.id = e.module_id${bound.sql}
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT $${params.push(limit)} OFFSET $${params.push(offset)}`;
|
||||
const { rows } = await pool.query(q, bound.params);
|
||||
res.json(apiList(rows, total, limit, offset));
|
||||
});
|
||||
|
||||
apiV1.get('/entries/:id', async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT e.id, e.student_name, e.group_id, g.name AS group_name, e.module_id, m.name AS module_name,
|
||||
e.description, e.description_original, e.photo_path, e.ai_status, e.created_at, e.deleted_at
|
||||
FROM entries e
|
||||
JOIN groups g ON g.id = e.group_id
|
||||
LEFT JOIN modules m ON m.id = e.module_id
|
||||
WHERE e.id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
const entry = rows[0];
|
||||
const fRes = await pool.query('SELECT id, token, name FROM project_files WHERE entry_id = $1 ORDER BY id', [entry.id]);
|
||||
entry.files = fRes.rows;
|
||||
res.json(entry);
|
||||
});
|
||||
|
||||
apiV1.get('/entries/:id/files', async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, token, name, created_at FROM project_files WHERE entry_id = $1 ORDER BY id',
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(apiList(rows, rows.length, rows.length, 0));
|
||||
});
|
||||
|
||||
apiV1.get('/lesson-reports', async (req, res) => {
|
||||
const { limit, offset } = apiPage(req);
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
if (req.query.group_id) { params.push(req.query.group_id); conditions.push(`lr.group_id = $${params.length}`); }
|
||||
if (req.query.date_from) { params.push(req.query.date_from); conditions.push(`lr.lesson_date >= $${params.length}::date`); }
|
||||
if (req.query.date_to) { params.push(req.query.date_to); conditions.push(`lr.lesson_date <= $${params.length}::date`); }
|
||||
if (req.query.search?.trim()) { params.push(`%${req.query.search.trim()}%`); conditions.push(`lr.text ILIKE $${params.length}`); }
|
||||
const s = branchScope(req.user);
|
||||
if (!s.admin) {
|
||||
if (!s.ids.length) conditions.push('1 = 0');
|
||||
else conditions.push(`g.branch_id IN (${s.ids.map(id => `$${params.push(id)}`).join(',')})`);
|
||||
}
|
||||
const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
|
||||
const from = `FROM lesson_reports lr JOIN groups g ON g.id = lr.group_id${where}`;
|
||||
const { rows: crows } = await pool.query(`SELECT count(*)::int AS n ${from}`, params);
|
||||
const total = crows[0].n;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.topic, lr.text,
|
||||
lr.ai_status, lr.author_id, lr.created_at, lr.updated_at, g.name AS group_name
|
||||
${from}
|
||||
ORDER BY lr.lesson_date DESC, lr.lesson_time DESC NULLS LAST, lr.id DESC
|
||||
LIMIT $${params.push(limit)} OFFSET $${params.push(offset)}`,
|
||||
params
|
||||
);
|
||||
res.json(apiList(rows, total, limit, offset));
|
||||
});
|
||||
|
||||
apiV1.get('/lesson-reports/:id', async (req, res) => {
|
||||
const { report, error, code } = await lessonReportById(req.user, req.params.id);
|
||||
if (!report) return res.status(code || 404).json({ error });
|
||||
res.json(report);
|
||||
});
|
||||
|
||||
function apiWrite(scope) {
|
||||
return (req, res, next) => {
|
||||
if (!req.apiKey || !req.apiKey.scopes.includes(scope)) {
|
||||
return res.status(403).json({ error: `API key lacks scope: ${scope}` });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
apiV1.post('/entries', apiWrite('write'), async (req, res) => {
|
||||
const studentName = reqStr(req.body?.student_name, 150);
|
||||
const description = reqStr(req.body?.description, 20000);
|
||||
const grp = await lessonReportGroup(req.user, req.body?.group_id);
|
||||
if (grp.error) return res.status(grp.code || 400).json({ error: grp.error });
|
||||
let mid = null;
|
||||
if (req.body?.module_id !== undefined && req.body?.module_id !== null && req.body?.module_id !== '') {
|
||||
const parsed = optInt(req.body.module_id, 1);
|
||||
if (!parsed) return res.status(400).json({ error: 'Модуль не найден' });
|
||||
const mod = await pool.query('SELECT id FROM modules WHERE id = $1', [parsed]);
|
||||
if (!mod.rows.length) return res.status(400).json({ error: 'Модуль не найден' });
|
||||
mid = parsed;
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('INSERT INTO students (name) VALUES ($1) ON CONFLICT (name) DO NOTHING', [studentName]);
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO entries (student_name, group_id, module_id, description, description_original)
|
||||
VALUES ($1, $2, $3, $4, $4) RETURNING *`,
|
||||
[studentName, grp.group.id, mid, description]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
await apiAudit(req, 'api.entry.create', { id: rows[0].id, group_id: grp.group.id, student_name: studentName });
|
||||
invalidateEntries();
|
||||
invalidateStats();
|
||||
broadcastEntryChanged();
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
apiV1.put('/entries/:id', apiWrite('write'), async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const before = await pool.query(
|
||||
'SELECT id, student_name, group_id, module_id, description FROM entries WHERE id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
if (!before.rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
const studentName = req.body?.student_name !== undefined ? reqStr(req.body.student_name, 150) : null;
|
||||
const description = req.body?.description !== undefined ? reqStr(req.body.description, 20000) : null;
|
||||
let groupId = null;
|
||||
if (req.body?.group_id !== undefined) {
|
||||
const grp = await lessonReportGroup(req.user, req.body.group_id);
|
||||
if (grp.error) return res.status(grp.code || 400).json({ error: grp.error });
|
||||
groupId = grp.group.id;
|
||||
}
|
||||
let hasModule = false;
|
||||
let mid = null;
|
||||
if (req.body?.module_id !== undefined) {
|
||||
hasModule = true;
|
||||
if (req.body.module_id !== null && req.body.module_id !== '') {
|
||||
const parsed = optInt(req.body.module_id, 1);
|
||||
if (!parsed) return res.status(400).json({ error: 'Модуль не найден' });
|
||||
const mod = await pool.query('SELECT id FROM modules WHERE id = $1', [parsed]);
|
||||
if (!mod.rows.length) return res.status(400).json({ error: 'Модуль не найден' });
|
||||
mid = parsed;
|
||||
}
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE entries SET
|
||||
student_name = COALESCE($1, student_name),
|
||||
group_id = COALESCE($2, group_id),
|
||||
description = COALESCE($3, description),
|
||||
description_original = COALESCE($3, description_original),
|
||||
module_id = CASE WHEN $4::boolean THEN $5::int ELSE module_id END
|
||||
WHERE id = $6 RETURNING *`,
|
||||
[studentName, groupId, description, hasModule, mid, req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
await apiAudit(req, 'api.entry.update', { id: rows[0].id, before: before.rows[0] });
|
||||
invalidateEntries();
|
||||
invalidateStats();
|
||||
broadcastEntryChanged();
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
apiV1.delete('/entries/:id', apiWrite('write'), async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
await pool.query('UPDATE entries SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL', [req.params.id]);
|
||||
await apiAudit(req, 'api.entry.delete', { id: req.params.id });
|
||||
invalidateEntries();
|
||||
invalidateStats();
|
||||
broadcastEntryChanged();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
apiV1.post('/lesson-reports', apiWrite('write'), async (req, res) => {
|
||||
const grp = await lessonReportGroup(req.user, req.body?.group_id);
|
||||
if (grp.error) return res.status(grp.code || 400).json({ error: grp.error });
|
||||
const date = parseLessonReportDate(req.body?.lesson_date);
|
||||
if (!date) return res.status(400).json({ error: 'Некорректная дата занятия' });
|
||||
const time = parseLessonReportTime(req.body?.lesson_time);
|
||||
if (time === undefined) return res.status(400).json({ error: 'Некорректное время занятия' });
|
||||
const topic = typeof req.body?.topic === 'string' ? req.body.topic.trim() : '';
|
||||
if (topic.length > LESSON_REPORT_TOPIC_MAX) return res.status(400).json({ error: `Тема занятия длиннее ${LESSON_REPORT_TOPIC_MAX} символов` });
|
||||
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '';
|
||||
if (!text) return res.status(400).json({ error: 'Введите текст отчёта' });
|
||||
if (text.length > LESSON_REPORT_TEXT_MAX) return res.status(400).json({ error: `Текст отчёта длиннее ${LESSON_REPORT_TEXT_MAX} символов` });
|
||||
const existing = await pool.query('SELECT id FROM lesson_reports WHERE group_id = $1 AND lesson_date = $2', [grp.group.id, date]);
|
||||
if (existing.rows.length) {
|
||||
return res.status(409).json({ error: 'За эту группу и дату отчёт уже есть — откройте его для редактирования', id: existing.rows[0].id });
|
||||
}
|
||||
const aiWanted = req.body?.ai_check === true && (await getSetting('lesson_ai_enabled', 'true')) !== 'false';
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, topic, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id)
|
||||
VALUES ($1, $2::date, $3, $4, $5, $6, NULL, $7::text, CASE WHEN $7::text = 'none' THEN NULL ELSE now() END, NULL, $8, $9) RETURNING *`,
|
||||
[grp.group.id, date, time, topic || null, text, aiWanted ? text : null, aiWanted ? 'pending' : 'none', req.user.id || null, grp.group.branch_id || null]
|
||||
);
|
||||
const created = rows[0];
|
||||
await saveLessonReportVersion(created.id, text, 'manual', req.user.id);
|
||||
await apiAudit(req, 'api.lesson_report.create', { id: created.id, group_id: grp.group.id, lesson_date: date });
|
||||
invalidateLessonReports();
|
||||
if (aiWanted) wakeLessonAiWorker();
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
apiV1.put('/lesson-reports/:id', apiWrite('write'), async (req, res) => {
|
||||
const { report, error, code } = await lessonReportById(req.user, req.params.id);
|
||||
if (!report) return res.status(code || 404).json({ error });
|
||||
const curDate = String(report.lesson_date).slice(0, 10);
|
||||
let date = curDate;
|
||||
if (req.body?.lesson_date !== undefined && req.body?.lesson_date !== null && req.body?.lesson_date !== '') {
|
||||
date = parseLessonReportDate(req.body.lesson_date);
|
||||
if (!date) return res.status(400).json({ error: 'Некорректная дата занятия' });
|
||||
}
|
||||
let time;
|
||||
if (req.body?.lesson_time !== undefined) {
|
||||
time = parseLessonReportTime(req.body.lesson_time);
|
||||
if (time === undefined) return res.status(400).json({ error: 'Некорректное время занятия' });
|
||||
}
|
||||
const body = req.body?.text === undefined ? null : (typeof req.body.text === 'string' ? req.body.text.trim() : '');
|
||||
if (req.body?.text !== undefined && !body) return res.status(400).json({ error: 'Введите текст отчёта' });
|
||||
if (body && body.length > LESSON_REPORT_TEXT_MAX) return res.status(400).json({ error: `Текст отчёта длиннее ${LESSON_REPORT_TEXT_MAX} символов` });
|
||||
let topicText;
|
||||
if (req.body?.topic !== undefined) {
|
||||
topicText = typeof req.body.topic === 'string' ? req.body.topic.trim() : '';
|
||||
if (topicText.length > LESSON_REPORT_TOPIC_MAX) return res.status(400).json({ error: `Тема занятия длиннее ${LESSON_REPORT_TOPIC_MAX} символов` });
|
||||
}
|
||||
if (date !== curDate) {
|
||||
const clash = await pool.query('SELECT id FROM lesson_reports WHERE group_id = $1 AND lesson_date = $2 AND id <> $3', [report.group_id, date, report.id]);
|
||||
if (clash.rows.length) return res.status(409).json({ error: 'За эту группу и дату уже есть другой отчёт' });
|
||||
}
|
||||
const nextTime = time === undefined ? report.lesson_time : time;
|
||||
const aiWanted = !!body && req.body?.ai_check === true && (await getSetting('lesson_ai_enabled', 'true')) !== 'false';
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE lesson_reports SET
|
||||
lesson_date = $1::date,
|
||||
lesson_time = $2,
|
||||
topic = CASE WHEN $6::boolean THEN $3::text ELSE topic END,
|
||||
text = COALESCE($4, text),
|
||||
text_original = CASE WHEN $5::boolean THEN $4::text ELSE text_original END,
|
||||
text_ai = CASE WHEN $5::boolean THEN NULL ELSE text_ai END,
|
||||
ai_status = CASE WHEN $5::boolean THEN 'pending'::varchar ELSE ai_status END,
|
||||
ai_checked_at = CASE WHEN $5::boolean THEN now() ELSE ai_checked_at END,
|
||||
ai_error = CASE WHEN $5::boolean THEN NULL ELSE ai_error END,
|
||||
updated_at = now()
|
||||
WHERE id = $7 RETURNING *`,
|
||||
[date, nextTime, topicText === undefined ? null : (topicText || null), body, aiWanted, req.body?.topic !== undefined, report.id]
|
||||
);
|
||||
if (body) await saveLessonReportVersion(report.id, body, 'manual', req.user.id);
|
||||
await apiAudit(req, 'api.lesson_report.update', { id: report.id, lesson_date: date });
|
||||
invalidateLessonReports();
|
||||
if (aiWanted) wakeLessonAiWorker();
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
apiV1.delete('/lesson-reports/:id', apiWrite('write'), async (req, res) => {
|
||||
const { report, error, code } = await lessonReportById(req.user, req.params.id);
|
||||
if (!report) return res.status(code || 404).json({ error });
|
||||
await pool.query('DELETE FROM lesson_reports WHERE id = $1', [report.id]);
|
||||
await apiAudit(req, 'api.lesson_report.delete', { id: report.id });
|
||||
invalidateLessonReports();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
apiV1.post('/students', apiWrite('write'), async (req, res) => {
|
||||
const name = reqStr(req.body?.name, 150);
|
||||
let gid = null;
|
||||
if (req.body?.group_id !== undefined && req.body?.group_id !== null && req.body?.group_id !== '') {
|
||||
const grp = await lessonReportGroup(req.user, req.body.group_id);
|
||||
if (grp.error) return res.status(grp.code || 400).json({ error: grp.error });
|
||||
gid = grp.group.id;
|
||||
}
|
||||
const { rows } = await pool.query('INSERT INTO students (name, group_id) VALUES ($1, $2) RETURNING *', [name, gid]);
|
||||
await apiAudit(req, 'api.student.create', { id: rows[0].id, name });
|
||||
invalidateStudents();
|
||||
invalidateStats();
|
||||
res.status(201).json(rows[0]);
|
||||
});
|
||||
|
||||
apiV1.put('/students/:id', apiWrite('write'), async (req, res) => {
|
||||
const name = reqStr(req.body?.name, 150);
|
||||
const cur = await pool.query('SELECT id, group_id FROM students WHERE id = $1', [req.params.id]);
|
||||
if (!cur.rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
if (req.user.role !== 'admin' && cur.rows[0].group_id && !(await groupBelongsToBranches(req.user, cur.rows[0].group_id))) {
|
||||
return res.status(403).json({ error: 'Нет доступа к этому ученику' });
|
||||
}
|
||||
let gid = null;
|
||||
if (req.body?.group_id !== undefined && req.body?.group_id !== null && req.body?.group_id !== '') {
|
||||
const grp = await lessonReportGroup(req.user, req.body.group_id);
|
||||
if (grp.error) return res.status(grp.code || 400).json({ error: grp.error });
|
||||
gid = grp.group.id;
|
||||
}
|
||||
const { rows } = await pool.query('UPDATE students SET name = $1, group_id = $2 WHERE id = $3 RETURNING *', [name, gid, req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
await apiAudit(req, 'api.student.update', { id: rows[0].id, name });
|
||||
invalidateStudents();
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
// --- Error handlers ---
|
||||
const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user