Миграции: _migrations + applyMigrations + sql/migrations/
APP_ENV: test/production с жёлтым баннером в UI Config: версия 0.1.12
This commit is contained in:
@@ -10,6 +10,10 @@ DB_SSLMODE=disable
|
|||||||
# true = mock-логин (без Keycloak, для тестов)
|
# true = mock-логин (без Keycloak, для тестов)
|
||||||
# false = OIDC SSO через Keycloak (ОБЯЗАТЕЛЬНО в production)
|
# false = OIDC SSO через Keycloak (ОБЯЗАТЕЛЬНО в production)
|
||||||
DEV_MODE=true
|
DEV_MODE=true
|
||||||
|
|
||||||
|
# APP_ENV=production (по умолчанию) или test (жёлтый баннер в UI)
|
||||||
|
APP_ENV=test
|
||||||
|
|
||||||
DEFAULT_LIMIT=15
|
DEFAULT_LIMIT=15
|
||||||
|
|
||||||
# ── Сессия (сгенерировать: openssl rand -base64 32) ──
|
# ── Сессия (сгенерировать: openssl rand -base64 32) ──
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 001: Добавить колонку impersonated_by в audit_log
|
||||||
|
-- Дата: 2026-06 (была добавлена в ensureSchema вручную)
|
||||||
|
|
||||||
|
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS impersonated_by VARCHAR(255) DEFAULT NULL;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- 002: Добавить значение RESTORE в CHECK-констрейнт audit_log.action
|
||||||
|
-- Дата: 2026-06 (до этого CHECK допускал только CREATE, UPDATE, DELETE)
|
||||||
|
|
||||||
|
-- Сначала удаляем старый CHECK, потом создаём новый со значением RESTORE
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
cn text;
|
||||||
|
BEGIN
|
||||||
|
SELECT con.conname INTO cn
|
||||||
|
FROM pg_constraint con
|
||||||
|
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||||
|
WHERE rel.relname = 'audit_log' AND con.contype = 'c';
|
||||||
|
IF cn IS NOT NULL THEN
|
||||||
|
EXECUTE 'ALTER TABLE audit_log DROP CONSTRAINT ' || cn;
|
||||||
|
END IF;
|
||||||
|
EXECUTE 'ALTER TABLE audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN (''CREATE'',''UPDATE'',''DELETE'',''RESTORE''))';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Миграции БД
|
||||||
|
|
||||||
|
Каждый файл — одна миграция. Применяются при старте приложения автоматически в порядке сортировки имён.
|
||||||
|
|
||||||
|
## Как добавить новую миграцию
|
||||||
|
|
||||||
|
1. Создать файл `{номер}_{описание}.sql` в этой папке
|
||||||
|
2. Номер должен быть следующим (003, 004, ...)
|
||||||
|
3. SQL должен быть идемпотентным (`IF NOT EXISTS` где возможно)
|
||||||
|
4. Закоммитить и запушнуть
|
||||||
|
|
||||||
|
## Важно
|
||||||
|
|
||||||
|
- Миграции выполняются **один раз**. После применения имя файла записывается в таблицу `_migrations`.
|
||||||
|
- Уже работающие миграции не перезапускаются — если нужно что-то изменить, создавай новую миграцию, не редактируй старую.
|
||||||
|
- Все DDL должны быть безопасны для повторного запуска — используй `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`.
|
||||||
@@ -31,7 +31,7 @@ function createAdminRouter({ generateCsrfToken } = {}) {
|
|||||||
try {
|
try {
|
||||||
const companies = await q.getAllCompanies();
|
const companies = await q.getAllCompanies();
|
||||||
res.render('v2/admin', {
|
res.render('v2/admin', {
|
||||||
companies, user: req.adminUser, version: config.version,
|
companies, user: req.adminUser, version: config.version, env: config.env,
|
||||||
csrfToken: generateCsrfToken ? generateCsrfToken(req, res) : '',
|
csrfToken: generateCsrfToken ? generateCsrfToken(req, res) : '',
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -52,7 +52,7 @@ function createAdminRouter({ generateCsrfToken } = {}) {
|
|||||||
res.render('v2/admin-audit', {
|
res.render('v2/admin-audit', {
|
||||||
audit: result.rows, total: result.total,
|
audit: result.rows, total: result.total,
|
||||||
page, pages: Math.ceil(result.total / perPage) || 1,
|
page, pages: Math.ceil(result.total / perPage) || 1,
|
||||||
days, company, version: config.version,
|
days, company, version: config.version, env: config.env,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
res.status(500).send('<h2>Ошибка</h2><pre>' + e.message + '</pre>');
|
||||||
|
|||||||
@@ -8,7 +8,12 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
version: '0.1.11',
|
version: '0.1.12',
|
||||||
|
|
||||||
|
// ── Среда ─────────────────────────────────────────────────────────────────
|
||||||
|
// APP_ENV=test → тестовый стенд (жёлтый баннер в UI, можно включать тестовый API)
|
||||||
|
// APP_ENV=production или не задана → production (баннера нет)
|
||||||
|
env: process.env.APP_ENV || 'production',
|
||||||
|
|
||||||
// ── IAM ──────────────────────────────────────────────────────────────────
|
// ── IAM ──────────────────────────────────────────────────────────────────
|
||||||
iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',
|
iamUrl: process.env.V2_IAM_URL || 'https://auth-api.ngcloud.ru/api/v1/auth/user',
|
||||||
|
|||||||
+85
-25
@@ -1,20 +1,22 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// V2 — инициализация схемы БД
|
// V2 — инициализация схемы БД + миграции
|
||||||
//
|
//
|
||||||
// ЭТО: CREATE TABLE IF NOT EXISTS — безопасный повторный запуск.
|
// ЭТО: CREATE TABLE IF NOT EXISTS (идемпотентно) + applyMigrations().
|
||||||
// ЗАЧЕМ:
|
// ЗАЧЕМ:
|
||||||
// 1. Приложение само создаёт таблицы при старте — не нужны миграции.
|
// 1. Приложение само создаёт таблицы при старте — не нужны ручные DDL.
|
||||||
// 2. v2_ префикс изолирует тестовые таблицы от основных.
|
// 2. Миграции — отдельные .sql файлы в sql/migrations/, выполняются по порядку.
|
||||||
// 3. Безопасно для production: IF NOT EXISTS.
|
// 3. Безопасно для production: IF NOT EXISTS + _migrations таблица.
|
||||||
//
|
//
|
||||||
// ТАБЛИЦЫ:
|
// ТАБЛИЦЫ:
|
||||||
// companies — client_id (W-номер), name, custom_limit
|
// companies — client_id (W-номер), name, custom_limit
|
||||||
// whitelist_entries — CIDR, комментарий, кто создал/изменил/удалил
|
// whitelist_entries — CIDR, комментарий, кто создал/изменил/удалил
|
||||||
// audit_log — все действия: CREATE/UPDATE/DELETE, с impersonated_by
|
// audit_log — все действия: CREATE/UPDATE/DELETE/RESTORE, с impersonated_by
|
||||||
//
|
// _migrations — какие миграции уже выполнены
|
||||||
// ИНДЕКСЫ: на company_id для быстрых SELECT + на unique(client_id).
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
async function ensureSchema(pool) {
|
async function ensureSchema(pool) {
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
-- Компании: W-номер уникален, upsert через ON CONFLICT
|
-- Компании: W-номер уникален, upsert через ON CONFLICT
|
||||||
@@ -51,7 +53,7 @@ async function ensureSchema(pool) {
|
|||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_email VARCHAR(255) NOT NULL,
|
user_email VARCHAR(255) NOT NULL,
|
||||||
company_id INTEGER NOT NULL,
|
company_id INTEGER NOT NULL,
|
||||||
action VARCHAR(32) NOT NULL CHECK (action IN ('CREATE','UPDATE','DELETE','RESTORE')),
|
action VARCHAR(32) NOT NULL,
|
||||||
old_value TEXT,
|
old_value TEXT,
|
||||||
new_value TEXT,
|
new_value TEXT,
|
||||||
entry_id INTEGER,
|
entry_id INTEGER,
|
||||||
@@ -62,24 +64,82 @@ async function ensureSchema(pool) {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_company_time
|
CREATE INDEX IF NOT EXISTS idx_audit_log_company_time
|
||||||
ON audit_log(company_id, created_at DESC);
|
ON audit_log(company_id, created_at DESC);
|
||||||
|
|
||||||
-- Миграция: добавить RESTORE в CHECK (если ещё нет)
|
-- Таблица учёта выполненных миграций
|
||||||
DO $$
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
DECLARE
|
id SERIAL PRIMARY KEY,
|
||||||
cn text;
|
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||||
BEGIN
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
SELECT con.conname INTO cn
|
);
|
||||||
FROM pg_constraint con
|
|
||||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
|
||||||
WHERE rel.relname = 'audit_log' AND con.contype = 'c';
|
|
||||||
IF cn IS NOT NULL THEN
|
|
||||||
EXECUTE 'ALTER TABLE audit_log DROP CONSTRAINT ' || cn;
|
|
||||||
END IF;
|
|
||||||
EXECUTE 'ALTER TABLE audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN (''CREATE'',''UPDATE'',''DELETE'',''RESTORE''))';
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
console.log('[db] Schema ensured');
|
console.log('[db] Schema ensured');
|
||||||
|
|
||||||
|
// Выполнить неприменённые миграции
|
||||||
|
await applyMigrations(pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ensureSchema };
|
/**
|
||||||
|
* Применить новые миграции из sql/migrations/.
|
||||||
|
* Читает все .sql файлы, сортирует по имени, выполняет те,
|
||||||
|
* которых ещё нет в таблице _migrations.
|
||||||
|
*/
|
||||||
|
async function applyMigrations(pool) {
|
||||||
|
const dir = path.join(__dirname, '..', '..', '..', 'sql', 'migrations');
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
console.log('[db] No migrations dir, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(dir)
|
||||||
|
.filter(f => f.endsWith('.sql'))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
// Какие уже выполнены
|
||||||
|
const done = new Set(
|
||||||
|
(await pool.query('SELECT filename FROM _migrations')).rows.map(r => r.filename)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (done.has(file)) continue;
|
||||||
|
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
|
||||||
|
await pool.query(sql);
|
||||||
|
await pool.query('INSERT INTO _migrations (filename) VALUES ($1)', [file]);
|
||||||
|
console.log('[db] Migration applied:', file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initOldMigrations(pool) {
|
||||||
|
// Миграции, которые раньше были в ensureSchema() — теперь
|
||||||
|
// фиксируем их как уже выполненные, чтобы не потерять.
|
||||||
|
const dir = path.join(__dirname, '..', '..', '..', 'sql', 'migrations');
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
const files = fs.readdirSync(dir)
|
||||||
|
.filter(f => f.endsWith('.sql'))
|
||||||
|
.sort();
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
// Создать таблицу если ещё нет (на случай первого запуска с миграциями)
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const done = new Set(
|
||||||
|
(await pool.query('SELECT filename FROM _migrations')).rows.map(r => r.filename)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (done.has(file)) continue;
|
||||||
|
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
|
||||||
|
await pool.query(sql);
|
||||||
|
await pool.query('INSERT INTO _migrations (filename) VALUES ($1)', [file]);
|
||||||
|
console.log('[db] Migration applied:', file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ensureSchema, applyMigrations, initOldMigrations };
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function createUserRouter({ generateCsrfToken } = {}) {
|
|||||||
activeClientId: clId,
|
activeClientId: clId,
|
||||||
user: templateUser,
|
user: templateUser,
|
||||||
includeDeleted,
|
includeDeleted,
|
||||||
version: config.version,
|
version: config.version, env: config.env,
|
||||||
csrfToken: generateCsrfToken ? generateCsrfToken(req, res) : '',
|
csrfToken: generateCsrfToken ? generateCsrfToken(req, res) : '',
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
--red: #dc2626; --green: #16a34a; --amber: #d97706;
|
--red: #dc2626; --green: #16a34a; --amber: #d97706;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
.test-banner { background: #fef3cd; color: #856404; padding: 12px; text-align: center; font-weight: bold; border-bottom: 2px solid #f0b400; font-size: 14px; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<% if (env === 'test') { %><div class="test-banner">⚠️ ТЕСТОВЫЙ СТЕНД — данные могут быть удалены в любой момент</div><% } %>
|
||||||
|
|
||||||
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.9rem;color:var(--muted);">
|
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.9rem;color:var(--muted);">
|
||||||
<svg width="130" height="28" viewBox="0 0 311 69" style="display:block;">
|
<svg width="130" height="28" viewBox="0 0 311 69" style="display:block;">
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
th{background:#263238;color:#fff}
|
th{background:#263238;color:#fff}
|
||||||
.bar{display:flex;justify-content:space-between;align-items:center;background:#263238;color:#fff;padding:10px;margin-bottom:15px}
|
.bar{display:flex;justify-content:space-between;align-items:center;background:#263238;color:#fff;padding:10px;margin-bottom:15px}
|
||||||
.bar a{color:#80cbc4}
|
.bar a{color:#80cbc4}
|
||||||
</style></head><body>
|
.test-banner{background:#fef3cd;color:#856404;padding:12px;text-align:center;font-weight:bold;border-bottom:2px solid #f0b400;font-size:14px}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<% if (env === 'test') { %><div class="test-banner">⚠️ ТЕСТОВЫЙ СТЕНД — данные могут быть удалены в любой момент</div><% } %>
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<span><strong>📋 Записи</strong> <%= company.client_id %> — <%= company.name || company.client_id %></span>
|
<span><strong>📋 Записи</strong> <%= company.client_id %> — <%= company.name || company.client_id %></span>
|
||||||
<span><a href="/v2/admin">← Админка</a></span>
|
<span><a href="/v2/admin">← Админка</a></span>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
--red: #dc2626; --green: #16a34a; --amber: #d97706;
|
--red: #dc2626; --green: #16a34a; --amber: #d97706;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
.test-banner { background: #fef3cd; color: #856404; padding: 12px; text-align: center; font-weight: bold; border-bottom: 2px solid #f0b400; font-size: 14px; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
||||||
@@ -65,6 +66,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<% if (env === 'test') { %><div class="test-banner">⚠️ ТЕСТОВЫЙ СТЕНД — данные могут быть удалены в любой момент</div><% } %>
|
||||||
|
|
||||||
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.9rem;color:var(--muted);">
|
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.9rem;color:var(--muted);">
|
||||||
<svg width="130" height="28" viewBox="0 0 311 69" style="display:block;">
|
<svg width="130" height="28" viewBox="0 0 311 69" style="display:block;">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
--red: #dc2626; --red-h: #b91c1c; --green: #16a34a; --amber: #d97706;
|
--red: #dc2626; --red-h: #b91c1c; --green: #16a34a; --amber: #d97706;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
.test-banner { background: #fef3cd; color: #856404; padding: 12px; text-align: center; font-weight: bold; border-bottom: 2px solid #f0b400; font-size: 14px; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5;
|
||||||
@@ -108,6 +109,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<% if (env === 'test') { %><div class="test-banner">⚠️ ТЕСТОВЫЙ СТЕНД — данные могут быть удалены в любой момент</div><% } %>
|
||||||
|
|
||||||
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.9rem;color:var(--muted);">
|
<header style="background:#fff;border-bottom:1px solid var(--border);padding:0 1.5rem;height:48px;display:flex;align-items:center;gap:.75rem;font-size:.9rem;color:var(--muted);">
|
||||||
<svg width="130" height="28" viewBox="0 0 311 69" style="display:block;">
|
<svg width="130" height="28" viewBox="0 0 311 69" style="display:block;">
|
||||||
|
|||||||
Reference in New Issue
Block a user