Files
family_budget/backend/src/db/migrate.ts

223 lines
13 KiB
TypeScript
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.
import { pool } from './pool';
const migrations: { name: string; sql: string }[] = [
{
name: '001_create_tables',
sql: `
CREATE TABLE IF NOT EXISTS accounts (
id BIGSERIAL PRIMARY KEY,
bank TEXT NOT NULL,
account_number TEXT NOT NULL,
currency TEXT NOT NULL,
alias TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_bank_number
ON accounts(bank, account_number);
CREATE TABLE IF NOT EXISTS categories (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
ALTER TABLE categories DROP CONSTRAINT IF EXISTS chk_categories_type;
ALTER TABLE categories
ADD CONSTRAINT chk_categories_type
CHECK (type IN ('expense', 'income', 'transfer'));
CREATE TABLE IF NOT EXISTS transactions (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id),
operation_at TIMESTAMPTZ NOT NULL,
amount_signed BIGINT NOT NULL,
commission BIGINT NOT NULL,
description TEXT NOT NULL,
direction TEXT NOT NULL,
fingerprint TEXT NOT NULL,
category_id BIGINT REFERENCES categories(id),
is_category_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_transactions_account_fingerprint
ON transactions(account_id, fingerprint);
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS chk_transactions_direction;
ALTER TABLE transactions
ADD CONSTRAINT chk_transactions_direction
CHECK (direction IN ('income', 'expense', 'transfer'));
CREATE TABLE IF NOT EXISTS category_rules (
id BIGSERIAL PRIMARY KEY,
pattern TEXT NOT NULL,
match_type TEXT NOT NULL,
category_id BIGINT NOT NULL REFERENCES categories(id),
priority INT NOT NULL DEFAULT 0,
requires_confirmation BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
`,
},
{
name: '002_seed_categories',
sql: `
INSERT INTO categories (name, type) VALUES
('Продукты', 'expense'),
('Авто', 'expense'),
('Здоровье', 'expense'),
('Арчи', 'expense'),
('ЖКХ', 'expense'),
('Дом', 'expense'),
('Проезд', 'expense'),
('Одежда', 'expense'),
('Химия', 'expense'),
('Косметика', 'expense'),
('Инвестиции', 'transfer'),
('Развлечения', 'expense'),
('Общепит', 'expense'),
('Штрафы', 'expense'),
('Налоги', 'expense'),
('Подписки', 'expense'),
('Перевод', 'transfer'),
('Наличные', 'expense'),
('Подарки', 'expense'),
('Спорт', 'expense'),
('Отпуск', 'expense'),
('Техника', 'expense'),
('Поступления', 'income')
ON CONFLICT DO NOTHING;
`,
},
{
name: '003_seed_category_rules',
sql: `
INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation)
SELECT pattern, match_type, category_id, priority, requires_confirmation
FROM (VALUES
('PYATEROCHK', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
('ПЯТЕРОЧКА', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
('AUCHAN', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
('DOSTAVKA IZ PYATEROCHK', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 25, false),
('МЕТРО', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
('PEREKRESTOK', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
('MAGNIT', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
('LENTA', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
('SPORTMASTER', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' AND type = 'expense' LIMIT 1), 20, false),
('DECATHLON', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' AND type = 'expense' LIMIT 1), 20, false),
('PAYPARKING', 'contains', (SELECT id FROM categories WHERE name = 'Авто' AND type = 'expense' LIMIT 1), 20, false),
('PARKING', 'contains', (SELECT id FROM categories WHERE name = 'Авто' AND type = 'expense' LIMIT 1), 15, false),
('AZS', 'contains', (SELECT id FROM categories WHERE name = 'Авто' AND type = 'expense' LIMIT 1), 20, false),
('ЯНДЕКС.ЕДА', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 25, false),
('MCDONALD', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 20, false),
('KFC', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 20, false),
('STARBUCKS', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 20, false),
('tapper.ru', 'contains', (SELECT id FROM categories WHERE name = 'Развлечения' AND type = 'expense' LIMIT 1), 20, false),
('АПТЕКА', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' AND type = 'expense' LIMIT 1), 20, false),
('РИГЛА', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' AND type = 'expense' LIMIT 1), 20, false),
('OZON', 'contains', (SELECT id FROM categories WHERE name = 'Дом' AND type = 'expense' LIMIT 1), 15, false),
('WILDBERRIES', 'contains', (SELECT id FROM categories WHERE name = 'Дом' AND type = 'expense' LIMIT 1), 15, false),
('ЯНДЕКС ПЛЮС', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' AND type = 'expense' LIMIT 1), 20, false),
('SPOTIFY', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' AND type = 'expense' LIMIT 1), 20, false),
('НЕТФЛИКС', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' AND type = 'expense' LIMIT 1), 20, false)
) AS v(pattern, match_type, category_id, priority, requires_confirmation)
WHERE category_id IS NOT NULL
AND EXISTS (SELECT 1 FROM categories LIMIT 1)
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1);
`,
},
{
name: '004_seed_category_rules_extended',
sql: `
INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation)
SELECT pattern, match_type, category_id, priority, requires_confirmation
FROM (VALUES
('STOLOVAYA', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
('KOFEJNYA', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
('JEFFREY S COFFEESHOP', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
('TA TORRRO GRIL', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
('OUSHEN FRENDS', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
('mkad', 'contains', (SELECT id FROM categories WHERE name = 'Авто' LIMIT 1), 20, false),
('RNAZK', 'contains', (SELECT id FROM categories WHERE name = 'Авто' LIMIT 1), 20, false),
('IP SHEVELEV', 'contains', (SELECT id FROM categories WHERE name = 'Авто' LIMIT 1), 20, false),
('PAVELETSKAYA', 'contains', (SELECT id FROM categories WHERE name = 'Проезд' LIMIT 1), 25, false),
('CPPK-', 'contains', (SELECT id FROM categories WHERE name = 'Проезд' LIMIT 1), 25, false),
('MOS.TRANSPORT', 'contains', (SELECT id FROM categories WHERE name = 'Проезд' LIMIT 1), 25, false),
('Lab4uru', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' LIMIT 1), 25, false),
('APTEKA', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' LIMIT 1), 20, false),
('IP SHARAFETDINOV', 'contains', (SELECT id FROM categories WHERE name = 'Арчи' LIMIT 1), 25, false),
('ZOOMAGAZIN CHETYRE LAP', 'contains', (SELECT id FROM categories WHERE name = 'Арчи' LIMIT 1), 25, false),
('VKUSVILL', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' LIMIT 1), 20, false),
('GLOBUS', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' LIMIT 1), 20, false),
('VERNYJ', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' LIMIT 1), 20, false),
('GOLD APPLE', 'contains', (SELECT id FROM categories WHERE name = 'Косметика' LIMIT 1), 25, false),
('SPIRITFIT', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' LIMIT 1), 25, false),
('insanity', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' LIMIT 1), 25, false),
('anta-sport', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' LIMIT 1), 25, false),
('VANYAVPN', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' LIMIT 1), 25, false),
('ГРАНЛАЙН', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' LIMIT 1), 20, false),
('Мобильная связь', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' LIMIT 1), 25, false),
('OSTROVOK', 'contains', (SELECT id FROM categories WHERE name = 'Отпуск' LIMIT 1), 25, false),
('sutochno', 'contains', (SELECT id FROM categories WHERE name = 'Отпуск' LIMIT 1), 25, false),
('УФК', 'contains', (SELECT id FROM categories WHERE name = 'Штрафы' LIMIT 1), 30, false),
('ГИБДД', 'contains', (SELECT id FROM categories WHERE name = 'Штрафы' LIMIT 1), 30, false),
('Поступление заработной платы', 'contains', (SELECT id FROM categories WHERE name = 'Поступления' LIMIT 1), 30, false),
('avito', 'contains', (SELECT id FROM categories WHERE name = 'Поступления' LIMIT 1), 25, false),
('Init payout', 'contains', (SELECT id FROM categories WHERE name = 'Поступления' LIMIT 1), 25, false)
) AS v(pattern, match_type, category_id, priority, requires_confirmation)
WHERE category_id IS NOT NULL
AND EXISTS (SELECT 1 FROM categories LIMIT 1);
`,
},
];
export async function runMigrations(): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS _migrations (
name TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
for (const m of migrations) {
const { rows } = await pool.query(
'SELECT 1 FROM _migrations WHERE name = $1',
[m.name],
);
if (rows.length > 0) continue;
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(m.sql);
await client.query('INSERT INTO _migrations (name) VALUES ($1)', [m.name]);
await client.query('COMMIT');
console.log(`Migration applied: ${m.name}`);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
}
if (require.main === module) {
runMigrations()
.then(() => {
console.log('All migrations applied');
process.exit(0);
})
.catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
}