Files
ipwhitelist-app/src/queries.js
T

250 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { pool } = require('./db');
const { validate, overlaps } = require('./validators');
// ── Companies ──
async function getOrCreateCompany(clientId, companyName) {
// Атомарный upsert — исключает дубли компаний при параллельных запросах
const res = await pool.query(
`INSERT INTO companies (client_id, name) VALUES ($1, $2)
ON CONFLICT (client_id) DO UPDATE SET name = COALESCE(companies.name, EXCLUDED.name)
RETURNING *`,
[clientId, companyName || clientId]
);
return res.rows[0];
}
async function getLimit(company) {
const defaultLimit = parseInt(process.env.DEFAULT_LIMIT, 10) || 15;
// != null: custom_limit = 0 не должен превращаться в defaultLimit
return company.custom_limit != null ? company.custom_limit : defaultLimit;
}
// ── Entries ──
async function listEntries(companyId, includeDeleted = false) {
let sql = 'SELECT * FROM whitelist_entries WHERE company_id = $1';
if (!includeDeleted) sql += ' AND deleted_at IS NULL';
sql += ' ORDER BY created_at DESC';
return (await pool.query(sql, [companyId])).rows;
}
async function createEntry(companyId, rawValue, comment, userEmail) {
const { cidr, wasNormalized } = validate(rawValue);
const client = await pool.connect();
try {
await client.query('BEGIN');
// Блокировка строки компании — сериализует параллельные вставки
const company = (await client.query(
'SELECT * FROM companies WHERE id = $1 FOR UPDATE', [companyId]
)).rows[0];
if (!company) throw new Error('Компания не найдена');
const limit = await getLimit(company);
const cnt = (await client.query(
'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
[companyId]
)).rows[0].c;
if (cnt >= limit) throw new Error(`Лимит исчерпан: ${cnt} из ${limit}`);
// Проверка дубликатов и пересечений
const existing = (await client.query(
'SELECT value_cidr FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
[companyId]
)).rows;
for (const row of existing) {
if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует');
if (overlaps(cidr, row.value_cidr))
throw new Error(`Пересечение с существующей записью ${row.value_cidr}`);
}
const res = await client.query(
`INSERT INTO whitelist_entries (company_id, value_cidr, comment, created_by)
VALUES ($1, $2, $3, $4) RETURNING *`,
[companyId, cidr, comment || null, userEmail]
);
await logAudit(userEmail, companyId, 'CREATE', null, cidr, res.rows[0].id, client);
await client.query('COMMIT');
return { entry: res.rows[0], wasNormalized };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
async function updateEntry(entryId, companyId, rawValue, comment, userEmail) {
const { cidr, wasNormalized } = validate(rawValue);
const client = await pool.connect();
try {
await client.query('BEGIN');
// Блокировка строки компании — сериализует параллельные обновления
await client.query('SELECT * FROM companies WHERE id = $1 FOR UPDATE', [companyId]);
const old = (await client.query(
'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL',
[entryId, companyId]
)).rows[0];
if (!old) throw new Error('Запись не найдена');
// Проверка пересечений (исключая саму себя)
const existing = (await client.query(
'SELECT value_cidr FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL AND id <> $2',
[companyId, entryId]
)).rows;
for (const row of existing) {
if (row.value_cidr === cidr) throw new Error('Такой адрес уже существует');
if (overlaps(cidr, row.value_cidr))
throw new Error(`Пересечение с существующей записью ${row.value_cidr}`);
}
const res = await client.query(
`UPDATE whitelist_entries SET value_cidr = $1, comment = $2, updated_by = $3, updated_at = NOW()
WHERE id = $4 AND company_id = $5 RETURNING *`,
[cidr, comment || old.comment, userEmail, entryId, companyId]
);
await logAudit(userEmail, companyId, 'UPDATE', old.value_cidr, cidr, entryId, client);
await client.query('COMMIT');
return { entry: res.rows[0], wasNormalized };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
async function deleteEntry(entryId, companyId, userEmail) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const old = (await client.query(
'SELECT * FROM whitelist_entries WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL',
[entryId, companyId]
)).rows[0];
if (!old) throw new Error('Запись не найдена');
// company_id в WHERE — глубина защиты от случайной утечки между компаниями
await client.query(
'UPDATE whitelist_entries SET deleted_by = $1, deleted_at = NOW() WHERE id = $2 AND company_id = $3',
[userEmail, entryId, companyId]
);
await logAudit(userEmail, companyId, 'DELETE', old.value_cidr, null, entryId, client);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
// ── Admin: компании ──
/**
* Получить одну компанию по числовому id (primary key).
* Используется когда admin работает с чужой компанией:
* нам не нужно getOrCreateCompany (которая привязывается к clientId пользователя).
*
* @param {number} id — companies.id
* @returns {object|null} строка из таблицы companies или null
*/
async function getCompanyById(id) {
const res = await pool.query('SELECT * FROM companies WHERE id = $1', [id]);
return res.rows[0] || null;
}
/**
* Получить все компании с количеством активных записей каждой.
* Используется на странице администратора: список компаний + статистика.
* LEFT JOIN гарантирует присутствие компаний с нулём записей.
*
* @returns {object[]} строки companies с доп. полем active_count
*/
async function getAllCompanies() {
const res = await pool.query(`
SELECT c.*,
COUNT(e.id)::int AS active_count
FROM companies c
LEFT JOIN whitelist_entries e
ON e.company_id = c.id AND e.deleted_at IS NULL
GROUP BY c.id
ORDER BY c.name, c.client_id
`);
return res.rows;
}
/**
* Задать индивидуальный лимит для компании.
* newLimit = null → сброс на глобальный дефолт (DEFAULT_LIMIT из .env / 15).
* newLimit = число → индивидуальный лимит, переопределяет глобальный.
*
* ТЗ 4.5: «снижение лимита не удаляет существующие записи, но блокирует создание новых».
*
* @param {number} companyId — companies.id
* @param {number|null} newLimit — новый лимит или null для сброса
*/
async function setLimit(companyId, newLimit) {
const result = await pool.query(
'UPDATE companies SET custom_limit = $1, updated_at = NOW() WHERE id = $2',
[newLimit, companyId]
);
if (result.rowCount === 0) {
const err = new Error('Company not found: ' + companyId);
err.status = 404;
throw err;
}
}
// ── Export ──
async function getExportCIDRs(companyId = null) {
let sql = 'SELECT value_cidr FROM whitelist_entries WHERE deleted_at IS NULL';
const params = [];
if (companyId) {
sql += ' AND company_id = $1';
params.push(companyId);
}
sql += ' ORDER BY value_cidr';
return ((await pool.query(sql, params)).rows).map(r => r.value_cidr);
}
// ── Audit ──
async function logAudit(userEmail, companyId, action, oldValue, newValue, entryId, db = pool) {
await db.query(
`INSERT INTO audit_log (user_email, company_id, action, old_value, new_value, entry_id)
VALUES ($1, $2, $3, $4, $5, $6)`,
[userEmail, companyId, action, oldValue, newValue, entryId || null]
);
}
async function getAudit(companyId = null) {
// JOIN с companies чтобы выводить human-readable название компании в UI.
// Лимит 500 строк — достаточно для оперативного мониторинга.
// В проде можно добавить пагинацию.
let sql = `
SELECT a.*, c.name AS company_name, c.client_id
FROM audit_log a
JOIN companies c ON c.id = a.company_id
`;
const params = [];
if (companyId) {
sql += ' WHERE a.company_id = $1';
params.push(companyId);
}
sql += ' ORDER BY a.created_at DESC LIMIT 500';
return (await pool.query(sql, params)).rows;
}
module.exports = {
getOrCreateCompany, getLimit,
listEntries, createEntry, updateEntry, deleteEntry,
getExportCIDRs,
getAudit,
// admin
getCompanyById, getAllCompanies, setLimit,
};