refactor(migrations): squash 75 migrations into 12 per-module initial migrations

The old migration chain was broken (downgrade path through vendor->merchant
rename made rollbacks impossible). This squashes everything into fresh
per-module migrations with zero schema drift, verified by autogenerate.

Changes:
- Replace 75 accumulated migrations with 12 per-module initial migrations
  (core, billing, catalog, marketplace, cms, customers, orders, inventory,
  cart, messaging, loyalty, dev_tools) in a linear chain
- Fix make db-reset to use SQL DROP SCHEMA instead of alembic downgrade base
- Enable migration autodiscovery for all modules (migrations_path in definitions)
- Rewrite alembic/env.py to import all 75 model tables across 13 modules
- Fix AdminNotification import (was incorrectly from tenancy, now from messaging)
- Update squash_migrations.py to handle all module migration directories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 11:51:37 +01:00
parent dad02695f6
commit c3d26e9aa4
111 changed files with 2285 additions and 11723 deletions

View File

@@ -1,650 +0,0 @@
"""add loyalty module tables
Revision ID: 0fb5d6d6ff97
Revises: zd3n4o5p6q7r8
Create Date: 2026-01-28 22:55:34.074321
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision: str = '0fb5d6d6ff97'
down_revision: Union[str, None] = 'zd3n4o5p6q7r8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('loyalty_programs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('store_id', sa.Integer(), nullable=False),
sa.Column('loyalty_type', sa.String(length=20), nullable=False),
sa.Column('stamps_target', sa.Integer(), nullable=False, comment='Number of stamps needed for reward'),
sa.Column('stamps_reward_description', sa.String(length=255), nullable=False, comment='Description of stamp reward'),
sa.Column('stamps_reward_value_cents', sa.Integer(), nullable=True, comment='Value of stamp reward in cents (for analytics)'),
sa.Column('points_per_euro', sa.Integer(), nullable=False, comment='Points earned per euro spent'),
sa.Column('points_rewards', sqlite.JSON(), nullable=False, comment='List of point rewards: [{id, name, points_required, description}]'),
sa.Column('cooldown_minutes', sa.Integer(), nullable=False, comment='Minutes between stamps for same card'),
sa.Column('max_daily_stamps', sa.Integer(), nullable=False, comment='Maximum stamps per card per day'),
sa.Column('require_staff_pin', sa.Boolean(), nullable=False, comment='Require staff PIN for stamp/points operations'),
sa.Column('card_name', sa.String(length=100), nullable=True, comment='Display name for loyalty card'),
sa.Column('card_color', sa.String(length=7), nullable=False, comment='Primary color for card (hex)'),
sa.Column('card_secondary_color', sa.String(length=7), nullable=True, comment='Secondary color for card (hex)'),
sa.Column('logo_url', sa.String(length=500), nullable=True, comment='URL to store logo for card'),
sa.Column('hero_image_url', sa.String(length=500), nullable=True, comment='URL to hero image for card'),
sa.Column('google_issuer_id', sa.String(length=100), nullable=True, comment='Google Wallet Issuer ID'),
sa.Column('google_class_id', sa.String(length=200), nullable=True, comment='Google Wallet Loyalty Class ID'),
sa.Column('apple_pass_type_id', sa.String(length=100), nullable=True, comment='Apple Wallet Pass Type ID'),
sa.Column('terms_text', sa.Text(), nullable=True, comment='Loyalty program terms and conditions'),
sa.Column('privacy_url', sa.String(length=500), nullable=True, comment='URL to privacy policy'),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('activated_at', sa.DateTime(timezone=True), nullable=True, comment='When program was first activated'),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_loyalty_program_store_active', 'loyalty_programs', ['store_id', 'is_active'], unique=False)
op.create_index(op.f('ix_loyalty_programs_id'), 'loyalty_programs', ['id'], unique=False)
op.create_index(op.f('ix_loyalty_programs_is_active'), 'loyalty_programs', ['is_active'], unique=False)
op.create_index(op.f('ix_loyalty_programs_store_id'), 'loyalty_programs', ['store_id'], unique=True)
op.create_table('loyalty_cards',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('customer_id', sa.Integer(), nullable=False),
sa.Column('program_id', sa.Integer(), nullable=False),
sa.Column('store_id', sa.Integer(), nullable=False, comment='Denormalized for query performance'),
sa.Column('card_number', sa.String(length=20), nullable=False, comment='Human-readable card number'),
sa.Column('qr_code_data', sa.String(length=50), nullable=False, comment='Data encoded in QR code for scanning'),
sa.Column('stamp_count', sa.Integer(), nullable=False, comment='Current stamps toward next reward'),
sa.Column('total_stamps_earned', sa.Integer(), nullable=False, comment='Lifetime stamps earned'),
sa.Column('stamps_redeemed', sa.Integer(), nullable=False, comment='Total rewards redeemed (stamps reset on redemption)'),
sa.Column('points_balance', sa.Integer(), nullable=False, comment='Current available points'),
sa.Column('total_points_earned', sa.Integer(), nullable=False, comment='Lifetime points earned'),
sa.Column('points_redeemed', sa.Integer(), nullable=False, comment='Lifetime points redeemed'),
sa.Column('google_object_id', sa.String(length=200), nullable=True, comment='Google Wallet Loyalty Object ID'),
sa.Column('google_object_jwt', sa.String(length=2000), nullable=True, comment="JWT for Google Wallet 'Add to Wallet' button"),
sa.Column('apple_serial_number', sa.String(length=100), nullable=True, comment='Apple Wallet pass serial number'),
sa.Column('apple_auth_token', sa.String(length=100), nullable=True, comment='Apple Wallet authentication token for updates'),
sa.Column('last_stamp_at', sa.DateTime(timezone=True), nullable=True, comment='Last stamp added (for cooldown)'),
sa.Column('last_points_at', sa.DateTime(timezone=True), nullable=True, comment='Last points earned'),
sa.Column('last_redemption_at', sa.DateTime(timezone=True), nullable=True, comment='Last reward redemption'),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['program_id'], ['loyalty_programs.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_loyalty_card_customer_program', 'loyalty_cards', ['customer_id', 'program_id'], unique=True)
op.create_index('idx_loyalty_card_store_active', 'loyalty_cards', ['store_id', 'is_active'], unique=False)
op.create_index(op.f('ix_loyalty_cards_apple_serial_number'), 'loyalty_cards', ['apple_serial_number'], unique=True)
op.create_index(op.f('ix_loyalty_cards_card_number'), 'loyalty_cards', ['card_number'], unique=True)
op.create_index(op.f('ix_loyalty_cards_customer_id'), 'loyalty_cards', ['customer_id'], unique=False)
op.create_index(op.f('ix_loyalty_cards_google_object_id'), 'loyalty_cards', ['google_object_id'], unique=False)
op.create_index(op.f('ix_loyalty_cards_id'), 'loyalty_cards', ['id'], unique=False)
op.create_index(op.f('ix_loyalty_cards_is_active'), 'loyalty_cards', ['is_active'], unique=False)
op.create_index(op.f('ix_loyalty_cards_program_id'), 'loyalty_cards', ['program_id'], unique=False)
op.create_index(op.f('ix_loyalty_cards_qr_code_data'), 'loyalty_cards', ['qr_code_data'], unique=True)
op.create_index(op.f('ix_loyalty_cards_store_id'), 'loyalty_cards', ['store_id'], unique=False)
op.create_table('staff_pins',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('program_id', sa.Integer(), nullable=False),
sa.Column('store_id', sa.Integer(), nullable=False, comment='Denormalized for query performance'),
sa.Column('name', sa.String(length=100), nullable=False, comment='Staff member name'),
sa.Column('staff_id', sa.String(length=50), nullable=True, comment='Optional staff ID/employee number'),
sa.Column('pin_hash', sa.String(length=255), nullable=False, comment='bcrypt hash of PIN'),
sa.Column('failed_attempts', sa.Integer(), nullable=False, comment='Consecutive failed PIN attempts'),
sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True, comment='Lockout expires at this time'),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True, comment='Last successful use of PIN'),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['program_id'], ['loyalty_programs.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_staff_pin_program_active', 'staff_pins', ['program_id', 'is_active'], unique=False)
op.create_index('idx_staff_pin_store_active', 'staff_pins', ['store_id', 'is_active'], unique=False)
op.create_index(op.f('ix_staff_pins_id'), 'staff_pins', ['id'], unique=False)
op.create_index(op.f('ix_staff_pins_is_active'), 'staff_pins', ['is_active'], unique=False)
op.create_index(op.f('ix_staff_pins_program_id'), 'staff_pins', ['program_id'], unique=False)
op.create_index(op.f('ix_staff_pins_staff_id'), 'staff_pins', ['staff_id'], unique=False)
op.create_index(op.f('ix_staff_pins_store_id'), 'staff_pins', ['store_id'], unique=False)
op.create_table('apple_device_registrations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('card_id', sa.Integer(), nullable=False),
sa.Column('device_library_identifier', sa.String(length=100), nullable=False, comment='Unique identifier for the device/library'),
sa.Column('push_token', sa.String(length=100), nullable=False, comment='APNs push token for this device'),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['card_id'], ['loyalty_cards.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_apple_device_card', 'apple_device_registrations', ['device_library_identifier', 'card_id'], unique=True)
op.create_index(op.f('ix_apple_device_registrations_card_id'), 'apple_device_registrations', ['card_id'], unique=False)
op.create_index(op.f('ix_apple_device_registrations_device_library_identifier'), 'apple_device_registrations', ['device_library_identifier'], unique=False)
op.create_index(op.f('ix_apple_device_registrations_id'), 'apple_device_registrations', ['id'], unique=False)
op.create_table('loyalty_transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('card_id', sa.Integer(), nullable=False),
sa.Column('store_id', sa.Integer(), nullable=False, comment='Denormalized for query performance'),
sa.Column('staff_pin_id', sa.Integer(), nullable=True, comment='Staff PIN used for this operation'),
sa.Column('transaction_type', sa.String(length=30), nullable=False),
sa.Column('stamps_delta', sa.Integer(), nullable=False, comment='Change in stamps (+1 for earn, -N for redeem)'),
sa.Column('points_delta', sa.Integer(), nullable=False, comment='Change in points (+N for earn, -N for redeem)'),
sa.Column('stamps_balance_after', sa.Integer(), nullable=True, comment='Stamp count after this transaction'),
sa.Column('points_balance_after', sa.Integer(), nullable=True, comment='Points balance after this transaction'),
sa.Column('purchase_amount_cents', sa.Integer(), nullable=True, comment='Purchase amount in cents (for points calculation)'),
sa.Column('order_reference', sa.String(length=100), nullable=True, comment='Reference to order that triggered points'),
sa.Column('reward_id', sa.String(length=50), nullable=True, comment='ID of redeemed reward (from program.points_rewards)'),
sa.Column('reward_description', sa.String(length=255), nullable=True, comment='Description of redeemed reward'),
sa.Column('ip_address', sa.String(length=45), nullable=True, comment='IP address of requester (IPv4 or IPv6)'),
sa.Column('user_agent', sa.String(length=500), nullable=True, comment='User agent string'),
sa.Column('notes', sa.Text(), nullable=True, comment='Additional notes (e.g., reason for adjustment)'),
sa.Column('transaction_at', sa.DateTime(timezone=True), nullable=False, comment='When the transaction occurred (may differ from created_at)'),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['card_id'], ['loyalty_cards.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['staff_pin_id'], ['staff_pins.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_loyalty_tx_card_type', 'loyalty_transactions', ['card_id', 'transaction_type'], unique=False)
op.create_index('idx_loyalty_tx_type_date', 'loyalty_transactions', ['transaction_type', 'transaction_at'], unique=False)
op.create_index('idx_loyalty_tx_store_date', 'loyalty_transactions', ['store_id', 'transaction_at'], unique=False)
op.create_index(op.f('ix_loyalty_transactions_card_id'), 'loyalty_transactions', ['card_id'], unique=False)
op.create_index(op.f('ix_loyalty_transactions_id'), 'loyalty_transactions', ['id'], unique=False)
op.create_index(op.f('ix_loyalty_transactions_order_reference'), 'loyalty_transactions', ['order_reference'], unique=False)
op.create_index(op.f('ix_loyalty_transactions_staff_pin_id'), 'loyalty_transactions', ['staff_pin_id'], unique=False)
op.create_index(op.f('ix_loyalty_transactions_transaction_at'), 'loyalty_transactions', ['transaction_at'], unique=False)
op.create_index(op.f('ix_loyalty_transactions_transaction_type'), 'loyalty_transactions', ['transaction_type'], unique=False)
op.create_index(op.f('ix_loyalty_transactions_store_id'), 'loyalty_transactions', ['store_id'], unique=False)
op.alter_column('admin_menu_configs', 'platform_id',
existing_type=sa.INTEGER(),
comment='Platform scope - applies to users/stores of this platform',
existing_comment='Platform scope - applies to all platform admins of this platform',
existing_nullable=True)
op.alter_column('admin_menu_configs', 'user_id',
existing_type=sa.INTEGER(),
comment='User scope - applies to this specific super admin (admin frontend only)',
existing_comment='User scope - applies to this specific super admin',
existing_nullable=True)
op.alter_column('admin_menu_configs', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('admin_menu_configs', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index('idx_admin_menu_configs_frontend_type', table_name='admin_menu_configs')
op.drop_index('idx_admin_menu_configs_menu_item_id', table_name='admin_menu_configs')
op.drop_index('idx_admin_menu_configs_platform_id', table_name='admin_menu_configs')
op.drop_index('idx_admin_menu_configs_user_id', table_name='admin_menu_configs')
op.create_index(op.f('ix_admin_menu_configs_frontend_type'), 'admin_menu_configs', ['frontend_type'], unique=False)
op.create_index(op.f('ix_admin_menu_configs_id'), 'admin_menu_configs', ['id'], unique=False)
op.create_index(op.f('ix_admin_menu_configs_menu_item_id'), 'admin_menu_configs', ['menu_item_id'], unique=False)
op.create_index(op.f('ix_admin_menu_configs_platform_id'), 'admin_menu_configs', ['platform_id'], unique=False)
op.create_index(op.f('ix_admin_menu_configs_user_id'), 'admin_menu_configs', ['user_id'], unique=False)
op.alter_column('admin_platforms', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('admin_platforms', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index('idx_admin_platforms_platform_id', table_name='admin_platforms')
op.drop_index('idx_admin_platforms_user_id', table_name='admin_platforms')
op.create_index(op.f('ix_admin_platforms_id'), 'admin_platforms', ['id'], unique=False)
op.create_index(op.f('ix_admin_platforms_platform_id'), 'admin_platforms', ['platform_id'], unique=False)
op.create_index(op.f('ix_admin_platforms_user_id'), 'admin_platforms', ['user_id'], unique=False)
op.alter_column('content_pages', 'platform_id',
existing_type=sa.INTEGER(),
comment='Platform this page belongs to',
existing_nullable=False)
op.alter_column('content_pages', 'store_id',
existing_type=sa.INTEGER(),
comment='Store this page belongs to (NULL for platform/default pages)',
existing_nullable=True)
op.alter_column('content_pages', 'is_platform_page',
existing_type=sa.BOOLEAN(),
comment='True = platform marketing page (homepage, pricing); False = store default or override',
existing_nullable=False,
existing_server_default=sa.text('false'))
op.alter_column('platform_modules', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('platform_modules', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.create_index(op.f('ix_platform_modules_id'), 'platform_modules', ['id'], unique=False)
op.alter_column('platforms', 'code',
existing_type=sa.VARCHAR(length=50),
comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')",
existing_nullable=False)
op.alter_column('platforms', 'name',
existing_type=sa.VARCHAR(length=100),
comment="Display name (e.g., 'Wizamart OMS')",
existing_nullable=False)
op.alter_column('platforms', 'description',
existing_type=sa.TEXT(),
comment='Platform description for admin/marketing purposes',
existing_nullable=True)
op.alter_column('platforms', 'domain',
existing_type=sa.VARCHAR(length=255),
comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')",
existing_nullable=True)
op.alter_column('platforms', 'path_prefix',
existing_type=sa.VARCHAR(length=50),
comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)",
existing_nullable=True)
op.alter_column('platforms', 'logo',
existing_type=sa.VARCHAR(length=500),
comment='Logo URL for light mode',
existing_nullable=True)
op.alter_column('platforms', 'logo_dark',
existing_type=sa.VARCHAR(length=500),
comment='Logo URL for dark mode',
existing_nullable=True)
op.alter_column('platforms', 'favicon',
existing_type=sa.VARCHAR(length=500),
comment='Favicon URL',
existing_nullable=True)
op.alter_column('platforms', 'theme_config',
existing_type=postgresql.JSON(astext_type=sa.Text()),
comment='Theme configuration (colors, fonts, etc.)',
existing_nullable=True)
op.alter_column('platforms', 'default_language',
existing_type=sa.VARCHAR(length=5),
comment="Default language code (e.g., 'fr', 'en', 'de')",
existing_nullable=False,
existing_server_default=sa.text("'fr'::character varying"))
op.alter_column('platforms', 'supported_languages',
existing_type=postgresql.JSON(astext_type=sa.Text()),
comment='List of supported language codes',
existing_nullable=False)
op.alter_column('platforms', 'is_active',
existing_type=sa.BOOLEAN(),
comment='Whether the platform is active and accessible',
existing_nullable=False,
existing_server_default=sa.text('true'))
op.alter_column('platforms', 'is_public',
existing_type=sa.BOOLEAN(),
comment='Whether the platform is visible in public listings',
existing_nullable=False,
existing_server_default=sa.text('true'))
op.alter_column('platforms', 'settings',
existing_type=postgresql.JSON(astext_type=sa.Text()),
comment='Platform-specific settings and feature flags',
existing_nullable=True)
op.alter_column('platforms', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('platforms', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.create_index(op.f('ix_platforms_id'), 'platforms', ['id'], unique=False)
op.alter_column('subscription_tiers', 'platform_id',
existing_type=sa.INTEGER(),
comment='Platform this tier belongs to (NULL = global tier)',
existing_nullable=True)
op.alter_column('subscription_tiers', 'cms_pages_limit',
existing_type=sa.INTEGER(),
comment='Total CMS pages limit (NULL = unlimited)',
existing_nullable=True)
op.alter_column('subscription_tiers', 'cms_custom_pages_limit',
existing_type=sa.INTEGER(),
comment='Custom pages limit, excluding overrides (NULL = unlimited)',
existing_nullable=True)
op.drop_index('ix_subscription_tiers_code', table_name='subscription_tiers')
op.create_index(op.f('ix_subscription_tiers_code'), 'subscription_tiers', ['code'], unique=False)
op.alter_column('users', 'is_super_admin',
existing_type=sa.BOOLEAN(),
comment=None,
existing_comment='Whether this admin has access to all platforms (super admin)',
existing_nullable=False,
existing_server_default=sa.text('false'))
op.alter_column('store_platforms', 'store_id',
existing_type=sa.INTEGER(),
comment='Reference to the store',
existing_nullable=False)
op.alter_column('store_platforms', 'platform_id',
existing_type=sa.INTEGER(),
comment='Reference to the platform',
existing_nullable=False)
op.alter_column('store_platforms', 'tier_id',
existing_type=sa.INTEGER(),
comment='Platform-specific subscription tier',
existing_nullable=True)
op.alter_column('store_platforms', 'is_active',
existing_type=sa.BOOLEAN(),
comment='Whether the store is active on this platform',
existing_nullable=False,
existing_server_default=sa.text('true'))
op.alter_column('store_platforms', 'is_primary',
existing_type=sa.BOOLEAN(),
comment="Whether this is the store's primary platform",
existing_nullable=False,
existing_server_default=sa.text('false'))
op.alter_column('store_platforms', 'custom_subdomain',
existing_type=sa.VARCHAR(length=100),
comment='Platform-specific subdomain (if different from main subdomain)',
existing_nullable=True)
op.alter_column('store_platforms', 'settings',
existing_type=postgresql.JSON(astext_type=sa.Text()),
comment='Platform-specific store settings',
existing_nullable=True)
op.alter_column('store_platforms', 'joined_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
comment='When the store joined this platform',
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('store_platforms', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('store_platforms', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.create_index(op.f('ix_store_platforms_id'), 'store_platforms', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_store_platforms_id'), table_name='store_platforms')
op.alter_column('store_platforms', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('store_platforms', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('store_platforms', 'joined_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
comment=None,
existing_comment='When the store joined this platform',
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('store_platforms', 'settings',
existing_type=postgresql.JSON(astext_type=sa.Text()),
comment=None,
existing_comment='Platform-specific store settings',
existing_nullable=True)
op.alter_column('store_platforms', 'custom_subdomain',
existing_type=sa.VARCHAR(length=100),
comment=None,
existing_comment='Platform-specific subdomain (if different from main subdomain)',
existing_nullable=True)
op.alter_column('store_platforms', 'is_primary',
existing_type=sa.BOOLEAN(),
comment=None,
existing_comment="Whether this is the store's primary platform",
existing_nullable=False,
existing_server_default=sa.text('false'))
op.alter_column('store_platforms', 'is_active',
existing_type=sa.BOOLEAN(),
comment=None,
existing_comment='Whether the store is active on this platform',
existing_nullable=False,
existing_server_default=sa.text('true'))
op.alter_column('store_platforms', 'tier_id',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Platform-specific subscription tier',
existing_nullable=True)
op.alter_column('store_platforms', 'platform_id',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Reference to the platform',
existing_nullable=False)
op.alter_column('store_platforms', 'store_id',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Reference to the store',
existing_nullable=False)
op.alter_column('users', 'is_super_admin',
existing_type=sa.BOOLEAN(),
comment='Whether this admin has access to all platforms (super admin)',
existing_nullable=False,
existing_server_default=sa.text('false'))
op.drop_index(op.f('ix_subscription_tiers_code'), table_name='subscription_tiers')
op.create_index('ix_subscription_tiers_code', 'subscription_tiers', ['code'], unique=True)
op.alter_column('subscription_tiers', 'cms_custom_pages_limit',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Custom pages limit, excluding overrides (NULL = unlimited)',
existing_nullable=True)
op.alter_column('subscription_tiers', 'cms_pages_limit',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Total CMS pages limit (NULL = unlimited)',
existing_nullable=True)
op.alter_column('subscription_tiers', 'platform_id',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Platform this tier belongs to (NULL = global tier)',
existing_nullable=True)
op.drop_index(op.f('ix_platforms_id'), table_name='platforms')
op.alter_column('platforms', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('platforms', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('platforms', 'settings',
existing_type=postgresql.JSON(astext_type=sa.Text()),
comment=None,
existing_comment='Platform-specific settings and feature flags',
existing_nullable=True)
op.alter_column('platforms', 'is_public',
existing_type=sa.BOOLEAN(),
comment=None,
existing_comment='Whether the platform is visible in public listings',
existing_nullable=False,
existing_server_default=sa.text('true'))
op.alter_column('platforms', 'is_active',
existing_type=sa.BOOLEAN(),
comment=None,
existing_comment='Whether the platform is active and accessible',
existing_nullable=False,
existing_server_default=sa.text('true'))
op.alter_column('platforms', 'supported_languages',
existing_type=postgresql.JSON(astext_type=sa.Text()),
comment=None,
existing_comment='List of supported language codes',
existing_nullable=False)
op.alter_column('platforms', 'default_language',
existing_type=sa.VARCHAR(length=5),
comment=None,
existing_comment="Default language code (e.g., 'fr', 'en', 'de')",
existing_nullable=False,
existing_server_default=sa.text("'fr'::character varying"))
op.alter_column('platforms', 'theme_config',
existing_type=postgresql.JSON(astext_type=sa.Text()),
comment=None,
existing_comment='Theme configuration (colors, fonts, etc.)',
existing_nullable=True)
op.alter_column('platforms', 'favicon',
existing_type=sa.VARCHAR(length=500),
comment=None,
existing_comment='Favicon URL',
existing_nullable=True)
op.alter_column('platforms', 'logo_dark',
existing_type=sa.VARCHAR(length=500),
comment=None,
existing_comment='Logo URL for dark mode',
existing_nullable=True)
op.alter_column('platforms', 'logo',
existing_type=sa.VARCHAR(length=500),
comment=None,
existing_comment='Logo URL for light mode',
existing_nullable=True)
op.alter_column('platforms', 'path_prefix',
existing_type=sa.VARCHAR(length=50),
comment=None,
existing_comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)",
existing_nullable=True)
op.alter_column('platforms', 'domain',
existing_type=sa.VARCHAR(length=255),
comment=None,
existing_comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')",
existing_nullable=True)
op.alter_column('platforms', 'description',
existing_type=sa.TEXT(),
comment=None,
existing_comment='Platform description for admin/marketing purposes',
existing_nullable=True)
op.alter_column('platforms', 'name',
existing_type=sa.VARCHAR(length=100),
comment=None,
existing_comment="Display name (e.g., 'Wizamart OMS')",
existing_nullable=False)
op.alter_column('platforms', 'code',
existing_type=sa.VARCHAR(length=50),
comment=None,
existing_comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')",
existing_nullable=False)
op.drop_index(op.f('ix_platform_modules_id'), table_name='platform_modules')
op.alter_column('platform_modules', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('platform_modules', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('content_pages', 'is_platform_page',
existing_type=sa.BOOLEAN(),
comment=None,
existing_comment='True = platform marketing page (homepage, pricing); False = store default or override',
existing_nullable=False,
existing_server_default=sa.text('false'))
op.alter_column('content_pages', 'store_id',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Store this page belongs to (NULL for platform/default pages)',
existing_nullable=True)
op.alter_column('content_pages', 'platform_id',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Platform this page belongs to',
existing_nullable=False)
op.drop_index(op.f('ix_admin_platforms_user_id'), table_name='admin_platforms')
op.drop_index(op.f('ix_admin_platforms_platform_id'), table_name='admin_platforms')
op.drop_index(op.f('ix_admin_platforms_id'), table_name='admin_platforms')
op.create_index('idx_admin_platforms_user_id', 'admin_platforms', ['user_id'], unique=False)
op.create_index('idx_admin_platforms_platform_id', 'admin_platforms', ['platform_id'], unique=False)
op.alter_column('admin_platforms', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('admin_platforms', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index(op.f('ix_admin_menu_configs_user_id'), table_name='admin_menu_configs')
op.drop_index(op.f('ix_admin_menu_configs_platform_id'), table_name='admin_menu_configs')
op.drop_index(op.f('ix_admin_menu_configs_menu_item_id'), table_name='admin_menu_configs')
op.drop_index(op.f('ix_admin_menu_configs_id'), table_name='admin_menu_configs')
op.drop_index(op.f('ix_admin_menu_configs_frontend_type'), table_name='admin_menu_configs')
op.create_index('idx_admin_menu_configs_user_id', 'admin_menu_configs', ['user_id'], unique=False)
op.create_index('idx_admin_menu_configs_platform_id', 'admin_menu_configs', ['platform_id'], unique=False)
op.create_index('idx_admin_menu_configs_menu_item_id', 'admin_menu_configs', ['menu_item_id'], unique=False)
op.create_index('idx_admin_menu_configs_frontend_type', 'admin_menu_configs', ['frontend_type'], unique=False)
op.alter_column('admin_menu_configs', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('admin_menu_configs', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('admin_menu_configs', 'user_id',
existing_type=sa.INTEGER(),
comment='User scope - applies to this specific super admin',
existing_comment='User scope - applies to this specific super admin (admin frontend only)',
existing_nullable=True)
op.alter_column('admin_menu_configs', 'platform_id',
existing_type=sa.INTEGER(),
comment='Platform scope - applies to all platform admins of this platform',
existing_comment='Platform scope - applies to users/stores of this platform',
existing_nullable=True)
op.drop_index(op.f('ix_loyalty_transactions_store_id'), table_name='loyalty_transactions')
op.drop_index(op.f('ix_loyalty_transactions_transaction_type'), table_name='loyalty_transactions')
op.drop_index(op.f('ix_loyalty_transactions_transaction_at'), table_name='loyalty_transactions')
op.drop_index(op.f('ix_loyalty_transactions_staff_pin_id'), table_name='loyalty_transactions')
op.drop_index(op.f('ix_loyalty_transactions_order_reference'), table_name='loyalty_transactions')
op.drop_index(op.f('ix_loyalty_transactions_id'), table_name='loyalty_transactions')
op.drop_index(op.f('ix_loyalty_transactions_card_id'), table_name='loyalty_transactions')
op.drop_index('idx_loyalty_tx_store_date', table_name='loyalty_transactions')
op.drop_index('idx_loyalty_tx_type_date', table_name='loyalty_transactions')
op.drop_index('idx_loyalty_tx_card_type', table_name='loyalty_transactions')
op.drop_table('loyalty_transactions')
op.drop_index(op.f('ix_apple_device_registrations_id'), table_name='apple_device_registrations')
op.drop_index(op.f('ix_apple_device_registrations_device_library_identifier'), table_name='apple_device_registrations')
op.drop_index(op.f('ix_apple_device_registrations_card_id'), table_name='apple_device_registrations')
op.drop_index('idx_apple_device_card', table_name='apple_device_registrations')
op.drop_table('apple_device_registrations')
op.drop_index(op.f('ix_staff_pins_store_id'), table_name='staff_pins')
op.drop_index(op.f('ix_staff_pins_staff_id'), table_name='staff_pins')
op.drop_index(op.f('ix_staff_pins_program_id'), table_name='staff_pins')
op.drop_index(op.f('ix_staff_pins_is_active'), table_name='staff_pins')
op.drop_index(op.f('ix_staff_pins_id'), table_name='staff_pins')
op.drop_index('idx_staff_pin_store_active', table_name='staff_pins')
op.drop_index('idx_staff_pin_program_active', table_name='staff_pins')
op.drop_table('staff_pins')
op.drop_index(op.f('ix_loyalty_cards_store_id'), table_name='loyalty_cards')
op.drop_index(op.f('ix_loyalty_cards_qr_code_data'), table_name='loyalty_cards')
op.drop_index(op.f('ix_loyalty_cards_program_id'), table_name='loyalty_cards')
op.drop_index(op.f('ix_loyalty_cards_is_active'), table_name='loyalty_cards')
op.drop_index(op.f('ix_loyalty_cards_id'), table_name='loyalty_cards')
op.drop_index(op.f('ix_loyalty_cards_google_object_id'), table_name='loyalty_cards')
op.drop_index(op.f('ix_loyalty_cards_customer_id'), table_name='loyalty_cards')
op.drop_index(op.f('ix_loyalty_cards_card_number'), table_name='loyalty_cards')
op.drop_index(op.f('ix_loyalty_cards_apple_serial_number'), table_name='loyalty_cards')
op.drop_index('idx_loyalty_card_store_active', table_name='loyalty_cards')
op.drop_index('idx_loyalty_card_customer_program', table_name='loyalty_cards')
op.drop_table('loyalty_cards')
op.drop_index(op.f('ix_loyalty_programs_store_id'), table_name='loyalty_programs')
op.drop_index(op.f('ix_loyalty_programs_is_active'), table_name='loyalty_programs')
op.drop_index(op.f('ix_loyalty_programs_id'), table_name='loyalty_programs')
op.drop_index('idx_loyalty_program_store_active', table_name='loyalty_programs')
op.drop_table('loyalty_programs')
# ### end Alembic commands ###

View File

@@ -0,0 +1,174 @@
"""loyalty initial - programs, cards, transactions, staff pins, apple devices, settings
Revision ID: loyalty_001
Revises: messaging_001
Create Date: 2026-02-07
"""
from alembic import op
import sqlalchemy as sa
revision = "loyalty_001"
down_revision = "messaging_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
# --- loyalty_programs ---
op.create_table(
"loyalty_programs",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), unique=True, nullable=False, index=True, comment="Merchant that owns this program (chain-wide)"),
sa.Column("loyalty_type", sa.String(20), nullable=False, server_default="points"),
sa.Column("stamps_target", sa.Integer(), nullable=False, server_default="10", comment="Number of stamps needed for reward"),
sa.Column("stamps_reward_description", sa.String(255), nullable=False, server_default="Free item", comment="Description of stamp reward"),
sa.Column("stamps_reward_value_cents", sa.Integer(), nullable=True, comment="Value of stamp reward in cents (for analytics)"),
sa.Column("points_per_euro", sa.Integer(), nullable=False, server_default="1", comment="Points earned per euro spent (1 euro = X points)"),
sa.Column("points_rewards", sa.JSON(), nullable=False, comment="List of point rewards: [{id, name, points_required, description}]"),
sa.Column("points_expiration_days", sa.Integer(), nullable=True, comment="Days of inactivity before points expire (None = never expire)"),
sa.Column("welcome_bonus_points", sa.Integer(), nullable=False, server_default="0", comment="Bonus points awarded on enrollment"),
sa.Column("minimum_redemption_points", sa.Integer(), nullable=False, server_default="100", comment="Minimum points required for any redemption"),
sa.Column("minimum_purchase_cents", sa.Integer(), nullable=False, server_default="0", comment="Minimum purchase amount (cents) to earn points (0 = no minimum)"),
sa.Column("tier_config", sa.JSON(), nullable=True, comment='Future: Tier thresholds {"bronze": 0, "silver": 1000, "gold": 5000}'),
sa.Column("cooldown_minutes", sa.Integer(), nullable=False, server_default="15", comment="Minutes between stamps for same card"),
sa.Column("max_daily_stamps", sa.Integer(), nullable=False, server_default="5", comment="Maximum stamps per card per day"),
sa.Column("require_staff_pin", sa.Boolean(), nullable=False, server_default="true", comment="Require staff PIN for stamp/points operations"),
sa.Column("card_name", sa.String(100), nullable=True, comment="Display name for loyalty card"),
sa.Column("card_color", sa.String(7), nullable=False, server_default="#4F46E5", comment="Primary color for card (hex)"),
sa.Column("card_secondary_color", sa.String(7), nullable=True, comment="Secondary color for card (hex)"),
sa.Column("logo_url", sa.String(500), nullable=True, comment="URL to merchant logo for card"),
sa.Column("hero_image_url", sa.String(500), nullable=True, comment="URL to hero image for card"),
sa.Column("google_issuer_id", sa.String(100), nullable=True, comment="Google Wallet Issuer ID"),
sa.Column("google_class_id", sa.String(200), nullable=True, comment="Google Wallet Loyalty Class ID"),
sa.Column("apple_pass_type_id", sa.String(100), nullable=True, comment="Apple Wallet Pass Type ID"),
sa.Column("terms_text", sa.Text(), nullable=True, comment="Loyalty program terms and conditions"),
sa.Column("privacy_url", sa.String(500), nullable=True, comment="URL to privacy policy"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", index=True),
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True, comment="When program was first activated"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("idx_loyalty_program_merchant_active", "loyalty_programs", ["merchant_id", "is_active"])
# --- staff_pins ---
op.create_table(
"staff_pins",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True, comment="Merchant that owns the loyalty program"),
sa.Column("program_id", sa.Integer(), sa.ForeignKey("loyalty_programs.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="CASCADE"), nullable=False, index=True, comment="Store (location) where this staff member works"),
sa.Column("name", sa.String(100), nullable=False, comment="Staff member name"),
sa.Column("staff_id", sa.String(50), nullable=True, index=True, comment="Optional staff ID/employee number"),
sa.Column("pin_hash", sa.String(255), nullable=False, comment="bcrypt hash of PIN"),
sa.Column("failed_attempts", sa.Integer(), nullable=False, server_default="0", comment="Consecutive failed PIN attempts"),
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True, comment="Lockout expires at this time"),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True, comment="Last successful use of PIN"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", index=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("idx_staff_pin_merchant_active", "staff_pins", ["merchant_id", "is_active"])
op.create_index("idx_staff_pin_store_active", "staff_pins", ["store_id", "is_active"])
op.create_index("idx_staff_pin_program_active", "staff_pins", ["program_id", "is_active"])
# --- loyalty_cards ---
op.create_table(
"loyalty_cards",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True, comment="Merchant whose program this card belongs to"),
sa.Column("customer_id", sa.Integer(), sa.ForeignKey("customers.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("program_id", sa.Integer(), sa.ForeignKey("loyalty_programs.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("enrolled_at_store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="SET NULL"), nullable=True, index=True, comment="Store where customer enrolled (for analytics)"),
sa.Column("card_number", sa.String(20), unique=True, nullable=False, index=True, comment="Human-readable card number (XXXX-XXXX-XXXX)"),
sa.Column("qr_code_data", sa.String(50), unique=True, nullable=False, index=True, comment="Data encoded in QR code for scanning"),
sa.Column("stamp_count", sa.Integer(), nullable=False, server_default="0", comment="Current stamps toward next reward"),
sa.Column("total_stamps_earned", sa.Integer(), nullable=False, server_default="0", comment="Lifetime stamps earned"),
sa.Column("stamps_redeemed", sa.Integer(), nullable=False, server_default="0", comment="Total rewards redeemed (stamps reset on redemption)"),
sa.Column("points_balance", sa.Integer(), nullable=False, server_default="0", comment="Current available points"),
sa.Column("total_points_earned", sa.Integer(), nullable=False, server_default="0", comment="Lifetime points earned"),
sa.Column("points_redeemed", sa.Integer(), nullable=False, server_default="0", comment="Lifetime points redeemed"),
sa.Column("google_object_id", sa.String(200), nullable=True, index=True, comment="Google Wallet Loyalty Object ID"),
sa.Column("google_object_jwt", sa.String(2000), nullable=True, comment="JWT for Google Wallet 'Add to Wallet' button"),
sa.Column("apple_serial_number", sa.String(100), unique=True, nullable=True, index=True, comment="Apple Wallet pass serial number"),
sa.Column("apple_auth_token", sa.String(100), nullable=True, comment="Apple Wallet authentication token for updates"),
sa.Column("last_stamp_at", sa.DateTime(timezone=True), nullable=True, comment="Last stamp added (for cooldown)"),
sa.Column("last_points_at", sa.DateTime(timezone=True), nullable=True, comment="Last points earned (for expiration tracking)"),
sa.Column("last_redemption_at", sa.DateTime(timezone=True), nullable=True, comment="Last reward redemption"),
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True, comment="Any activity (for expiration calculation)"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", index=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("idx_loyalty_card_merchant_customer", "loyalty_cards", ["merchant_id", "customer_id"], unique=True)
op.create_index("idx_loyalty_card_merchant_active", "loyalty_cards", ["merchant_id", "is_active"])
op.create_index("idx_loyalty_card_customer_program", "loyalty_cards", ["customer_id", "program_id"], unique=True)
# --- loyalty_transactions ---
op.create_table(
"loyalty_transactions",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True, comment="Merchant that owns the loyalty program"),
sa.Column("card_id", sa.Integer(), sa.ForeignKey("loyalty_cards.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="SET NULL"), nullable=True, index=True, comment="Store (location) that processed this transaction"),
sa.Column("staff_pin_id", sa.Integer(), sa.ForeignKey("staff_pins.id", ondelete="SET NULL"), nullable=True, index=True, comment="Staff PIN used for this operation"),
sa.Column("related_transaction_id", sa.Integer(), sa.ForeignKey("loyalty_transactions.id", ondelete="SET NULL"), nullable=True, index=True, comment="Original transaction (for voids/returns)"),
sa.Column("transaction_type", sa.String(30), nullable=False, index=True),
sa.Column("stamps_delta", sa.Integer(), nullable=False, server_default="0", comment="Change in stamps (+1 for earn, -N for redeem)"),
sa.Column("points_delta", sa.Integer(), nullable=False, server_default="0", comment="Change in points (+N for earn, -N for redeem)"),
sa.Column("stamps_balance_after", sa.Integer(), nullable=True, comment="Stamp count after this transaction"),
sa.Column("points_balance_after", sa.Integer(), nullable=True, comment="Points balance after this transaction"),
sa.Column("purchase_amount_cents", sa.Integer(), nullable=True, comment="Purchase amount in cents (for points calculation)"),
sa.Column("order_reference", sa.String(100), nullable=True, index=True, comment="Reference to order that triggered points"),
sa.Column("reward_id", sa.String(50), nullable=True, comment="ID of redeemed reward (from program.points_rewards)"),
sa.Column("reward_description", sa.String(255), nullable=True, comment="Description of redeemed reward"),
sa.Column("ip_address", sa.String(45), nullable=True, comment="IP address of requester (IPv4 or IPv6)"),
sa.Column("user_agent", sa.String(500), nullable=True, comment="User agent string"),
sa.Column("notes", sa.Text(), nullable=True, comment="Additional notes (e.g., reason for adjustment)"),
sa.Column("transaction_at", sa.DateTime(timezone=True), nullable=False, index=True, comment="When the transaction occurred (may differ from created_at)"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("idx_loyalty_tx_card_type", "loyalty_transactions", ["card_id", "transaction_type"])
op.create_index("idx_loyalty_tx_store_date", "loyalty_transactions", ["store_id", "transaction_at"])
op.create_index("idx_loyalty_tx_type_date", "loyalty_transactions", ["transaction_type", "transaction_at"])
op.create_index("idx_loyalty_tx_merchant_date", "loyalty_transactions", ["merchant_id", "transaction_at"])
op.create_index("idx_loyalty_tx_merchant_store", "loyalty_transactions", ["merchant_id", "store_id"])
# --- apple_device_registrations ---
op.create_table(
"apple_device_registrations",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("card_id", sa.Integer(), sa.ForeignKey("loyalty_cards.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("device_library_identifier", sa.String(100), nullable=False, index=True, comment="Unique identifier for the device/library"),
sa.Column("push_token", sa.String(100), nullable=False, comment="APNs push token for this device"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("idx_apple_device_card", "apple_device_registrations", ["device_library_identifier", "card_id"], unique=True)
# --- merchant_loyalty_settings ---
op.create_table(
"merchant_loyalty_settings",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), unique=True, nullable=False, index=True, comment="Merchant these settings apply to"),
sa.Column("staff_pin_policy", sa.String(20), nullable=False, server_default="required", comment="Staff PIN policy: required, optional, disabled"),
sa.Column("staff_pin_lockout_attempts", sa.Integer(), nullable=False, server_default="5", comment="Max failed PIN attempts before lockout"),
sa.Column("staff_pin_lockout_minutes", sa.Integer(), nullable=False, server_default="30", comment="Lockout duration in minutes"),
sa.Column("allow_self_enrollment", sa.Boolean(), nullable=False, server_default="true", comment="Allow customers to self-enroll via QR code"),
sa.Column("allow_void_transactions", sa.Boolean(), nullable=False, server_default="true", comment="Allow voiding points for returns"),
sa.Column("allow_cross_location_redemption", sa.Boolean(), nullable=False, server_default="true", comment="Allow redemption at any merchant location"),
sa.Column("require_order_reference", sa.Boolean(), nullable=False, server_default="false", comment="Require order reference when earning points"),
sa.Column("log_ip_addresses", sa.Boolean(), nullable=False, server_default="true", comment="Log IP addresses for transactions"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("idx_merchant_loyalty_settings_merchant", "merchant_loyalty_settings", ["merchant_id"])
def downgrade() -> None:
op.drop_table("merchant_loyalty_settings")
op.drop_table("apple_device_registrations")
op.drop_table("loyalty_transactions")
op.drop_table("loyalty_cards")
op.drop_table("staff_pins")
op.drop_table("loyalty_programs")

View File

@@ -1,560 +0,0 @@
"""Phase 2: migrate loyalty module to merchant-based architecture
Revision ID: loyalty_003_phase2
Revises: 0fb5d6d6ff97
Create Date: 2026-02-06 20:30:00.000000
Phase 2 changes:
- loyalty_programs: store_id -> merchant_id (one program per merchant)
- loyalty_cards: add merchant_id, rename store_id -> enrolled_at_store_id
- loyalty_transactions: add merchant_id, add related_transaction_id, store_id nullable
- staff_pins: add merchant_id
- NEW TABLE: merchant_loyalty_settings
- NEW COLUMNS on loyalty_programs: points_expiration_days, welcome_bonus_points,
minimum_redemption_points, minimum_purchase_cents, tier_config
- NEW COLUMN on loyalty_cards: last_activity_at
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "loyalty_003_phase2"
down_revision: Union[str, None] = "0fb5d6d6ff97"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# =========================================================================
# 1. Create merchant_loyalty_settings table
# =========================================================================
op.create_table(
"merchant_loyalty_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("merchant_id", sa.Integer(), nullable=False),
sa.Column(
"staff_pin_policy",
sa.String(length=20),
nullable=False,
server_default="required",
),
sa.Column(
"staff_pin_lockout_attempts",
sa.Integer(),
nullable=False,
server_default="5",
),
sa.Column(
"staff_pin_lockout_minutes",
sa.Integer(),
nullable=False,
server_default="30",
),
sa.Column(
"allow_self_enrollment",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
sa.Column(
"allow_void_transactions",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
sa.Column(
"allow_cross_location_redemption",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
sa.Column(
"require_order_reference",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"log_ip_addresses",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["merchant_id"], ["merchants.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_merchant_loyalty_settings_id"),
"merchant_loyalty_settings",
["id"],
unique=False,
)
op.create_index(
op.f("ix_merchant_loyalty_settings_merchant_id"),
"merchant_loyalty_settings",
["merchant_id"],
unique=True,
)
# =========================================================================
# 2. Modify loyalty_programs: store_id -> merchant_id + new columns
# =========================================================================
# Add merchant_id (nullable first for data migration)
op.add_column(
"loyalty_programs", sa.Column("merchant_id", sa.Integer(), nullable=True)
)
# Migrate existing data: derive merchant_id from store_id
op.execute(
"""
UPDATE loyalty_programs lp
SET merchant_id = v.merchant_id
FROM stores v
WHERE v.id = lp.store_id
"""
)
# Make merchant_id non-nullable
op.alter_column("loyalty_programs", "merchant_id", nullable=False)
# Add FK and indexes
op.create_foreign_key(
"fk_loyalty_programs_merchant_id",
"loyalty_programs",
"merchants",
["merchant_id"],
["id"],
ondelete="CASCADE",
)
op.create_index(
op.f("ix_loyalty_programs_merchant_id"),
"loyalty_programs",
["merchant_id"],
unique=True,
)
op.create_index(
"idx_loyalty_program_merchant_active",
"loyalty_programs",
["merchant_id", "is_active"],
)
# Add new Phase 2 columns
op.add_column(
"loyalty_programs",
sa.Column("points_expiration_days", sa.Integer(), nullable=True),
)
op.add_column(
"loyalty_programs",
sa.Column(
"welcome_bonus_points",
sa.Integer(),
nullable=False,
server_default="0",
),
)
op.add_column(
"loyalty_programs",
sa.Column(
"minimum_redemption_points",
sa.Integer(),
nullable=False,
server_default="100",
),
)
op.add_column(
"loyalty_programs",
sa.Column(
"minimum_purchase_cents",
sa.Integer(),
nullable=False,
server_default="0",
),
)
op.add_column(
"loyalty_programs",
sa.Column("tier_config", sa.JSON(), nullable=True),
)
# Drop old store_id column and indexes
op.drop_index("idx_loyalty_program_store_active", table_name="loyalty_programs")
op.drop_index(
op.f("ix_loyalty_programs_store_id"), table_name="loyalty_programs"
)
op.drop_constraint(
"loyalty_programs_store_id_fkey", "loyalty_programs", type_="foreignkey"
)
op.drop_column("loyalty_programs", "store_id")
# =========================================================================
# 3. Modify loyalty_cards: add merchant_id, rename store_id
# =========================================================================
# Add merchant_id
op.add_column(
"loyalty_cards", sa.Column("merchant_id", sa.Integer(), nullable=True)
)
# Migrate data
op.execute(
"""
UPDATE loyalty_cards lc
SET merchant_id = v.merchant_id
FROM stores v
WHERE v.id = lc.store_id
"""
)
op.alter_column("loyalty_cards", "merchant_id", nullable=False)
op.create_foreign_key(
"fk_loyalty_cards_merchant_id",
"loyalty_cards",
"merchants",
["merchant_id"],
["id"],
ondelete="CASCADE",
)
op.create_index(
op.f("ix_loyalty_cards_merchant_id"),
"loyalty_cards",
["merchant_id"],
unique=False,
)
op.create_index(
"idx_loyalty_card_merchant_active",
"loyalty_cards",
["merchant_id", "is_active"],
)
op.create_index(
"idx_loyalty_card_merchant_customer",
"loyalty_cards",
["merchant_id", "customer_id"],
unique=True,
)
# Rename store_id -> enrolled_at_store_id, make nullable, change FK
op.drop_index("idx_loyalty_card_store_active", table_name="loyalty_cards")
op.drop_index(op.f("ix_loyalty_cards_store_id"), table_name="loyalty_cards")
op.drop_constraint(
"loyalty_cards_store_id_fkey", "loyalty_cards", type_="foreignkey"
)
op.alter_column(
"loyalty_cards",
"store_id",
new_column_name="enrolled_at_store_id",
nullable=True,
)
op.create_foreign_key(
"fk_loyalty_cards_enrolled_store",
"loyalty_cards",
"stores",
["enrolled_at_store_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(
op.f("ix_loyalty_cards_enrolled_at_store_id"),
"loyalty_cards",
["enrolled_at_store_id"],
unique=False,
)
# Add last_activity_at
op.add_column(
"loyalty_cards",
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
)
# =========================================================================
# 4. Modify loyalty_transactions: add merchant_id, related_transaction_id
# =========================================================================
# Add merchant_id
op.add_column(
"loyalty_transactions",
sa.Column("merchant_id", sa.Integer(), nullable=True),
)
# Migrate data (from card's merchant)
op.execute(
"""
UPDATE loyalty_transactions lt
SET merchant_id = lc.merchant_id
FROM loyalty_cards lc
WHERE lc.id = lt.card_id
"""
)
op.alter_column("loyalty_transactions", "merchant_id", nullable=False)
op.create_foreign_key(
"fk_loyalty_transactions_merchant_id",
"loyalty_transactions",
"merchants",
["merchant_id"],
["id"],
ondelete="CASCADE",
)
op.create_index(
op.f("ix_loyalty_transactions_merchant_id"),
"loyalty_transactions",
["merchant_id"],
unique=False,
)
op.create_index(
"idx_loyalty_tx_merchant_date",
"loyalty_transactions",
["merchant_id", "transaction_at"],
)
op.create_index(
"idx_loyalty_tx_merchant_store",
"loyalty_transactions",
["merchant_id", "store_id"],
)
# Make store_id nullable and change FK to SET NULL
op.drop_constraint(
"loyalty_transactions_store_id_fkey",
"loyalty_transactions",
type_="foreignkey",
)
op.alter_column("loyalty_transactions", "store_id", nullable=True)
op.create_foreign_key(
"fk_loyalty_transactions_store_id",
"loyalty_transactions",
"stores",
["store_id"],
["id"],
ondelete="SET NULL",
)
# Add related_transaction_id (for void linkage)
op.add_column(
"loyalty_transactions",
sa.Column("related_transaction_id", sa.Integer(), nullable=True),
)
op.create_foreign_key(
"fk_loyalty_tx_related",
"loyalty_transactions",
"loyalty_transactions",
["related_transaction_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(
op.f("ix_loyalty_transactions_related_transaction_id"),
"loyalty_transactions",
["related_transaction_id"],
unique=False,
)
# =========================================================================
# 5. Modify staff_pins: add merchant_id
# =========================================================================
op.add_column(
"staff_pins", sa.Column("merchant_id", sa.Integer(), nullable=True)
)
# Migrate data (from store's merchant)
op.execute(
"""
UPDATE staff_pins sp
SET merchant_id = v.merchant_id
FROM stores v
WHERE v.id = sp.store_id
"""
)
op.alter_column("staff_pins", "merchant_id", nullable=False)
op.create_foreign_key(
"fk_staff_pins_merchant_id",
"staff_pins",
"merchants",
["merchant_id"],
["id"],
ondelete="CASCADE",
)
op.create_index(
op.f("ix_staff_pins_merchant_id"),
"staff_pins",
["merchant_id"],
unique=False,
)
op.create_index(
"idx_staff_pin_merchant_active",
"staff_pins",
["merchant_id", "is_active"],
)
def downgrade() -> None:
# =========================================================================
# 5. Revert staff_pins
# =========================================================================
op.drop_index("idx_staff_pin_merchant_active", table_name="staff_pins")
op.drop_index(op.f("ix_staff_pins_merchant_id"), table_name="staff_pins")
op.drop_constraint("fk_staff_pins_merchant_id", "staff_pins", type_="foreignkey")
op.drop_column("staff_pins", "merchant_id")
# =========================================================================
# 4. Revert loyalty_transactions
# =========================================================================
op.drop_index(
op.f("ix_loyalty_transactions_related_transaction_id"),
table_name="loyalty_transactions",
)
op.drop_constraint(
"fk_loyalty_tx_related", "loyalty_transactions", type_="foreignkey"
)
op.drop_column("loyalty_transactions", "related_transaction_id")
op.drop_constraint(
"fk_loyalty_transactions_store_id",
"loyalty_transactions",
type_="foreignkey",
)
op.alter_column("loyalty_transactions", "store_id", nullable=False)
op.create_foreign_key(
"loyalty_transactions_store_id_fkey",
"loyalty_transactions",
"stores",
["store_id"],
["id"],
ondelete="CASCADE",
)
op.drop_index(
"idx_loyalty_tx_merchant_store", table_name="loyalty_transactions"
)
op.drop_index(
"idx_loyalty_tx_merchant_date", table_name="loyalty_transactions"
)
op.drop_index(
op.f("ix_loyalty_transactions_merchant_id"),
table_name="loyalty_transactions",
)
op.drop_constraint(
"fk_loyalty_transactions_merchant_id",
"loyalty_transactions",
type_="foreignkey",
)
op.drop_column("loyalty_transactions", "merchant_id")
# =========================================================================
# 3. Revert loyalty_cards
# =========================================================================
op.drop_column("loyalty_cards", "last_activity_at")
op.drop_index(
op.f("ix_loyalty_cards_enrolled_at_store_id"), table_name="loyalty_cards"
)
op.drop_constraint(
"fk_loyalty_cards_enrolled_store", "loyalty_cards", type_="foreignkey"
)
op.alter_column(
"loyalty_cards",
"enrolled_at_store_id",
new_column_name="store_id",
nullable=False,
)
op.create_foreign_key(
"loyalty_cards_store_id_fkey",
"loyalty_cards",
"stores",
["store_id"],
["id"],
ondelete="CASCADE",
)
op.create_index(
op.f("ix_loyalty_cards_store_id"),
"loyalty_cards",
["store_id"],
unique=False,
)
op.create_index(
"idx_loyalty_card_store_active",
"loyalty_cards",
["store_id", "is_active"],
)
op.drop_index(
"idx_loyalty_card_merchant_customer", table_name="loyalty_cards"
)
op.drop_index(
"idx_loyalty_card_merchant_active", table_name="loyalty_cards"
)
op.drop_index(
op.f("ix_loyalty_cards_merchant_id"), table_name="loyalty_cards"
)
op.drop_constraint(
"fk_loyalty_cards_merchant_id", "loyalty_cards", type_="foreignkey"
)
op.drop_column("loyalty_cards", "merchant_id")
# =========================================================================
# 2. Revert loyalty_programs
# =========================================================================
op.add_column(
"loyalty_programs",
sa.Column("store_id", sa.Integer(), nullable=True),
)
# Note: data migration back not possible if merchant had multiple stores
op.create_foreign_key(
"loyalty_programs_store_id_fkey",
"loyalty_programs",
"stores",
["store_id"],
["id"],
ondelete="CASCADE",
)
op.create_index(
op.f("ix_loyalty_programs_store_id"),
"loyalty_programs",
["store_id"],
unique=True,
)
op.create_index(
"idx_loyalty_program_store_active",
"loyalty_programs",
["store_id", "is_active"],
)
op.drop_column("loyalty_programs", "tier_config")
op.drop_column("loyalty_programs", "minimum_purchase_cents")
op.drop_column("loyalty_programs", "minimum_redemption_points")
op.drop_column("loyalty_programs", "welcome_bonus_points")
op.drop_column("loyalty_programs", "points_expiration_days")
op.drop_index(
"idx_loyalty_program_merchant_active", table_name="loyalty_programs"
)
op.drop_index(
op.f("ix_loyalty_programs_merchant_id"), table_name="loyalty_programs"
)
op.drop_constraint(
"fk_loyalty_programs_merchant_id", "loyalty_programs", type_="foreignkey"
)
op.drop_column("loyalty_programs", "merchant_id")
# =========================================================================
# 1. Drop merchant_loyalty_settings table
# =========================================================================
op.drop_index(
op.f("ix_merchant_loyalty_settings_merchant_id"),
table_name="merchant_loyalty_settings",
)
op.drop_index(
op.f("ix_merchant_loyalty_settings_id"),
table_name="merchant_loyalty_settings",
)
op.drop_table("merchant_loyalty_settings")