127 lines
7.0 KiB
SQL
127 lines
7.0 KiB
SQL
-- ╔══════════════════════════════════════════════════════════════════╗
|
||
-- ║ CryptoWallet API — Production DB schema ║
|
||
-- ║ ║
|
||
-- ║ APPEND-ONLY / NON-DESTRUCTIVE: ║
|
||
-- ║ Безопасно прогонять повторно. Ничего не DROP'ает, не overwrite. ║
|
||
-- ║ Если оператор добавил кастомные таблицы / индексы / constraints ║
|
||
-- ║ вручную — они НЕ будут затронуты. ║
|
||
-- ║ ║
|
||
-- ║ Применять: psql -h <host> -U <user> -d <db> -f cryptowallet-schema.sql ║
|
||
-- ╚══════════════════════════════════════════════════════════════════╝
|
||
|
||
-- NOTE: idempotency_keys и audit_log таблицы НЕ используются.
|
||
-- - idempotency_keys → KeyDB (Redis cache) — apps/api/src/config/redis.ts
|
||
-- - audit_log → stdout JSON-lines — apps/api/src/lib/audit-log.ts
|
||
-- Скрипт их НЕ дропает (чтобы re-run был non-destructive).
|
||
-- Если оператор хочет cleanup — manual one-time:
|
||
-- DROP TABLE IF EXISTS audit_log CASCADE;
|
||
-- DROP TABLE IF EXISTS idempotency_keys CASCADE;
|
||
|
||
-- ── USERS ───────────────────────────────────────────────────────────
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id VARCHAR(26) NOT NULL PRIMARY KEY,
|
||
email VARCHAR(255) NOT NULL UNIQUE,
|
||
password_hash VARCHAR(255) NOT NULL,
|
||
last_name VARCHAR(128),
|
||
first_name VARCHAR(128),
|
||
middle_name VARCHAR(128),
|
||
birth_date DATE,
|
||
-- DEPRECATED: исторически generic crypto address; для ETH используем erc20 ниже.
|
||
crypto_wallet VARCHAR(255),
|
||
phone VARCHAR(16),
|
||
inn VARCHAR(12),
|
||
kyc_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||
kyc_verified_at TIMESTAMP WITH TIME ZONE,
|
||
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
passport_data VARCHAR(255),
|
||
erc20 VARCHAR(255),
|
||
-- EXTENSION (custodial wallet support):
|
||
-- AES-256-GCM blob: IV(12) || ciphertext || authTag(16), base64. Master-key в Vault.
|
||
encrypted_mnemonic TEXT
|
||
);
|
||
|
||
-- Idempotent ALTERs для existing БД у которой нет extension-columns (только ADD если нет колонки)
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic') THEN
|
||
ALTER TABLE users ADD COLUMN encrypted_mnemonic TEXT;
|
||
END IF;
|
||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'erc20') THEN
|
||
ALTER TABLE users ADD COLUMN erc20 VARCHAR(255);
|
||
END IF;
|
||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'passport_data') THEN
|
||
ALTER TABLE users ADD COLUMN passport_data VARCHAR(255);
|
||
END IF;
|
||
END $$;
|
||
|
||
-- Constraint: blob size check (only ADDs if missing, никогда не DROP).
|
||
-- Floor 100 (worst-case 12-word 3-char mnemonic = 100 base64 chars).
|
||
-- Если оператор изменил этот constraint вручную — наш script его НЕ перезатрёт.
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN
|
||
ALTER TABLE users
|
||
ADD CONSTRAINT users_encrypted_mnemonic_size
|
||
CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 100 AND 512));
|
||
END IF;
|
||
END $$;
|
||
|
||
-- Case-insensitive email uniqueness (Alice@x.com ≠ alice@x.com → ACCOUNT HIJACKING fix)
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'users_email_lower_unique') THEN
|
||
CREATE UNIQUE INDEX users_email_lower_unique ON users (lower(email));
|
||
END IF;
|
||
END $$;
|
||
|
||
-- Partial index для active-user queries
|
||
CREATE INDEX IF NOT EXISTS idx_users_active ON users(id) WHERE is_deleted = FALSE;
|
||
|
||
-- erc20 format check (NULL or 0x + 40 hex)
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_erc20_format') THEN
|
||
ALTER TABLE users
|
||
ADD CONSTRAINT users_erc20_format
|
||
CHECK (erc20 IS NULL OR erc20 ~ '^0x[0-9a-fA-F]{40}$');
|
||
END IF;
|
||
END $$;
|
||
|
||
-- KYC consistency: verified=true requires verified_at NOT NULL
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_kyc_consistency') THEN
|
||
ALTER TABLE users
|
||
ADD CONSTRAINT users_kyc_consistency
|
||
CHECK ((kyc_verified = FALSE) OR (kyc_verified = TRUE AND kyc_verified_at IS NOT NULL));
|
||
END IF;
|
||
END $$;
|
||
|
||
-- ── WALLETS ─────────────────────────────────────────────────────────
|
||
-- ON DELETE RESTRICT: hard-delete user → запрос отвергнут пока есть wallets.
|
||
-- Это защита от unrecoverable fund loss при GDPR-wipe или admin удалении.
|
||
CREATE TABLE IF NOT EXISTS wallets (
|
||
id VARCHAR(26) NOT NULL PRIMARY KEY,
|
||
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||
chain VARCHAR(16) NOT NULL,
|
||
address VARCHAR(128) NOT NULL,
|
||
derivation_path VARCHAR(64) NOT NULL,
|
||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
UNIQUE (user_id, chain),
|
||
CHECK (chain IN ('ETH', 'BSC', 'BTC', 'TRX', 'SOL'))
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id);
|
||
CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address);
|
||
|
||
-- NOTE: если БД старая и wallets.user_id_fkey ON DELETE CASCADE (а нужен RESTRICT
|
||
-- для защиты от fund loss при delete user), оператор делает manual ОДИН раз:
|
||
--
|
||
-- ALTER TABLE wallets DROP CONSTRAINT wallets_user_id_fkey;
|
||
-- ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey
|
||
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;
|
||
--
|
||
-- Этот script ничего не дропает — re-run полностью non-destructive.
|