"""Complete initial database schema with email auth support Revision ID: 001_initial Revises: Create Date: 2025-12-11 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql from sqlalchemy import text revision = '001_initial' down_revision = None branch_labels = None depends_on = None def upgrade() -> None: # Create enum types conn = op.get_bind() enum_types = [ ('transaction_status', ['draft', 'pending_approval', 'executed', 'reversed']), ('member_role', ['owner', 'adult', 'member', 'child', 'read_only']), ('event_action', ['create', 'update', 'delete', 'confirm', 'execute', 'reverse']), ('family_role', ['owner', 'member', 'restricted']), ('account_type', ['card', 'cash', 'deposit', 'goal', 'other']), ('category_type', ['expense', 'income']), ('transaction_type', ['expense', 'income', 'transfer']), ('budget_period', ['daily', 'weekly', 'monthly', 'yearly']), ] for enum_name, enum_values in enum_types: result = conn.execute( text(f"SELECT EXISTS(SELECT 1 FROM pg_type WHERE typname = '{enum_name}')") ) if not result.scalar(): values_str = ', '.join(f"'{v}'" for v in enum_values) conn.execute(text(f"CREATE TYPE {enum_name} AS ENUM ({values_str})")) # Create users table with email support op.create_table( 'users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('telegram_id', sa.Integer(), nullable=True), sa.Column('email', sa.String(length=255), nullable=True), sa.Column('password_hash', sa.String(length=255), nullable=True), sa.Column('username', sa.String(length=255), nullable=True), sa.Column('first_name', sa.String(length=255), nullable=True), sa.Column('last_name', sa.String(length=255), nullable=True), sa.Column('phone', sa.String(length=20), nullable=True), sa.Column('email_verified', sa.Boolean(), nullable=False, server_default='false'), sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), sa.Column('last_login_at', sa.DateTime(), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False), sa.Column('last_activity', sa.DateTime(), nullable=True), sa.PrimaryKeyConstraint('id') ) op.create_index('ix_users_telegram_id', 'users', ['telegram_id'], unique=True, postgresql_where=sa.text("telegram_id IS NOT NULL")) op.create_index('ix_users_email', 'users', ['email'], unique=True, postgresql_where=sa.text("email IS NOT NULL")) op.create_index('ix_users_username', 'users', ['username']) # Create families table op.create_table( 'families', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=255), nullable=False), sa.Column('description', sa.String(length=1000), nullable=True), sa.Column('owner_id', sa.Integer(), nullable=False), sa.Column('currency', sa.String(length=3), nullable=False, server_default='USD'), sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['owner_id'], ['users.id']) ) op.create_index('ix_families_owner_id', 'families', ['owner_id']) # Create family_members table with RBAC op.create_table( 'family_members', sa.Column('id', sa.Integer(), nullable=False), sa.Column('family_id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('role', postgresql.ENUM('owner', 'adult', 'member', 'child', 'read_only', name='member_role', create_type=False), nullable=False, server_default='member'), sa.Column('permissions', postgresql.JSON(), nullable=False, server_default='{}'), sa.Column('status', sa.String(length=50), nullable=False, server_default='active'), sa.Column('joined_at', sa.DateTime(), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['family_id'], ['families.id']), sa.ForeignKeyConstraint(['user_id'], ['users.id']) ) op.create_index('ix_family_members_family_id', 'family_members', ['family_id']) op.create_index('ix_family_members_user_id', 'family_members', ['user_id']) # Create accounts table op.create_table( 'accounts', sa.Column('id', sa.Integer(), nullable=False), sa.Column('family_id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=255), nullable=False), sa.Column('type', postgresql.ENUM('card', 'cash', 'deposit', 'goal', 'other', name='account_type', create_type=False), nullable=False), sa.Column('balance', sa.Numeric(precision=15, scale=2), nullable=False, server_default='0.00'), sa.Column('currency', sa.String(length=3), nullable=False, server_default='USD'), sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['family_id'], ['families.id']) ) op.create_index('ix_accounts_family_id', 'accounts', ['family_id']) # Create categories table op.create_table( 'categories', sa.Column('id', sa.Integer(), nullable=False), sa.Column('family_id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=255), nullable=False), sa.Column('type', postgresql.ENUM('expense', 'income', name='category_type', create_type=False), nullable=False), sa.Column('icon', sa.String(length=50), nullable=True), sa.Column('color', sa.String(length=7), nullable=True), sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['family_id'], ['families.id']) ) op.create_index('ix_categories_family_id', 'categories', ['family_id']) # Create transactions table with status tracking op.create_table( 'transactions', sa.Column('id', sa.Integer(), nullable=False), sa.Column('account_id', sa.Integer(), nullable=False), sa.Column('category_id', sa.Integer(), nullable=True), sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('amount', sa.Numeric(precision=15, scale=2), nullable=False), sa.Column('type', postgresql.ENUM('expense', 'income', 'transfer', name='transaction_type', create_type=False), nullable=False), sa.Column('description', sa.String(length=500), nullable=True), sa.Column('status', postgresql.ENUM('draft', 'pending_approval', 'executed', 'reversed', name='transaction_status', create_type=False), nullable=False, server_default='executed'), sa.Column('confirmation_required', sa.Boolean(), nullable=False, server_default='false'), sa.Column('confirmation_token', sa.String(length=255), nullable=True), sa.Column('approved_by_id', sa.Integer(), nullable=True), sa.Column('approved_at', sa.DateTime(), nullable=True), sa.Column('reversed_at', sa.DateTime(), nullable=True), sa.Column('reversal_reason', sa.String(length=500), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['account_id'], ['accounts.id']), sa.ForeignKeyConstraint(['category_id'], ['categories.id']), sa.ForeignKeyConstraint(['user_id'], ['users.id']), sa.ForeignKeyConstraint(['approved_by_id'], ['users.id']) ) op.create_index('ix_transactions_account_id', 'transactions', ['account_id']) op.create_index('ix_transactions_user_id', 'transactions', ['user_id']) op.create_index('ix_transactions_created_at', 'transactions', ['created_at']) # Create budgets table op.create_table( 'budgets', sa.Column('id', sa.Integer(), nullable=False), sa.Column('family_id', sa.Integer(), nullable=False), sa.Column('category_id', sa.Integer(), nullable=False), sa.Column('limit_amount', sa.Numeric(precision=15, scale=2), nullable=False), sa.Column('period', postgresql.ENUM('daily', 'weekly', 'monthly', 'yearly', name='budget_period', create_type=False), nullable=False), sa.Column('start_date', sa.DateTime(), nullable=False), sa.Column('end_date', sa.DateTime(), nullable=True), sa.Column('alert_threshold', sa.Integer(), nullable=False, server_default='80'), sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['family_id'], ['families.id']), sa.ForeignKeyConstraint(['category_id'], ['categories.id']) ) op.create_index('ix_budgets_family_id', 'budgets', ['family_id']) # Create goals table op.create_table( 'goals', sa.Column('id', sa.Integer(), nullable=False), sa.Column('family_id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=255), nullable=False), sa.Column('description', sa.String(length=1000), nullable=True), sa.Column('target_amount', sa.Numeric(precision=15, scale=2), nullable=False), sa.Column('current_amount', sa.Numeric(precision=15, scale=2), nullable=False, server_default='0.00'), sa.Column('deadline', sa.DateTime(), nullable=True), sa.Column('priority', sa.Integer(), nullable=False, server_default='1'), sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['family_id'], ['families.id']) ) op.create_index('ix_goals_family_id', 'goals', ['family_id']) # Create sessions table for refresh tokens op.create_table( 'sessions', sa.Column('id', sa.String(length=36), nullable=False, primary_key=True), sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('refresh_token_hash', sa.String(length=255), nullable=False), sa.Column('device_id', sa.String(length=255), nullable=True), sa.Column('ip_address', sa.String(length=45), nullable=True), sa.Column('user_agent', sa.String(length=500), nullable=True), sa.Column('expires_at', sa.DateTime(), nullable=False), sa.Column('revoked_at', sa.DateTime(), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False), sa.ForeignKeyConstraint(['user_id'], ['users.id']) ) op.create_index('ix_sessions_user_id', 'sessions', ['user_id']) op.create_index('ix_sessions_expires_at', 'sessions', ['expires_at']) # Create audit_logs table op.create_table( 'audit_logs', sa.Column('id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=True), sa.Column('entity_type', sa.String(length=100), nullable=False), sa.Column('entity_id', sa.Integer(), nullable=False), sa.Column('action', postgresql.ENUM('create', 'update', 'delete', 'confirm', 'execute', 'reverse', name='event_action', create_type=False), nullable=False), sa.Column('changes', postgresql.JSON(), nullable=False), sa.Column('ip_address', sa.String(length=45), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id'), sa.ForeignKeyConstraint(['user_id'], ['users.id']) ) op.create_index('ix_audit_logs_user_id', 'audit_logs', ['user_id']) op.create_index('ix_audit_logs_created_at', 'audit_logs', ['created_at']) op.create_index('ix_audit_logs_entity', 'audit_logs', ['entity_type', 'entity_id']) def downgrade() -> None: # Drop all tables in reverse order op.drop_table('audit_logs') op.drop_table('sessions') op.drop_table('goals') op.drop_table('budgets') op.drop_table('transactions') op.drop_table('categories') op.drop_table('accounts') op.drop_table('family_members') op.drop_table('families') op.drop_table('users') # Drop enum types enum_types = [ 'transaction_status', 'member_role', 'event_action', 'family_role', 'account_type', 'category_type', 'transaction_type', 'budget_period' ] for enum_name in enum_types: op.execute(f"DROP TYPE IF EXISTS {enum_name} CASCADE")