feat: Complete API authentication system with email & Telegram support
- Add email/password registration endpoint (/api/v1/auth/register) - Add JWT token endpoints for Telegram users (/api/v1/auth/token/get, /api/v1/auth/token/refresh-telegram) - Enhance User model to support both email and Telegram authentication - Fix JWT token handling: convert sub to string (RFC compliance with PyJWT 2.10.1+) - Fix bot API calls: filter None values from query parameters - Fix JWT extraction from Redis: handle both bytes and string returns - Add public endpoints to JWT middleware: /api/v1/auth/register, /api/v1/auth/token/* - Update bot commands: /register (one-tap), /link (account linking), /start (options) - Create complete database schema migration with email auth support - Remove deprecated version attribute from docker-compose.yml - Add service dependency: bot waits for web service startup Features: - Dual authentication: email/password OR Telegram ID - JWT tokens with 15-min access + 30-day refresh lifetime - Redis-based token storage with TTL - Comprehensive API documentation and integration guides - Test scripts and Python examples - Full deployment checklist Database changes: - User model: added email, password_hash, email_verified (nullable fields) - telegram_id now nullable to support email-only users - Complete schema with families, accounts, categories, transactions, budgets, goals Status: Production-ready with all tests passing
This commit is contained in:
258
migrations/versions/001_initial_complete_schema.py
Normal file
258
migrations/versions/001_initial_complete_schema.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user