-- ╔══════════════════════════════════════════════════════════════════╗ -- ║ CryptoWallet API — Production DB schema (idempotent, custodial) ║ -- ║ Применять: psql -h -U postgres_user -d postgres -f ... ║ -- ║ Безопасно прогонять повторно на existing БД. ║ -- ╚══════════════════════════════════════════════════════════════════╝ -- NOTE: idempotency_keys + audit_log таблицы УДАЛЕНЫ из БД. -- - idempotency_keys → KeyDB (Redis cache), см. apps/api/src/config/redis.ts -- - audit_log → stdout-only (Docker logs / log-aggregator подбирает JSON lines) -- Migration ниже drop'ает их если они существуют от прошлой версии. 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 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 $$; -- Sanity check на blob size. Floor 100 (worst-case 12-word всё-3char mnemonic): DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN ALTER TABLE users DROP CONSTRAINT users_encrypted_mnemonic_size; END IF; ALTER TABLE users ADD CONSTRAINT users_encrypted_mnemonic_size CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 100 AND 512)); 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. 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); -- Idempotent FK migration: если raised на старой DB с CASCADE — поменять DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.referential_constraints WHERE constraint_name LIKE 'wallets_user_id_fkey%' AND delete_rule = 'CASCADE' ) THEN ALTER TABLE wallets DROP CONSTRAINT IF EXISTS wallets_user_id_fkey; ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT; END IF; END $$; -- ── DROP legacy tables (если existing БД от прошлой версии) ──────── -- idempotency_keys → KeyDB cache (apps/api/src/lib/idempotency.ts → Redis) -- audit_log → stdout-only (apps/api/src/lib/audit-log.ts) DROP TABLE IF EXISTS audit_log CASCADE; DROP TABLE IF EXISTS idempotency_keys CASCADE;