"""MSP Quote Wizard Tables Revision ID: 20260309_074038 Revises: a0dfb0b4373c Create Date: 2026-03-09 07:40:38 Creates the MSP Quote Wizard tables: - quotes: Main quote records with contact info, pricing, and tracking - quote_items: Line items for each quote (services, products, addons) - quote_activity: Activity log for quote interactions - quote_notifications: Email/webhook notification queue """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = '20260309_074038' down_revision: Union[str, None] = 'a0dfb0b4373c' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Create MSP Quote Wizard tables.""" # 1. Create quotes table - main quote records op.create_table( 'quotes', sa.Column('id', sa.CHAR(36), primary_key=True), sa.Column('company_name', sa.String(255), nullable=True), sa.Column('contact_name', sa.String(255), nullable=False), sa.Column('contact_email', sa.String(255), nullable=False), sa.Column('contact_phone', sa.String(50), nullable=True), sa.Column('employee_count', sa.Integer(), nullable=True), sa.Column('industry', sa.String(100), nullable=True), sa.Column('current_it_situation', sa.Text(), nullable=True), sa.Column('status', sa.Enum('draft', 'submitted', 'viewed', 'followed_up', 'converted', 'expired', name='quote_status'), server_default='draft'), sa.Column('access_token', sa.String(64), unique=True, nullable=False), sa.Column('monthly_total', sa.DECIMAL(10, 2), server_default='0'), sa.Column('setup_total', sa.DECIMAL(10, 2), server_default='0'), sa.Column('syncro_lead_id', sa.String(100), nullable=True), sa.Column('syncro_synced_at', sa.DateTime(), nullable=True), sa.Column('is_existing_customer', sa.Boolean(), server_default='0'), sa.Column('source', sa.String(50), server_default='website'), sa.Column('utm_source', sa.String(100), nullable=True), sa.Column('utm_medium', sa.String(100), nullable=True), sa.Column('utm_campaign', sa.String(100), nullable=True), sa.Column('ip_address', sa.String(45), nullable=True), sa.Column('user_agent', sa.Text(), nullable=True), sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')), sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')), sa.Column('submitted_at', sa.DateTime(), nullable=True), sa.Column('expires_at', sa.DateTime(), nullable=True), ) # Indexes for quotes table op.create_index('idx_quotes_status', 'quotes', ['status']) op.create_index('idx_quotes_email', 'quotes', ['contact_email']) op.create_index('idx_quotes_created', 'quotes', ['created_at']) op.create_index('idx_quotes_token', 'quotes', ['access_token']) # 2. Create quote_items table - line items for each quote op.create_table( 'quote_items', sa.Column('id', sa.CHAR(36), primary_key=True), sa.Column('quote_id', sa.CHAR(36), nullable=False), sa.Column('category', sa.Enum('gps_monitoring', 'support_plan', 'voip', 'web_hosting', 'email', 'hardware', 'addon', name='quote_item_category'), nullable=False), sa.Column('product_code', sa.String(50), nullable=False), sa.Column('product_name', sa.String(255), nullable=False), sa.Column('description', sa.Text(), nullable=True), sa.Column('quantity', sa.Integer(), server_default='1'), sa.Column('unit_price', sa.DECIMAL(10, 2), nullable=False), sa.Column('setup_price', sa.DECIMAL(10, 2), server_default='0'), sa.Column('billing_frequency', sa.Enum('monthly', 'yearly', 'one_time', name='billing_frequency'), server_default='monthly'), sa.Column('tier', sa.String(50), nullable=True), sa.Column('is_recommended', sa.Boolean(), server_default='0'), sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')), sa.ForeignKeyConstraint(['quote_id'], ['quotes.id'], ondelete='CASCADE'), ) # Indexes for quote_items table op.create_index('idx_quote_items_quote', 'quote_items', ['quote_id']) op.create_index('idx_quote_items_category', 'quote_items', ['category']) # 3. Create quote_activity table - activity log for quotes op.create_table( 'quote_activity', sa.Column('id', sa.CHAR(36), primary_key=True), sa.Column('quote_id', sa.CHAR(36), nullable=False), sa.Column('action', sa.String(50), nullable=False), sa.Column('step_name', sa.String(50), nullable=True), sa.Column('details', sa.JSON(), nullable=True), sa.Column('ip_address', sa.String(45), nullable=True), sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')), sa.ForeignKeyConstraint(['quote_id'], ['quotes.id'], ondelete='CASCADE'), ) # Index for quote_activity table op.create_index('idx_quote_activity_quote', 'quote_activity', ['quote_id']) # 4. Create quote_notifications table - notification queue op.create_table( 'quote_notifications', sa.Column('id', sa.CHAR(36), primary_key=True), sa.Column('quote_id', sa.CHAR(36), nullable=False), sa.Column('notification_type', sa.Enum('email', 'webhook', name='notification_type'), nullable=False), sa.Column('recipient', sa.String(255), nullable=False), sa.Column('subject', sa.String(255), nullable=True), sa.Column('body', sa.Text(), nullable=True), sa.Column('status', sa.Enum('pending', 'sent', 'failed', name='notification_status'), server_default='pending'), sa.Column('attempts', sa.Integer(), server_default='0'), sa.Column('last_attempt_at', sa.DateTime(), nullable=True), sa.Column('sent_at', sa.DateTime(), nullable=True), sa.Column('error_message', sa.Text(), nullable=True), sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')), sa.ForeignKeyConstraint(['quote_id'], ['quotes.id'], ondelete='CASCADE'), ) # Indexes for quote_notifications table op.create_index('idx_notifications_status', 'quote_notifications', ['status']) op.create_index('idx_notifications_quote', 'quote_notifications', ['quote_id']) def downgrade() -> None: """Drop MSP Quote Wizard tables in reverse order.""" # Drop quote_notifications and its indexes op.drop_index('idx_notifications_quote', table_name='quote_notifications') op.drop_index('idx_notifications_status', table_name='quote_notifications') op.drop_table('quote_notifications') # Drop quote_activity and its index op.drop_index('idx_quote_activity_quote', table_name='quote_activity') op.drop_table('quote_activity') # Drop quote_items and its indexes op.drop_index('idx_quote_items_category', table_name='quote_items') op.drop_index('idx_quote_items_quote', table_name='quote_items') op.drop_table('quote_items') # Drop quotes and its indexes op.drop_index('idx_quotes_token', table_name='quotes') op.drop_index('idx_quotes_created', table_name='quotes') op.drop_index('idx_quotes_email', table_name='quotes') op.drop_index('idx_quotes_status', table_name='quotes') op.drop_table('quotes') # Drop the enum types op.execute("DROP TYPE IF EXISTS notification_status") op.execute("DROP TYPE IF EXISTS notification_type") op.execute("DROP TYPE IF EXISTS billing_frequency") op.execute("DROP TYPE IF EXISTS quote_item_category") op.execute("DROP TYPE IF EXISTS quote_status")