feat(loyalty): implement complete loyalty module MVP

Add stamp-based and points-based loyalty programs for vendors with:

Database Models (5 tables):
- loyalty_programs: Vendor program configuration
- loyalty_cards: Customer cards with stamp/point balances
- loyalty_transactions: Immutable audit log
- staff_pins: Fraud prevention PINs (bcrypt hashed)
- apple_device_registrations: Apple Wallet push tokens

Services:
- program_service: Program CRUD and statistics
- card_service: Customer enrollment and card lookup
- stamp_service: Stamp operations with anti-fraud checks
- points_service: Points earning and redemption
- pin_service: Staff PIN management with lockout
- wallet_service: Unified wallet abstraction
- google_wallet_service: Google Wallet API integration
- apple_wallet_service: Apple Wallet .pkpass generation

API Routes:
- Admin: /api/v1/admin/loyalty/* (programs list, stats)
- Vendor: /api/v1/vendor/loyalty/* (stamp, points, cards, PINs)
- Public: /api/v1/loyalty/* (enrollment, Apple Web Service)

Anti-Fraud Features:
- Staff PIN verification (configurable per program)
- Cooldown period between stamps (default 15 min)
- Daily stamp limits (default 5/day)
- PIN lockout after failed attempts

Wallet Integration:
- Google Wallet: LoyaltyClass and LoyaltyObject management
- Apple Wallet: .pkpass generation with PKCS#7 signing
- Apple Web Service endpoints for device registration/updates

Also includes:
- Alembic migration for all tables with indexes
- Localization files (en, fr, de, lu)
- Module documentation
- Phase 2 interface and user journey plan

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 23:04:00 +01:00
parent fbcf07914e
commit b5a803cde8
44 changed files with 8073 additions and 0 deletions

View File

@@ -0,0 +1,650 @@
"""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('vendor_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 vendor 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(['vendor_id'], ['vendors.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_loyalty_program_vendor_active', 'loyalty_programs', ['vendor_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_vendor_id'), 'loyalty_programs', ['vendor_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('vendor_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(['vendor_id'], ['vendors.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_vendor_active', 'loyalty_cards', ['vendor_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_vendor_id'), 'loyalty_cards', ['vendor_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('vendor_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(['vendor_id'], ['vendors.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_vendor_active', 'staff_pins', ['vendor_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_vendor_id'), 'staff_pins', ['vendor_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('vendor_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(['vendor_id'], ['vendors.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_vendor_date', 'loyalty_transactions', ['vendor_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_vendor_id'), 'loyalty_transactions', ['vendor_id'], unique=False)
op.alter_column('admin_menu_configs', 'platform_id',
existing_type=sa.INTEGER(),
comment='Platform scope - applies to users/vendors 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', 'vendor_id',
existing_type=sa.INTEGER(),
comment='Vendor 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 = vendor 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('vendor_platforms', 'vendor_id',
existing_type=sa.INTEGER(),
comment='Reference to the vendor',
existing_nullable=False)
op.alter_column('vendor_platforms', 'platform_id',
existing_type=sa.INTEGER(),
comment='Reference to the platform',
existing_nullable=False)
op.alter_column('vendor_platforms', 'tier_id',
existing_type=sa.INTEGER(),
comment='Platform-specific subscription tier',
existing_nullable=True)
op.alter_column('vendor_platforms', 'is_active',
existing_type=sa.BOOLEAN(),
comment='Whether the vendor is active on this platform',
existing_nullable=False,
existing_server_default=sa.text('true'))
op.alter_column('vendor_platforms', 'is_primary',
existing_type=sa.BOOLEAN(),
comment="Whether this is the vendor's primary platform",
existing_nullable=False,
existing_server_default=sa.text('false'))
op.alter_column('vendor_platforms', 'custom_subdomain',
existing_type=sa.VARCHAR(length=100),
comment='Platform-specific subdomain (if different from main subdomain)',
existing_nullable=True)
op.alter_column('vendor_platforms', 'settings',
existing_type=postgresql.JSON(astext_type=sa.Text()),
comment='Platform-specific vendor settings',
existing_nullable=True)
op.alter_column('vendor_platforms', 'joined_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
comment='When the vendor joined this platform',
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('vendor_platforms', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('vendor_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_vendor_platforms_id'), 'vendor_platforms', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_vendor_platforms_id'), table_name='vendor_platforms')
op.alter_column('vendor_platforms', 'updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('vendor_platforms', 'created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('vendor_platforms', 'joined_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
comment=None,
existing_comment='When the vendor joined this platform',
existing_nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('vendor_platforms', 'settings',
existing_type=postgresql.JSON(astext_type=sa.Text()),
comment=None,
existing_comment='Platform-specific vendor settings',
existing_nullable=True)
op.alter_column('vendor_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('vendor_platforms', 'is_primary',
existing_type=sa.BOOLEAN(),
comment=None,
existing_comment="Whether this is the vendor's primary platform",
existing_nullable=False,
existing_server_default=sa.text('false'))
op.alter_column('vendor_platforms', 'is_active',
existing_type=sa.BOOLEAN(),
comment=None,
existing_comment='Whether the vendor is active on this platform',
existing_nullable=False,
existing_server_default=sa.text('true'))
op.alter_column('vendor_platforms', 'tier_id',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Platform-specific subscription tier',
existing_nullable=True)
op.alter_column('vendor_platforms', 'platform_id',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Reference to the platform',
existing_nullable=False)
op.alter_column('vendor_platforms', 'vendor_id',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Reference to the vendor',
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 = vendor default or override',
existing_nullable=False,
existing_server_default=sa.text('false'))
op.alter_column('content_pages', 'vendor_id',
existing_type=sa.INTEGER(),
comment=None,
existing_comment='Vendor 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/vendors of this platform',
existing_nullable=True)
op.drop_index(op.f('ix_loyalty_transactions_vendor_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_vendor_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_vendor_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_vendor_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_vendor_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_vendor_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_vendor_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_vendor_active', table_name='loyalty_programs')
op.drop_table('loyalty_programs')
# ### end Alembic commands ###