diff --git a/Backend/alembic/versions/__pycache__/add_guest_profile_crm_tables.cpython-312.pyc b/Backend/alembic/versions/__pycache__/add_guest_profile_crm_tables.cpython-312.pyc new file mode 100644 index 00000000..12987963 Binary files /dev/null and b/Backend/alembic/versions/__pycache__/add_guest_profile_crm_tables.cpython-312.pyc differ diff --git a/Backend/alembic/versions/__pycache__/add_loyalty_system_tables.cpython-312.pyc b/Backend/alembic/versions/__pycache__/add_loyalty_system_tables.cpython-312.pyc new file mode 100644 index 00000000..2ed5f96f Binary files /dev/null and b/Backend/alembic/versions/__pycache__/add_loyalty_system_tables.cpython-312.pyc differ diff --git a/Backend/alembic/versions/add_guest_profile_crm_tables.py b/Backend/alembic/versions/add_guest_profile_crm_tables.py new file mode 100644 index 00000000..4aea4e73 --- /dev/null +++ b/Backend/alembic/versions/add_guest_profile_crm_tables.py @@ -0,0 +1,160 @@ +"""add guest profile crm tables + +Revision ID: add_guest_profile_crm +Revises: ff515d77abbe +Create Date: 2024-01-01 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'add_guest_profile_crm' +down_revision = '9bb08492a382' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add new fields to users table + op.add_column('users', sa.Column('is_vip', sa.Boolean(), nullable=False, server_default='0')) + op.add_column('users', sa.Column('lifetime_value', sa.Numeric(10, 2), nullable=True, server_default='0')) + op.add_column('users', sa.Column('satisfaction_score', sa.Numeric(3, 2), nullable=True)) + op.add_column('users', sa.Column('last_visit_date', sa.DateTime(), nullable=True)) + op.add_column('users', sa.Column('total_visits', sa.Integer(), nullable=False, server_default='0')) + + # Create guest_preferences table + op.create_table( + 'guest_preferences', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('preferred_room_location', sa.String(length=100), nullable=True), + sa.Column('preferred_floor', sa.Integer(), nullable=True), + sa.Column('preferred_room_type_id', sa.Integer(), nullable=True), + sa.Column('preferred_amenities', sa.JSON(), nullable=True), + sa.Column('special_requests', sa.Text(), nullable=True), + sa.Column('preferred_services', sa.JSON(), nullable=True), + sa.Column('preferred_contact_method', sa.String(length=50), nullable=True), + sa.Column('preferred_language', sa.String(length=10), nullable=True, server_default='en'), + sa.Column('dietary_restrictions', sa.JSON(), nullable=True), + sa.Column('additional_preferences', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['preferred_room_type_id'], ['room_types.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_guest_preferences_id'), 'guest_preferences', ['id'], unique=False) + op.create_index(op.f('ix_guest_preferences_user_id'), 'guest_preferences', ['user_id'], unique=False) + + # Create guest_notes table + op.create_table( + 'guest_notes', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('note', sa.Text(), nullable=False), + sa.Column('is_important', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('is_private', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_guest_notes_id'), 'guest_notes', ['id'], unique=False) + op.create_index(op.f('ix_guest_notes_user_id'), 'guest_notes', ['user_id'], unique=False) + + # Create guest_tags table + op.create_table( + 'guest_tags', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('color', sa.String(length=7), nullable=True, server_default='#3B82F6'), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_index(op.f('ix_guest_tags_id'), 'guest_tags', ['id'], unique=False) + op.create_index(op.f('ix_guest_tags_name'), 'guest_tags', ['name'], unique=True) + + # Create guest_tag_associations table + op.create_table( + 'guest_tag_associations', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('tag_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['tag_id'], ['guest_tags.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('user_id', 'tag_id') + ) + + # Create guest_communications table + op.create_table( + 'guest_communications', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('staff_id', sa.Integer(), nullable=True), + sa.Column('communication_type', sa.Enum('email', 'phone', 'sms', 'chat', 'in_person', 'other', name='communicationtype'), nullable=False), + sa.Column('direction', sa.Enum('inbound', 'outbound', name='communicationdirection'), nullable=False), + sa.Column('subject', sa.String(length=255), nullable=True), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('booking_id', sa.Integer(), nullable=True), + sa.Column('is_automated', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('communication_metadata', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ), + sa.ForeignKeyConstraint(['staff_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_guest_communications_id'), 'guest_communications', ['id'], unique=False) + op.create_index(op.f('ix_guest_communications_user_id'), 'guest_communications', ['user_id'], unique=False) + + # Create guest_segments table + op.create_table( + 'guest_segments', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('criteria', sa.JSON(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_index(op.f('ix_guest_segments_id'), 'guest_segments', ['id'], unique=False) + op.create_index(op.f('ix_guest_segments_name'), 'guest_segments', ['name'], unique=True) + + # Create guest_segment_associations table + op.create_table( + 'guest_segment_associations', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('segment_id', sa.Integer(), nullable=False), + sa.Column('assigned_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['segment_id'], ['guest_segments.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('user_id', 'segment_id') + ) + + +def downgrade() -> None: + # Drop tables in reverse order + op.drop_table('guest_segment_associations') + op.drop_table('guest_segments') + op.drop_table('guest_communications') + op.drop_table('guest_tag_associations') + op.drop_table('guest_tags') + op.drop_table('guest_notes') + op.drop_table('guest_preferences') + + # Remove columns from users table + op.drop_column('users', 'total_visits') + op.drop_column('users', 'last_visit_date') + op.drop_column('users', 'satisfaction_score') + op.drop_column('users', 'lifetime_value') + op.drop_column('users', 'is_vip') + diff --git a/Backend/alembic/versions/add_loyalty_system_tables.py b/Backend/alembic/versions/add_loyalty_system_tables.py new file mode 100644 index 00000000..06fb8aed --- /dev/null +++ b/Backend/alembic/versions/add_loyalty_system_tables.py @@ -0,0 +1,215 @@ +"""add loyalty system tables + +Revision ID: add_loyalty_tables_001 +Revises: ff515d77abbe +Create Date: 2024-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'add_loyalty_tables_001' +down_revision = 'ff515d77abbe' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create loyalty_tiers table + op.create_table( + 'loyalty_tiers', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('level', sa.Enum('bronze', 'silver', 'gold', 'platinum', name='tierlevel'), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('min_points', sa.Integer(), nullable=False, server_default='0'), + sa.Column('points_earn_rate', sa.Numeric(precision=5, scale=2), nullable=False, server_default='1.0'), + sa.Column('discount_percentage', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('benefits', sa.Text(), nullable=True), + sa.Column('icon', sa.String(length=255), nullable=True), + sa.Column('color', sa.String(length=50), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('level') + ) + op.create_index(op.f('ix_loyalty_tiers_id'), 'loyalty_tiers', ['id'], unique=False) + op.create_index(op.f('ix_loyalty_tiers_level'), 'loyalty_tiers', ['level'], unique=True) + + # Create user_loyalty table + op.create_table( + 'user_loyalty', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('tier_id', sa.Integer(), nullable=False), + sa.Column('total_points', sa.Integer(), nullable=False, server_default='0'), + sa.Column('lifetime_points', sa.Integer(), nullable=False, server_default='0'), + sa.Column('available_points', sa.Integer(), nullable=False, server_default='0'), + sa.Column('expired_points', sa.Integer(), nullable=False, server_default='0'), + sa.Column('referral_code', sa.String(length=50), nullable=True), + sa.Column('referral_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('birthday', sa.Date(), nullable=True), + sa.Column('anniversary_date', sa.Date(), nullable=True), + sa.Column('last_points_earned_date', sa.DateTime(), nullable=True), + sa.Column('tier_started_date', sa.DateTime(), nullable=True), + sa.Column('next_tier_points_needed', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tier_id'], ['loyalty_tiers.id']), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id') + ) + op.create_index(op.f('ix_user_loyalty_id'), 'user_loyalty', ['id'], unique=False) + op.create_index(op.f('ix_user_loyalty_user_id'), 'user_loyalty', ['user_id'], unique=True) + op.create_index(op.f('ix_user_loyalty_tier_id'), 'user_loyalty', ['tier_id'], unique=False) + op.create_index(op.f('ix_user_loyalty_referral_code'), 'user_loyalty', ['referral_code'], unique=True) + + # Create loyalty_point_transactions table + op.create_table( + 'loyalty_point_transactions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_loyalty_id', sa.Integer(), nullable=False), + sa.Column('booking_id', sa.Integer(), nullable=True), + sa.Column('transaction_type', sa.Enum('earned', 'redeemed', 'expired', 'bonus', 'adjustment', name='transactiontype'), nullable=False), + sa.Column('source', sa.Enum('booking', 'referral', 'birthday', 'anniversary', 'redemption', 'promotion', 'manual', name='transactionsource'), nullable=False), + sa.Column('points', sa.Integer(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('reference_number', sa.String(length=100), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_loyalty_id'], ['user_loyalty.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_loyalty_point_transactions_id'), 'loyalty_point_transactions', ['id'], unique=False) + op.create_index(op.f('ix_loyalty_point_transactions_user_loyalty_id'), 'loyalty_point_transactions', ['user_loyalty_id'], unique=False) + op.create_index(op.f('ix_loyalty_point_transactions_booking_id'), 'loyalty_point_transactions', ['booking_id'], unique=False) + op.create_index(op.f('ix_loyalty_point_transactions_transaction_type'), 'loyalty_point_transactions', ['transaction_type'], unique=False) + op.create_index(op.f('ix_loyalty_point_transactions_created_at'), 'loyalty_point_transactions', ['created_at'], unique=False) + + # Create loyalty_rewards table + op.create_table( + 'loyalty_rewards', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('reward_type', sa.Enum('discount', 'room_upgrade', 'amenity', 'cashback', 'voucher', name='rewardtype'), nullable=False), + sa.Column('points_cost', sa.Integer(), nullable=False), + sa.Column('discount_percentage', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('discount_amount', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('max_discount_amount', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('applicable_tier_id', sa.Integer(), nullable=True), + sa.Column('min_booking_amount', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('icon', sa.String(length=255), nullable=True), + sa.Column('image', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('stock_quantity', sa.Integer(), nullable=True), + sa.Column('redeemed_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('valid_from', sa.DateTime(), nullable=True), + sa.Column('valid_until', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['applicable_tier_id'], ['loyalty_tiers.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_loyalty_rewards_id'), 'loyalty_rewards', ['id'], unique=False) + op.create_index(op.f('ix_loyalty_rewards_reward_type'), 'loyalty_rewards', ['reward_type'], unique=False) + + # Create reward_redemptions table + op.create_table( + 'reward_redemptions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_loyalty_id', sa.Integer(), nullable=False), + sa.Column('reward_id', sa.Integer(), nullable=False), + sa.Column('booking_id', sa.Integer(), nullable=True), + sa.Column('points_used', sa.Integer(), nullable=False), + sa.Column('status', sa.Enum('pending', 'active', 'used', 'expired', 'cancelled', name='redemptionstatus'), nullable=False, server_default='pending'), + sa.Column('code', sa.String(length=50), nullable=True), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('used_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_loyalty_id'], ['user_loyalty.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['reward_id'], ['loyalty_rewards.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_index(op.f('ix_reward_redemptions_id'), 'reward_redemptions', ['id'], unique=False) + op.create_index(op.f('ix_reward_redemptions_user_loyalty_id'), 'reward_redemptions', ['user_loyalty_id'], unique=False) + op.create_index(op.f('ix_reward_redemptions_reward_id'), 'reward_redemptions', ['reward_id'], unique=False) + op.create_index(op.f('ix_reward_redemptions_booking_id'), 'reward_redemptions', ['booking_id'], unique=False) + op.create_index(op.f('ix_reward_redemptions_status'), 'reward_redemptions', ['status'], unique=False) + op.create_index(op.f('ix_reward_redemptions_code'), 'reward_redemptions', ['code'], unique=True) + + # Create referrals table + op.create_table( + 'referrals', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('referrer_id', sa.Integer(), nullable=False), + sa.Column('referred_user_id', sa.Integer(), nullable=False), + sa.Column('referral_code', sa.String(length=50), nullable=False), + sa.Column('booking_id', sa.Integer(), nullable=True), + sa.Column('status', sa.Enum('pending', 'completed', 'rewarded', name='referralstatus'), nullable=False, server_default='pending'), + sa.Column('referrer_points_earned', sa.Integer(), nullable=False, server_default='0'), + sa.Column('referred_points_earned', sa.Integer(), nullable=False, server_default='0'), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.Column('rewarded_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['referrer_id'], ['user_loyalty.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['referred_user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_referrals_id'), 'referrals', ['id'], unique=False) + op.create_index(op.f('ix_referrals_referrer_id'), 'referrals', ['referrer_id'], unique=False) + op.create_index(op.f('ix_referrals_referred_user_id'), 'referrals', ['referred_user_id'], unique=False) + op.create_index(op.f('ix_referrals_referral_code'), 'referrals', ['referral_code'], unique=False) + op.create_index(op.f('ix_referrals_booking_id'), 'referrals', ['booking_id'], unique=False) + op.create_index(op.f('ix_referrals_status'), 'referrals', ['status'], unique=False) + op.create_index(op.f('ix_referrals_created_at'), 'referrals', ['created_at'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_referrals_created_at'), table_name='referrals') + op.drop_index(op.f('ix_referrals_status'), table_name='referrals') + op.drop_index(op.f('ix_referrals_booking_id'), table_name='referrals') + op.drop_index(op.f('ix_referrals_referral_code'), table_name='referrals') + op.drop_index(op.f('ix_referrals_referred_user_id'), table_name='referrals') + op.drop_index(op.f('ix_referrals_referrer_id'), table_name='referrals') + op.drop_index(op.f('ix_referrals_id'), table_name='referrals') + op.drop_table('referrals') + + op.drop_index(op.f('ix_reward_redemptions_code'), table_name='reward_redemptions') + op.drop_index(op.f('ix_reward_redemptions_status'), table_name='reward_redemptions') + op.drop_index(op.f('ix_reward_redemptions_booking_id'), table_name='reward_redemptions') + op.drop_index(op.f('ix_reward_redemptions_reward_id'), table_name='reward_redemptions') + op.drop_index(op.f('ix_reward_redemptions_user_loyalty_id'), table_name='reward_redemptions') + op.drop_index(op.f('ix_reward_redemptions_id'), table_name='reward_redemptions') + op.drop_table('reward_redemptions') + + op.drop_index(op.f('ix_loyalty_rewards_reward_type'), table_name='loyalty_rewards') + op.drop_index(op.f('ix_loyalty_rewards_id'), table_name='loyalty_rewards') + op.drop_table('loyalty_rewards') + + op.drop_index(op.f('ix_loyalty_point_transactions_created_at'), table_name='loyalty_point_transactions') + op.drop_index(op.f('ix_loyalty_point_transactions_transaction_type'), table_name='loyalty_point_transactions') + op.drop_index(op.f('ix_loyalty_point_transactions_booking_id'), table_name='loyalty_point_transactions') + op.drop_index(op.f('ix_loyalty_point_transactions_user_loyalty_id'), table_name='loyalty_point_transactions') + op.drop_index(op.f('ix_loyalty_point_transactions_id'), table_name='loyalty_point_transactions') + op.drop_table('loyalty_point_transactions') + + op.drop_index(op.f('ix_user_loyalty_referral_code'), table_name='user_loyalty') + op.drop_index(op.f('ix_user_loyalty_tier_id'), table_name='user_loyalty') + op.drop_index(op.f('ix_user_loyalty_user_id'), table_name='user_loyalty') + op.drop_index(op.f('ix_user_loyalty_id'), table_name='user_loyalty') + op.drop_table('user_loyalty') + + op.drop_index(op.f('ix_loyalty_tiers_level'), table_name='loyalty_tiers') + op.drop_index(op.f('ix_loyalty_tiers_id'), table_name='loyalty_tiers') + op.drop_table('loyalty_tiers') + diff --git a/Backend/seeds_data/seed_loyalty_rewards.py b/Backend/seeds_data/seed_loyalty_rewards.py new file mode 100644 index 00000000..f13ace32 --- /dev/null +++ b/Backend/seeds_data/seed_loyalty_rewards.py @@ -0,0 +1,303 @@ +import sys +import os +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) +from sqlalchemy.orm import Session +from src.config.database import SessionLocal +from src.models.loyalty_reward import LoyaltyReward, RewardType +from src.models.loyalty_tier import LoyaltyTier, TierLevel +from datetime import datetime, timedelta + +def get_db(): + db = SessionLocal() + try: + return db + finally: + pass + +def seed_loyalty_rewards(db: Session): + print('=' * 80) + print('SEEDING LOYALTY REWARDS') + print('=' * 80) + + # Get tier IDs for tier-specific rewards + bronze_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.bronze).first() + silver_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.silver).first() + gold_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.gold).first() + platinum_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.platinum).first() + + # Create default tiers if they don't exist + if not bronze_tier: + from src.services.loyalty_service import LoyaltyService + LoyaltyService.create_default_tiers(db) + db.refresh() + bronze_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.bronze).first() + silver_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.silver).first() + gold_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.gold).first() + platinum_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.platinum).first() + + # Sample rewards data + rewards_data = [ + # Discount Rewards (Available to all tiers) + { + 'name': '5% Booking Discount', + 'description': 'Get 5% off your next booking. Valid for bookings over $100.', + 'reward_type': RewardType.discount, + 'points_cost': 5000, + 'discount_percentage': 5.0, + 'max_discount_amount': 50.0, + 'min_booking_amount': 100.0, + 'applicable_tier_id': None, # Available to all tiers + 'stock_quantity': None, # Unlimited + 'icon': 'šŸŽ«', + 'is_active': True + }, + { + 'name': '10% Booking Discount', + 'description': 'Get 10% off your next booking. Valid for bookings over $200.', + 'reward_type': RewardType.discount, + 'points_cost': 10000, + 'discount_percentage': 10.0, + 'max_discount_amount': 100.0, + 'min_booking_amount': 200.0, + 'applicable_tier_id': silver_tier.id if silver_tier else None, + 'stock_quantity': None, + 'icon': 'šŸŽ«', + 'is_active': True + }, + { + 'name': '15% Premium Discount', + 'description': 'Get 15% off your next booking. Maximum discount $150. Valid for bookings over $300.', + 'reward_type': RewardType.discount, + 'points_cost': 15000, + 'discount_percentage': 15.0, + 'max_discount_amount': 150.0, + 'min_booking_amount': 300.0, + 'applicable_tier_id': gold_tier.id if gold_tier else None, + 'stock_quantity': None, + 'icon': 'šŸ’Ž', + 'is_active': True + }, + { + 'name': '20% VIP Discount', + 'description': 'Exclusive 20% discount for Platinum members. Maximum discount $200. Valid for bookings over $500.', + 'reward_type': RewardType.discount, + 'points_cost': 25000, + 'discount_percentage': 20.0, + 'max_discount_amount': 200.0, + 'min_booking_amount': 500.0, + 'applicable_tier_id': platinum_tier.id if platinum_tier else None, + 'stock_quantity': None, + 'icon': 'šŸ‘‘', + 'is_active': True + }, + + # Room Upgrade Rewards + { + 'name': 'Complimentary Room Upgrade', + 'description': 'Upgrade to the next room category at no extra cost. Subject to availability at check-in.', + 'reward_type': RewardType.room_upgrade, + 'points_cost': 20000, + 'applicable_tier_id': silver_tier.id if silver_tier else None, + 'stock_quantity': 50, # Limited stock + 'icon': 'šŸ›ļø', + 'is_active': True + }, + { + 'name': 'Premium Suite Upgrade', + 'description': 'Upgrade to a premium suite or executive room. Subject to availability.', + 'reward_type': RewardType.room_upgrade, + 'points_cost': 35000, + 'applicable_tier_id': gold_tier.id if gold_tier else None, + 'stock_quantity': 30, + 'icon': 'šŸØ', + 'is_active': True + }, + { + 'name': 'Luxury Suite Upgrade', + 'description': 'Upgrade to our most luxurious suite category. Ultimate comfort guaranteed. Subject to availability.', + 'reward_type': RewardType.room_upgrade, + 'points_cost': 50000, + 'applicable_tier_id': platinum_tier.id if platinum_tier else None, + 'stock_quantity': 20, + 'icon': '✨', + 'is_active': True + }, + + # Amenity Rewards + { + 'name': 'Welcome Amenity Package', + 'description': 'Complimentary welcome basket with fruits, chocolates, and wine upon arrival.', + 'reward_type': RewardType.amenity, + 'points_cost': 3000, + 'applicable_tier_id': None, + 'stock_quantity': None, + 'icon': 'šŸ¾', + 'is_active': True + }, + { + 'name': 'Breakfast for Two', + 'description': 'Complimentary breakfast buffet for two guests during your stay.', + 'reward_type': RewardType.amenity, + 'points_cost': 8000, + 'applicable_tier_id': None, + 'stock_quantity': None, + 'icon': 'šŸ³', + 'is_active': True + }, + { + 'name': 'Spa Treatment Voucher', + 'description': 'One complimentary spa treatment of your choice (60 minutes). Valid for 90 days.', + 'reward_type': RewardType.amenity, + 'points_cost': 12000, + 'applicable_tier_id': silver_tier.id if silver_tier else None, + 'stock_quantity': 40, + 'icon': 'šŸ’†', + 'is_active': True, + 'valid_until': datetime.utcnow() + timedelta(days=90) + }, + { + 'name': 'Romantic Dinner Package', + 'description': 'Private romantic dinner for two with wine pairing at our fine dining restaurant.', + 'reward_type': RewardType.amenity, + 'points_cost': 18000, + 'applicable_tier_id': gold_tier.id if gold_tier else None, + 'stock_quantity': 25, + 'icon': 'šŸ½ļø', + 'is_active': True + }, + { + 'name': 'VIP Airport Transfer', + 'description': 'Complimentary luxury airport transfer (one-way) in premium vehicle.', + 'reward_type': RewardType.amenity, + 'points_cost': 15000, + 'applicable_tier_id': platinum_tier.id if platinum_tier else None, + 'stock_quantity': 20, + 'icon': 'šŸš—', + 'is_active': True + }, + + # Voucher Rewards + { + 'name': '$50 Hotel Credit', + 'description': 'Redeem for $50 credit towards room service, spa, or dining at the hotel. Valid for 6 months.', + 'reward_type': RewardType.voucher, + 'points_cost': 10000, + 'discount_amount': 50.0, + 'applicable_tier_id': None, + 'stock_quantity': None, + 'icon': 'šŸ’³', + 'is_active': True, + 'valid_until': datetime.utcnow() + timedelta(days=180) + }, + { + 'name': '$100 Hotel Credit', + 'description': 'Redeem for $100 credit towards any hotel service. Valid for 6 months.', + 'reward_type': RewardType.voucher, + 'points_cost': 20000, + 'discount_amount': 100.0, + 'applicable_tier_id': silver_tier.id if silver_tier else None, + 'stock_quantity': None, + 'icon': 'šŸ’µ', + 'is_active': True, + 'valid_until': datetime.utcnow() + timedelta(days=180) + }, + { + 'name': '$200 Premium Credit', + 'description': 'Redeem for $200 credit towards premium services. Perfect for special occasions. Valid for 1 year.', + 'reward_type': RewardType.voucher, + 'points_cost': 40000, + 'discount_amount': 200.0, + 'applicable_tier_id': gold_tier.id if gold_tier else None, + 'stock_quantity': None, + 'icon': 'šŸ’°', + 'is_active': True, + 'valid_until': datetime.utcnow() + timedelta(days=365) + }, + + # Cashback Rewards + { + 'name': 'Early Check-in Benefit', + 'description': 'Guaranteed early check-in (12:00 PM) at no extra charge. Subject to availability.', + 'reward_type': RewardType.amenity, + 'points_cost': 2000, + 'applicable_tier_id': None, + 'stock_quantity': None, + 'icon': 'ā°', + 'is_active': True + }, + { + 'name': 'Late Check-out Benefit', + 'description': 'Extended check-out until 2:00 PM at no extra charge. Subject to availability.', + 'reward_type': RewardType.amenity, + 'points_cost': 2000, + 'applicable_tier_id': None, + 'stock_quantity': None, + 'icon': 'šŸ•', + 'is_active': True + }, + { + 'name': 'Free Night Stay', + 'description': 'One complimentary night stay in a standard room. Valid for bookings of 2+ nights.', + 'reward_type': RewardType.voucher, + 'points_cost': 30000, + 'applicable_tier_id': gold_tier.id if gold_tier else None, + 'stock_quantity': 15, + 'icon': 'šŸŒ™', + 'is_active': True, + 'valid_until': datetime.utcnow() + timedelta(days=180) + }, + { + 'name': 'Complimentary Room Service', + 'description': '$75 credit for room service orders. Valid for one stay.', + 'reward_type': RewardType.amenity, + 'points_cost': 6000, + 'discount_amount': 75.0, + 'applicable_tier_id': None, + 'stock_quantity': None, + 'icon': 'šŸ½ļø', + 'is_active': True + }, + ] + + created_count = 0 + skipped_count = 0 + + for reward_data in rewards_data: + # Check if reward already exists by name + existing = db.query(LoyaltyReward).filter(LoyaltyReward.name == reward_data['name']).first() + + if existing: + print(f' āš ļø Reward "{reward_data["name"]}" already exists, skipping...') + skipped_count += 1 + continue + + # Create reward object + reward = LoyaltyReward(**reward_data) + db.add(reward) + print(f' āœ“ Created reward: {reward_data["name"]} ({reward_data["points_cost"]:,} points)') + created_count += 1 + + db.commit() + print('\nāœ“ Loyalty rewards seeded successfully!') + print(f' - Created: {created_count} reward(s)') + print(f' - Skipped: {skipped_count} reward(s) (already exist)') + print('=' * 80) + +def main(): + db = get_db() + try: + seed_loyalty_rewards(db) + print('\nāœ… Loyalty rewards seeding completed successfully!') + print('=' * 80) + except Exception as e: + print(f'\nāŒ Error: {e}') + import traceback + traceback.print_exc() + db.rollback() + finally: + db.close() + +if __name__ == '__main__': + main() + diff --git a/Backend/src/__pycache__/main.cpython-312.pyc b/Backend/src/__pycache__/main.cpython-312.pyc index bdb57e06..10b06b58 100644 Binary files a/Backend/src/__pycache__/main.cpython-312.pyc and b/Backend/src/__pycache__/main.cpython-312.pyc differ diff --git a/Backend/src/main.py b/Backend/src/main.py index 6ca18dbb..ad038dff 100644 --- a/Backend/src/main.py +++ b/Backend/src/main.py @@ -95,7 +95,7 @@ async def metrics(): return {'status': 'success', 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'timestamp': datetime.utcnow().isoformat()} app.include_router(auth_routes.router, prefix='/api') app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX) -from .routes import room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, system_settings_routes, contact_routes, page_content_routes, home_routes, about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, terms_routes, refunds_routes, cancellation_routes, accessibility_routes, faq_routes +from .routes import room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, system_settings_routes, contact_routes, page_content_routes, home_routes, about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, terms_routes, refunds_routes, cancellation_routes, accessibility_routes, faq_routes, loyalty_routes, guest_profile_routes app.include_router(room_routes.router, prefix='/api') app.include_router(booking_routes.router, prefix='/api') app.include_router(payment_routes.router, prefix='/api') @@ -123,6 +123,8 @@ app.include_router(cancellation_routes.router, prefix='/api') app.include_router(accessibility_routes.router, prefix='/api') app.include_router(faq_routes.router, prefix='/api') app.include_router(chat_routes.router, prefix='/api') +app.include_router(loyalty_routes.router, prefix='/api') +app.include_router(guest_profile_routes.router, prefix='/api') app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(booking_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(payment_routes.router, prefix=settings.API_V1_PREFIX) @@ -150,6 +152,8 @@ app.include_router(cancellation_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(accessibility_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(faq_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(chat_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(loyalty_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(guest_profile_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(page_content_routes.router, prefix='/api') app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX) logger.info('All routes registered successfully') diff --git a/Backend/src/models/__init__.py b/Backend/src/models/__init__.py index dd121095..50280bc0 100644 --- a/Backend/src/models/__init__.py +++ b/Backend/src/models/__init__.py @@ -21,4 +21,15 @@ from .system_settings import SystemSettings from .invoice import Invoice, InvoiceItem from .page_content import PageContent, PageType from .chat import Chat, ChatMessage, ChatStatus -__all__ = ['Role', 'User', 'RefreshToken', 'PasswordResetToken', 'RoomType', 'Room', 'Booking', 'Payment', 'Service', 'ServiceUsage', 'ServiceBooking', 'ServiceBookingItem', 'ServicePayment', 'ServiceBookingStatus', 'ServicePaymentStatus', 'ServicePaymentMethod', 'Promotion', 'CheckInCheckOut', 'Banner', 'Review', 'Favorite', 'AuditLog', 'CookiePolicy', 'CookieIntegrationConfig', 'SystemSettings', 'Invoice', 'InvoiceItem', 'PageContent', 'PageType', 'Chat', 'ChatMessage', 'ChatStatus'] \ No newline at end of file +from .loyalty_tier import LoyaltyTier, TierLevel +from .user_loyalty import UserLoyalty +from .loyalty_point_transaction import LoyaltyPointTransaction, TransactionType, TransactionSource +from .loyalty_reward import LoyaltyReward, RewardType, RewardStatus +from .reward_redemption import RewardRedemption, RedemptionStatus +from .referral import Referral, ReferralStatus +from .guest_preference import GuestPreference +from .guest_note import GuestNote +from .guest_tag import GuestTag, guest_tag_association +from .guest_communication import GuestCommunication, CommunicationType, CommunicationDirection +from .guest_segment import GuestSegment, guest_segment_association +__all__ = ['Role', 'User', 'RefreshToken', 'PasswordResetToken', 'RoomType', 'Room', 'Booking', 'Payment', 'Service', 'ServiceUsage', 'ServiceBooking', 'ServiceBookingItem', 'ServicePayment', 'ServiceBookingStatus', 'ServicePaymentStatus', 'ServicePaymentMethod', 'Promotion', 'CheckInCheckOut', 'Banner', 'Review', 'Favorite', 'AuditLog', 'CookiePolicy', 'CookieIntegrationConfig', 'SystemSettings', 'Invoice', 'InvoiceItem', 'PageContent', 'PageType', 'Chat', 'ChatMessage', 'ChatStatus', 'LoyaltyTier', 'TierLevel', 'UserLoyalty', 'LoyaltyPointTransaction', 'TransactionType', 'TransactionSource', 'LoyaltyReward', 'RewardType', 'RewardStatus', 'RewardRedemption', 'RedemptionStatus', 'Referral', 'ReferralStatus', 'GuestPreference', 'GuestNote', 'GuestTag', 'guest_tag_association', 'GuestCommunication', 'CommunicationType', 'CommunicationDirection', 'GuestSegment', 'guest_segment_association'] \ No newline at end of file diff --git a/Backend/src/models/__pycache__/__init__.cpython-312.pyc b/Backend/src/models/__pycache__/__init__.cpython-312.pyc index 76f051e0..0f1848e2 100644 Binary files a/Backend/src/models/__pycache__/__init__.cpython-312.pyc and b/Backend/src/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/guest_communication.cpython-312.pyc b/Backend/src/models/__pycache__/guest_communication.cpython-312.pyc new file mode 100644 index 00000000..b2da0b6a Binary files /dev/null and b/Backend/src/models/__pycache__/guest_communication.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/guest_note.cpython-312.pyc b/Backend/src/models/__pycache__/guest_note.cpython-312.pyc new file mode 100644 index 00000000..9b86be55 Binary files /dev/null and b/Backend/src/models/__pycache__/guest_note.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/guest_preference.cpython-312.pyc b/Backend/src/models/__pycache__/guest_preference.cpython-312.pyc new file mode 100644 index 00000000..b362f264 Binary files /dev/null and b/Backend/src/models/__pycache__/guest_preference.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/guest_segment.cpython-312.pyc b/Backend/src/models/__pycache__/guest_segment.cpython-312.pyc new file mode 100644 index 00000000..a6503585 Binary files /dev/null and b/Backend/src/models/__pycache__/guest_segment.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/guest_tag.cpython-312.pyc b/Backend/src/models/__pycache__/guest_tag.cpython-312.pyc new file mode 100644 index 00000000..1e356f8a Binary files /dev/null and b/Backend/src/models/__pycache__/guest_tag.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/loyalty_point_transaction.cpython-312.pyc b/Backend/src/models/__pycache__/loyalty_point_transaction.cpython-312.pyc new file mode 100644 index 00000000..b03f9718 Binary files /dev/null and b/Backend/src/models/__pycache__/loyalty_point_transaction.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/loyalty_reward.cpython-312.pyc b/Backend/src/models/__pycache__/loyalty_reward.cpython-312.pyc new file mode 100644 index 00000000..d2db6252 Binary files /dev/null and b/Backend/src/models/__pycache__/loyalty_reward.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/loyalty_tier.cpython-312.pyc b/Backend/src/models/__pycache__/loyalty_tier.cpython-312.pyc new file mode 100644 index 00000000..39652193 Binary files /dev/null and b/Backend/src/models/__pycache__/loyalty_tier.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/referral.cpython-312.pyc b/Backend/src/models/__pycache__/referral.cpython-312.pyc new file mode 100644 index 00000000..a45decab Binary files /dev/null and b/Backend/src/models/__pycache__/referral.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/reward_redemption.cpython-312.pyc b/Backend/src/models/__pycache__/reward_redemption.cpython-312.pyc new file mode 100644 index 00000000..16bb912f Binary files /dev/null and b/Backend/src/models/__pycache__/reward_redemption.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/user.cpython-312.pyc b/Backend/src/models/__pycache__/user.cpython-312.pyc index 55f5045e..5419cfbe 100644 Binary files a/Backend/src/models/__pycache__/user.cpython-312.pyc and b/Backend/src/models/__pycache__/user.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/user_loyalty.cpython-312.pyc b/Backend/src/models/__pycache__/user_loyalty.cpython-312.pyc new file mode 100644 index 00000000..09538007 Binary files /dev/null and b/Backend/src/models/__pycache__/user_loyalty.cpython-312.pyc differ diff --git a/Backend/src/models/guest_communication.py b/Backend/src/models/guest_communication.py new file mode 100644 index 00000000..31e06bff --- /dev/null +++ b/Backend/src/models/guest_communication.py @@ -0,0 +1,36 @@ +from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Enum, Boolean +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ..config.database import Base + +class CommunicationType(str, enum.Enum): + email = 'email' + phone = 'phone' + sms = 'sms' + chat = 'chat' + in_person = 'in_person' + other = 'other' + +class CommunicationDirection(str, enum.Enum): + inbound = 'inbound' # Guest to hotel + outbound = 'outbound' # Hotel to guest + +class GuestCommunication(Base): + __tablename__ = 'guest_communications' + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True) + staff_id = Column(Integer, ForeignKey('users.id'), nullable=True) # Staff member who handled communication + communication_type = Column(Enum(CommunicationType), nullable=False) + direction = Column(Enum(CommunicationDirection), nullable=False) + subject = Column(String(255), nullable=True) + content = Column(Text, nullable=False) + booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True) # Related booking if applicable + is_automated = Column(Boolean, nullable=False, default=False) # If this was an automated communication + communication_metadata = Column(Text, nullable=True) # JSON string for additional data + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + user = relationship('User', foreign_keys=[user_id], back_populates='guest_communications') + staff = relationship('User', foreign_keys=[staff_id]) + booking = relationship('Booking') + diff --git a/Backend/src/models/guest_note.py b/Backend/src/models/guest_note.py new file mode 100644 index 00000000..c37e8127 --- /dev/null +++ b/Backend/src/models/guest_note.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Boolean +from sqlalchemy.orm import relationship +from datetime import datetime +from ..config.database import Base + +class GuestNote(Base): + __tablename__ = 'guest_notes' + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True) + created_by = Column(Integer, ForeignKey('users.id'), nullable=False) # Staff/admin who created the note + note = Column(Text, nullable=False) + is_important = Column(Boolean, nullable=False, default=False) + is_private = Column(Boolean, nullable=False, default=False) # Private notes only visible to admins + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + user = relationship('User', foreign_keys=[user_id], back_populates='guest_notes') + creator = relationship('User', foreign_keys=[created_by]) + diff --git a/Backend/src/models/guest_preference.py b/Backend/src/models/guest_preference.py new file mode 100644 index 00000000..b3cae213 --- /dev/null +++ b/Backend/src/models/guest_preference.py @@ -0,0 +1,40 @@ +from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey, DateTime, Boolean +from sqlalchemy.orm import relationship +from datetime import datetime +from ..config.database import Base + +class GuestPreference(Base): + __tablename__ = 'guest_preferences' + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True) + + # Room preferences + preferred_room_location = Column(String(100), nullable=True) # e.g., "high floor", "near elevator", "ocean view" + preferred_floor = Column(Integer, nullable=True) + preferred_room_type_id = Column(Integer, ForeignKey('room_types.id'), nullable=True) + + # Amenity preferences + preferred_amenities = Column(JSON, nullable=True) # Array of amenity names + + # Special requests + special_requests = Column(Text, nullable=True) # General special requests + + # Service preferences + preferred_services = Column(JSON, nullable=True) # Array of service preferences + + # Communication preferences + preferred_contact_method = Column(String(50), nullable=True) # email, phone, sms + preferred_language = Column(String(10), nullable=True, default='en') + + # Dietary preferences + dietary_restrictions = Column(JSON, nullable=True) # Array of dietary restrictions + + # Other preferences + additional_preferences = Column(JSON, nullable=True) # Flexible JSON for other preferences + + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + user = relationship('User', back_populates='guest_preferences') + preferred_room_type = relationship('RoomType') + diff --git a/Backend/src/models/guest_segment.py b/Backend/src/models/guest_segment.py new file mode 100644 index 00000000..d05e58e0 --- /dev/null +++ b/Backend/src/models/guest_segment.py @@ -0,0 +1,26 @@ +from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Boolean, JSON, Table +from sqlalchemy.orm import relationship +from datetime import datetime +from ..config.database import Base + +# Association table for many-to-many relationship between users and segments +guest_segment_association = Table( + 'guest_segment_associations', + Base.metadata, + Column('user_id', Integer, ForeignKey('users.id'), primary_key=True), + Column('segment_id', Integer, ForeignKey('guest_segments.id'), primary_key=True), + Column('assigned_at', DateTime, default=datetime.utcnow, nullable=False) +) + +class GuestSegment(Base): + __tablename__ = 'guest_segments' + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(String(100), unique=True, nullable=False, index=True) + description = Column(Text, nullable=True) + criteria = Column(JSON, nullable=True) # JSON object defining segment criteria + is_active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + users = relationship('User', secondary=guest_segment_association, back_populates='guest_segments') + diff --git a/Backend/src/models/guest_tag.py b/Backend/src/models/guest_tag.py new file mode 100644 index 00000000..91bb9117 --- /dev/null +++ b/Backend/src/models/guest_tag.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Table +from sqlalchemy.orm import relationship +from datetime import datetime +from ..config.database import Base + +# Association table for many-to-many relationship between users and tags +guest_tag_association = Table( + 'guest_tag_associations', + Base.metadata, + Column('user_id', Integer, ForeignKey('users.id'), primary_key=True), + Column('tag_id', Integer, ForeignKey('guest_tags.id'), primary_key=True), + Column('created_at', DateTime, default=datetime.utcnow, nullable=False) +) + +class GuestTag(Base): + __tablename__ = 'guest_tags' + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(String(50), unique=True, nullable=False, index=True) + color = Column(String(7), nullable=True, default='#3B82F6') # Hex color code + description = Column(String(255), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + users = relationship('User', secondary=guest_tag_association, back_populates='guest_tags') + diff --git a/Backend/src/models/loyalty_point_transaction.py b/Backend/src/models/loyalty_point_transaction.py new file mode 100644 index 00000000..3fbdea7a --- /dev/null +++ b/Backend/src/models/loyalty_point_transaction.py @@ -0,0 +1,40 @@ +from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, ForeignKey, Enum +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ..config.database import Base + +class TransactionType(str, enum.Enum): + earned = 'earned' + redeemed = 'redeemed' + expired = 'expired' + bonus = 'bonus' + adjustment = 'adjustment' + +class TransactionSource(str, enum.Enum): + booking = 'booking' + referral = 'referral' + birthday = 'birthday' + anniversary = 'anniversary' + redemption = 'redemption' + promotion = 'promotion' + manual = 'manual' + +class LoyaltyPointTransaction(Base): + __tablename__ = 'loyalty_point_transactions' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_loyalty_id = Column(Integer, ForeignKey('user_loyalty.id'), nullable=False, index=True) + booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True, index=True) + transaction_type = Column(Enum(TransactionType), nullable=False, index=True) + source = Column(Enum(TransactionSource), nullable=False) + points = Column(Integer, nullable=False) + description = Column(Text, nullable=True) + expires_at = Column(DateTime, nullable=True) + reference_number = Column(String(100), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + # Relationships + user_loyalty = relationship('UserLoyalty', back_populates='point_transactions') + booking = relationship('Booking', foreign_keys=[booking_id]) + diff --git a/Backend/src/models/loyalty_reward.py b/Backend/src/models/loyalty_reward.py new file mode 100644 index 00000000..c6de5667 --- /dev/null +++ b/Backend/src/models/loyalty_reward.py @@ -0,0 +1,89 @@ +from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, Enum, ForeignKey +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ..config.database import Base + +class RewardType(str, enum.Enum): + discount = 'discount' + room_upgrade = 'room_upgrade' + amenity = 'amenity' + cashback = 'cashback' + voucher = 'voucher' + +class RewardStatus(str, enum.Enum): + available = 'available' + redeemed = 'redeemed' + expired = 'expired' + +class LoyaltyReward(Base): + __tablename__ = 'loyalty_rewards' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + reward_type = Column(Enum(RewardType), nullable=False, index=True) + points_cost = Column(Integer, nullable=False) + discount_percentage = Column(Numeric(5, 2), nullable=True) + discount_amount = Column(Numeric(10, 2), nullable=True) + max_discount_amount = Column(Numeric(10, 2), nullable=True) + applicable_tier_id = Column(Integer, ForeignKey('loyalty_tiers.id'), nullable=True) + min_booking_amount = Column(Numeric(10, 2), nullable=True) + icon = Column(String(255), nullable=True) + image = Column(String(255), nullable=True) + is_active = Column(Boolean, nullable=False, default=True) + stock_quantity = Column(Integer, nullable=True) + redeemed_count = Column(Integer, nullable=False, default=0) + valid_from = Column(DateTime, nullable=True) + valid_until = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + applicable_tier = relationship('LoyaltyTier', foreign_keys=[applicable_tier_id]) + redemptions = relationship('RewardRedemption', back_populates='reward') + + def is_available(self, user_tier_id=None, user_tier_min_points=None): + """ + Check if reward is available for user + + Args: + user_tier_id: User's current tier ID + user_tier_min_points: User's tier minimum points (for tier level comparison) + """ + from datetime import datetime + now = datetime.utcnow() + + if not self.is_active: + return False + + # If reward has a tier requirement, check if user's tier qualifies + if self.applicable_tier_id: + if user_tier_id is None: + return False + + # Exact tier match - always allowed + if user_tier_id == self.applicable_tier_id: + pass # Continue to other checks + # If we have tier point info, allow users with higher tier level (more points) + elif user_tier_min_points is not None and hasattr(self, '_required_tier_min_points'): + # User tier must have equal or more minimum points (higher tier) + if user_tier_min_points < self._required_tier_min_points: + return False + else: + # Without tier point info, use exact match only + # Convert to int for comparison to avoid type mismatch issues + try: + if int(user_tier_id) != int(self.applicable_tier_id): + return False + except (ValueError, TypeError): + return False + + if self.valid_from and now < self.valid_from: + return False + if self.valid_until and now > self.valid_until: + return False + if self.stock_quantity is not None and self.redeemed_count >= self.stock_quantity: + return False + return True + diff --git a/Backend/src/models/loyalty_tier.py b/Backend/src/models/loyalty_tier.py new file mode 100644 index 00000000..9dd3da3c --- /dev/null +++ b/Backend/src/models/loyalty_tier.py @@ -0,0 +1,32 @@ +from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, Enum +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ..config.database import Base + +class TierLevel(str, enum.Enum): + bronze = 'bronze' + silver = 'silver' + gold = 'gold' + platinum = 'platinum' + +class LoyaltyTier(Base): + __tablename__ = 'loyalty_tiers' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + level = Column(Enum(TierLevel), unique=True, nullable=False, index=True) + name = Column(String(100), nullable=False) + description = Column(Text, nullable=True) + min_points = Column(Integer, nullable=False, default=0) + points_earn_rate = Column(Numeric(5, 2), nullable=False, default=1.0) + discount_percentage = Column(Numeric(5, 2), nullable=True, default=0) + benefits = Column(Text, nullable=True) + icon = Column(String(255), nullable=True) + color = Column(String(50), nullable=True) + is_active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + user_tiers = relationship('UserLoyalty', back_populates='tier') + diff --git a/Backend/src/models/referral.py b/Backend/src/models/referral.py new file mode 100644 index 00000000..8f0bccf5 --- /dev/null +++ b/Backend/src/models/referral.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, ForeignKey, Enum +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ..config.database import Base + +class ReferralStatus(str, enum.Enum): + pending = 'pending' + completed = 'completed' + rewarded = 'rewarded' + +class Referral(Base): + __tablename__ = 'referrals' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + referrer_id = Column(Integer, ForeignKey('user_loyalty.id'), nullable=False, index=True) + referred_user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True) + referral_code = Column(String(50), nullable=False, index=True) + booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True, index=True) + status = Column(Enum(ReferralStatus), nullable=False, default=ReferralStatus.pending, index=True) + referrer_points_earned = Column(Integer, nullable=False, default=0) + referred_points_earned = Column(Integer, nullable=False, default=0) + completed_at = Column(DateTime, nullable=True) + rewarded_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + referrer = relationship('UserLoyalty', foreign_keys=[referrer_id], back_populates='referrals') + referred_user = relationship('User', foreign_keys=[referred_user_id]) + booking = relationship('Booking', foreign_keys=[booking_id]) + diff --git a/Backend/src/models/reward_redemption.py b/Backend/src/models/reward_redemption.py new file mode 100644 index 00000000..42b9b0cb --- /dev/null +++ b/Backend/src/models/reward_redemption.py @@ -0,0 +1,33 @@ +from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, ForeignKey, Enum +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ..config.database import Base + +class RedemptionStatus(str, enum.Enum): + pending = 'pending' + active = 'active' + used = 'used' + expired = 'expired' + cancelled = 'cancelled' + +class RewardRedemption(Base): + __tablename__ = 'reward_redemptions' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_loyalty_id = Column(Integer, ForeignKey('user_loyalty.id'), nullable=False, index=True) + reward_id = Column(Integer, ForeignKey('loyalty_rewards.id'), nullable=False, index=True) + booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True, index=True) + points_used = Column(Integer, nullable=False) + status = Column(Enum(RedemptionStatus), nullable=False, default=RedemptionStatus.pending, index=True) + code = Column(String(50), unique=True, nullable=True, index=True) + expires_at = Column(DateTime, nullable=True) + used_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + user_loyalty = relationship('UserLoyalty', foreign_keys=[user_loyalty_id]) + reward = relationship('LoyaltyReward', back_populates='redemptions') + booking = relationship('Booking', foreign_keys=[booking_id]) + diff --git a/Backend/src/models/user.py b/Backend/src/models/user.py index 30ea4c00..e5d87a09 100644 --- a/Backend/src/models/user.py +++ b/Backend/src/models/user.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, Text, ForeignKey, DateTime +from sqlalchemy import Column, Integer, String, Boolean, Text, ForeignKey, DateTime, Numeric from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base @@ -18,6 +18,14 @@ class User(Base): mfa_enabled = Column(Boolean, nullable=False, default=False) mfa_secret = Column(String(255), nullable=True) mfa_backup_codes = Column(Text, nullable=True) + + # Guest Profile & CRM fields + is_vip = Column(Boolean, nullable=False, default=False) + lifetime_value = Column(Numeric(10, 2), nullable=True, default=0) # Total revenue from guest + satisfaction_score = Column(Numeric(3, 2), nullable=True) # Average satisfaction score (0-5) + last_visit_date = Column(DateTime, nullable=True) # Last booking check-in date + total_visits = Column(Integer, nullable=False, default=0) # Total number of bookings + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) role = relationship('Role', back_populates='users') @@ -29,4 +37,13 @@ class User(Base): favorites = relationship('Favorite', back_populates='user', cascade='all, delete-orphan') service_bookings = relationship('ServiceBooking', back_populates='user') visitor_chats = relationship('Chat', foreign_keys='Chat.visitor_id', back_populates='visitor') - staff_chats = relationship('Chat', foreign_keys='Chat.staff_id', back_populates='staff') \ No newline at end of file + staff_chats = relationship('Chat', foreign_keys='Chat.staff_id', back_populates='staff') + loyalty = relationship('UserLoyalty', back_populates='user', uselist=False, cascade='all, delete-orphan') + referrals = relationship('Referral', foreign_keys='Referral.referred_user_id', back_populates='referred_user') + + # Guest Profile & CRM relationships + guest_preferences = relationship('GuestPreference', back_populates='user', uselist=False, cascade='all, delete-orphan') + guest_notes = relationship('GuestNote', foreign_keys='GuestNote.user_id', back_populates='user', cascade='all, delete-orphan') + guest_tags = relationship('GuestTag', secondary='guest_tag_associations', back_populates='users') + guest_communications = relationship('GuestCommunication', foreign_keys='GuestCommunication.user_id', back_populates='user', cascade='all, delete-orphan') + guest_segments = relationship('GuestSegment', secondary='guest_segment_associations', back_populates='users') \ No newline at end of file diff --git a/Backend/src/models/user_loyalty.py b/Backend/src/models/user_loyalty.py new file mode 100644 index 00000000..c86ee813 --- /dev/null +++ b/Backend/src/models/user_loyalty.py @@ -0,0 +1,31 @@ +from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, ForeignKey, Date +from sqlalchemy.orm import relationship +from datetime import datetime +from ..config.database import Base + +class UserLoyalty(Base): + __tablename__ = 'user_loyalty' + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_id = Column(Integer, ForeignKey('users.id'), unique=True, nullable=False, index=True) + tier_id = Column(Integer, ForeignKey('loyalty_tiers.id'), nullable=False, index=True) + total_points = Column(Integer, nullable=False, default=0) + lifetime_points = Column(Integer, nullable=False, default=0) + available_points = Column(Integer, nullable=False, default=0) + expired_points = Column(Integer, nullable=False, default=0) + referral_code = Column(String(50), unique=True, nullable=True, index=True) + referral_count = Column(Integer, nullable=False, default=0) + birthday = Column(Date, nullable=True) + anniversary_date = Column(Date, nullable=True) + last_points_earned_date = Column(DateTime, nullable=True) + tier_started_date = Column(DateTime, nullable=True) + next_tier_points_needed = Column(Integer, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + user = relationship('User', back_populates='loyalty') + tier = relationship('LoyaltyTier', back_populates='user_tiers') + point_transactions = relationship('LoyaltyPointTransaction', back_populates='user_loyalty', cascade='all, delete-orphan') + referrals = relationship('Referral', foreign_keys='Referral.referrer_id', back_populates='referrer') + diff --git a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc index ef2fbfa7..d195e172 100644 Binary files a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/guest_profile_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/guest_profile_routes.cpython-312.pyc new file mode 100644 index 00000000..994b24cf Binary files /dev/null and b/Backend/src/routes/__pycache__/guest_profile_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/loyalty_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/loyalty_routes.cpython-312.pyc new file mode 100644 index 00000000..22a83a08 Binary files /dev/null and b/Backend/src/routes/__pycache__/loyalty_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc index 692e426c..f6c44d4b 100644 Binary files a/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/booking_routes.py b/Backend/src/routes/booking_routes.py index 47fa224a..dc39c343 100644 --- a/Backend/src/routes/booking_routes.py +++ b/Backend/src/routes/booking_routes.py @@ -15,10 +15,13 @@ from ..models.room import Room, RoomStatus from ..models.room_type import RoomType from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus from ..models.service_usage import ServiceUsage +from ..models.user_loyalty import UserLoyalty +from ..models.referral import Referral, ReferralStatus from ..services.room_service import normalize_images, get_base_url from fastapi import Request from ..utils.mailer import send_email from ..utils.email_templates import booking_confirmation_email_template, booking_status_changed_email_template +from ..services.loyalty_service import LoyaltyService router = APIRouter(prefix='/bookings', tags=['bookings']) def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str: @@ -161,6 +164,7 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr notes = booking_data.get('notes') payment_method = booking_data.get('payment_method', 'cash') promotion_code = booking_data.get('promotion_code') + referral_code = booking_data.get('referral_code') invoice_info = booking_data.get('invoice_info', {}) missing_fields = [] if not room_id: @@ -262,6 +266,33 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr ) db.add(booking) db.flush() + + # Process referral code if provided + if referral_code: + try: + from ..services.loyalty_service import LoyaltyService + from ..models.system_settings import SystemSettings + + # Check if loyalty program is enabled + setting = db.query(SystemSettings).filter( + SystemSettings.key == 'loyalty_program_enabled' + ).first() + is_enabled = True # Default to enabled + if setting: + is_enabled = setting.value.lower() == 'true' + + if is_enabled: + # Process referral code - this will create referral record and award points when booking is confirmed + LoyaltyService.process_referral( + db, + current_user.id, + referral_code.upper().strip(), + booking.id + ) + logger.info(f"Referral code {referral_code} processed for booking {booking.id}") + except Exception as referral_error: + logger.warning(f"Failed to process referral code {referral_code}: {referral_error}") + # Don't fail the booking if referral processing fails if payment_method in ['stripe', 'paypal']: from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType if payment_method == 'stripe': @@ -586,6 +617,42 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends email_html = booking_confirmation_email_template(booking_number=booking.booking_number, guest_name=guest_name, room_number=room.room_number if room else 'N/A', room_type=room_type_name, check_in=booking.check_in_date.strftime('%B %d, %Y') if booking.check_in_date else 'N/A', check_out=booking.check_out_date.strftime('%B %d, %Y') if booking.check_out_date else 'N/A', num_guests=booking.num_guests, total_price=float(booking.total_price), requires_deposit=booking.requires_deposit, deposit_amount=float(booking.total_price) * 0.2 if booking.requires_deposit else None, original_price=float(booking.original_price) if booking.original_price else None, discount_amount=float(booking.discount_amount) if booking.discount_amount else None, promotion_code=booking.promotion_code, client_url=client_url, currency_symbol=currency_symbol) if guest_email: await send_email(to=guest_email, subject=f'Booking Confirmed - {booking.booking_number}', html=email_html) + + # Award loyalty points for confirmed booking + if booking.user: + try: + # Check if booking already earned points + from ..models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionSource + existing_points = db.query(LoyaltyPointTransaction).filter( + LoyaltyPointTransaction.booking_id == booking.id, + LoyaltyPointTransaction.source == TransactionSource.booking + ).first() + + if not existing_points: + # Award points based on total price paid + total_paid = sum(float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed) + if total_paid > 0: + LoyaltyService.earn_points_from_booking(db, booking.user_id, booking, total_paid) + + # Process referral if applicable - referral is already processed when booking is created + # This section is for backward compatibility with existing referrals + if booking.user: + user_loyalty = db.query(UserLoyalty).filter(UserLoyalty.user_id == booking.user_id).first() + if user_loyalty and user_loyalty.referral_code: + # Check if there's a referral for this user that hasn't been rewarded yet + from ..models.referral import Referral + referral = db.query(Referral).filter( + Referral.referred_user_id == booking.user_id, + Referral.booking_id == booking.id, + Referral.status.in_([ReferralStatus.pending, ReferralStatus.completed]) + ).first() + if referral and referral.status == ReferralStatus.pending: + # Award points now that booking is confirmed + LoyaltyService.process_referral(db, booking.user_id, referral.referral_code, booking.id) + except Exception as loyalty_error: + import logging + logger = logging.getLogger(__name__) + logger.error(f'Failed to award loyalty points: {loyalty_error}') elif booking.status == BookingStatus.cancelled: guest_name = booking.user.full_name if booking.user else 'Guest' guest_email = booking.user.email if booking.user else None diff --git a/Backend/src/routes/guest_profile_routes.py b/Backend/src/routes/guest_profile_routes.py new file mode 100644 index 00000000..1e016352 --- /dev/null +++ b/Backend/src/routes/guest_profile_routes.py @@ -0,0 +1,564 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import Optional, List +from ..config.database import get_db +from ..middleware.auth import get_current_user, authorize_roles +from ..models.user import User +from ..models.guest_preference import GuestPreference +from ..models.guest_note import GuestNote +from ..models.guest_tag import GuestTag +from ..models.guest_communication import GuestCommunication, CommunicationType, CommunicationDirection +from ..models.guest_segment import GuestSegment +from ..services.guest_profile_service import GuestProfileService +import json + +router = APIRouter(prefix='/guest-profiles', tags=['guest-profiles']) + +# Guest Search and List +@router.get('/') +async def search_guests( + search: Optional[str] = Query(None), + is_vip: Optional[bool] = Query(None), + segment_id: Optional[int] = Query(None), + min_lifetime_value: Optional[float] = Query(None), + min_satisfaction_score: Optional[float] = Query(None), + tag_id: Optional[int] = Query(None), + page: int = Query(1, ge=1), + limit: int = Query(10, ge=1, le=100), + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Search and filter guests""" + try: + result = GuestProfileService.search_guests( + db=db, + search=search, + is_vip=is_vip, + segment_id=segment_id, + min_lifetime_value=min_lifetime_value, + min_satisfaction_score=min_satisfaction_score, + tag_id=tag_id, + page=page, + limit=limit + ) + + guests_data = [] + for guest in result['guests']: + guest_dict = { + 'id': guest.id, + 'full_name': guest.full_name, + 'email': guest.email, + 'phone': guest.phone, + 'is_vip': guest.is_vip, + 'lifetime_value': float(guest.lifetime_value) if guest.lifetime_value else 0, + 'satisfaction_score': float(guest.satisfaction_score) if guest.satisfaction_score else None, + 'total_visits': guest.total_visits, + 'last_visit_date': guest.last_visit_date.isoformat() if guest.last_visit_date else None, + 'tags': [{'id': tag.id, 'name': tag.name, 'color': tag.color} for tag in guest.guest_tags], + 'segments': [{'id': seg.id, 'name': seg.name} for seg in guest.guest_segments] + } + guests_data.append(guest_dict) + + return { + 'status': 'success', + 'data': { + 'guests': guests_data, + 'pagination': { + 'total': result['total'], + 'page': result['page'], + 'limit': result['limit'], + 'total_pages': result['total_pages'] + } + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +# Get Guest Profile Details +@router.get('/{user_id}') +async def get_guest_profile( + user_id: int, + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Get comprehensive guest profile""" + try: + # First check if user exists at all + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail=f'User with ID {user_id} not found') + + # Check if user is a customer (role_id == 3) + if user.role_id != 3: + raise HTTPException(status_code=404, detail=f'User with ID {user_id} is not a guest (customer)') + + # Get analytics + analytics = GuestProfileService.get_guest_analytics(user_id, db) + + # Get preferences + preferences = db.query(GuestPreference).filter(GuestPreference.user_id == user_id).first() + + # Get notes + notes = db.query(GuestNote).filter(GuestNote.user_id == user_id).order_by(GuestNote.created_at.desc()).all() + + # Get communications + communications = db.query(GuestCommunication).filter( + GuestCommunication.user_id == user_id + ).order_by(GuestCommunication.created_at.desc()).limit(20).all() + + # Get booking history + bookings = GuestProfileService.get_booking_history(user_id, db, limit=10) + + # Safely access relationships + try: + tags = [{'id': tag.id, 'name': tag.name, 'color': tag.color} for tag in (user.guest_tags or [])] + except Exception: + tags = [] + + try: + segments = [{'id': seg.id, 'name': seg.name, 'description': seg.description} for seg in (user.guest_segments or [])] + except Exception: + segments = [] + + profile_data = { + 'id': user.id, + 'full_name': user.full_name, + 'email': user.email, + 'phone': user.phone, + 'address': user.address, + 'avatar': user.avatar, + 'is_vip': getattr(user, 'is_vip', False), + 'lifetime_value': float(user.lifetime_value) if hasattr(user, 'lifetime_value') and user.lifetime_value else 0, + 'satisfaction_score': float(user.satisfaction_score) if hasattr(user, 'satisfaction_score') and user.satisfaction_score else None, + 'total_visits': getattr(user, 'total_visits', 0), + 'last_visit_date': user.last_visit_date.isoformat() if hasattr(user, 'last_visit_date') and user.last_visit_date else None, + 'created_at': user.created_at.isoformat() if user.created_at else None, + 'analytics': analytics, + 'preferences': { + 'preferred_room_location': preferences.preferred_room_location if preferences else None, + 'preferred_floor': preferences.preferred_floor if preferences else None, + 'preferred_amenities': preferences.preferred_amenities if preferences else None, + 'special_requests': preferences.special_requests if preferences else None, + 'preferred_contact_method': preferences.preferred_contact_method if preferences else None, + 'dietary_restrictions': preferences.dietary_restrictions if preferences else None, + } if preferences else None, + 'tags': tags, + 'segments': segments, + 'notes': [{ + 'id': note.id, + 'note': note.note, + 'is_important': note.is_important, + 'is_private': note.is_private, + 'created_by': note.creator.full_name if note.creator else None, + 'created_at': note.created_at.isoformat() if note.created_at else None + } for note in notes], + 'communications': [{ + 'id': comm.id, + 'communication_type': comm.communication_type.value, + 'direction': comm.direction.value, + 'subject': comm.subject, + 'content': comm.content, + 'staff_name': comm.staff.full_name if comm.staff else None, + 'created_at': comm.created_at.isoformat() if comm.created_at else None + } for comm in communications], + 'recent_bookings': [{ + 'id': booking.id, + 'booking_number': booking.booking_number, + 'check_in_date': booking.check_in_date.isoformat() if booking.check_in_date else None, + 'check_out_date': booking.check_out_date.isoformat() if booking.check_out_date else None, + 'status': booking.status.value if hasattr(booking.status, 'value') else str(booking.status), + 'total_price': float(booking.total_price) if booking.total_price else 0 + } for booking in bookings] + } + + return {'status': 'success', 'data': {'profile': profile_data}} + except HTTPException: + raise + except Exception as e: + import traceback + error_detail = f'Error fetching guest profile: {str(e)}\n{traceback.format_exc()}' + raise HTTPException(status_code=500, detail=error_detail) + +# Update Guest Preferences +@router.put('/{user_id}/preferences') +async def update_guest_preferences( + user_id: int, + preferences_data: dict, + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Update guest preferences""" + try: + user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() + if not user: + raise HTTPException(status_code=404, detail='Guest not found') + + preferences = db.query(GuestPreference).filter(GuestPreference.user_id == user_id).first() + + if not preferences: + preferences = GuestPreference(user_id=user_id) + db.add(preferences) + + if 'preferred_room_location' in preferences_data: + preferences.preferred_room_location = preferences_data['preferred_room_location'] + if 'preferred_floor' in preferences_data: + preferences.preferred_floor = preferences_data['preferred_floor'] + if 'preferred_room_type_id' in preferences_data: + preferences.preferred_room_type_id = preferences_data['preferred_room_type_id'] + if 'preferred_amenities' in preferences_data: + preferences.preferred_amenities = preferences_data['preferred_amenities'] + if 'special_requests' in preferences_data: + preferences.special_requests = preferences_data['special_requests'] + if 'preferred_services' in preferences_data: + preferences.preferred_services = preferences_data['preferred_services'] + if 'preferred_contact_method' in preferences_data: + preferences.preferred_contact_method = preferences_data['preferred_contact_method'] + if 'preferred_language' in preferences_data: + preferences.preferred_language = preferences_data['preferred_language'] + if 'dietary_restrictions' in preferences_data: + preferences.dietary_restrictions = preferences_data['dietary_restrictions'] + if 'additional_preferences' in preferences_data: + preferences.additional_preferences = preferences_data['additional_preferences'] + + db.commit() + db.refresh(preferences) + + return {'status': 'success', 'message': 'Preferences updated successfully'} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +# Create Guest Note +@router.post('/{user_id}/notes') +async def create_guest_note( + user_id: int, + note_data: dict, + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Create a note for a guest""" + try: + user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() + if not user: + raise HTTPException(status_code=404, detail='Guest not found') + + note = GuestNote( + user_id=user_id, + created_by=current_user.id, + note=note_data.get('note'), + is_important=note_data.get('is_important', False), + is_private=note_data.get('is_private', False) + ) + db.add(note) + db.commit() + db.refresh(note) + + return {'status': 'success', 'message': 'Note created successfully', 'data': {'note': { + 'id': note.id, + 'note': note.note, + 'is_important': note.is_important, + 'is_private': note.is_private, + 'created_at': note.created_at.isoformat() if note.created_at else None + }}} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +# Delete Guest Note +@router.delete('/{user_id}/notes/{note_id}') +async def delete_guest_note( + user_id: int, + note_id: int, + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Delete a guest note""" + try: + note = db.query(GuestNote).filter(GuestNote.id == note_id, GuestNote.user_id == user_id).first() + if not note: + raise HTTPException(status_code=404, detail='Note not found') + + db.delete(note) + db.commit() + + return {'status': 'success', 'message': 'Note deleted successfully'} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +# Toggle VIP Status +@router.put('/{user_id}/vip-status') +async def toggle_vip_status( + user_id: int, + vip_data: dict, + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Toggle VIP status for a guest""" + try: + user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() + if not user: + raise HTTPException(status_code=404, detail='Guest not found') + + user.is_vip = vip_data.get('is_vip', False) + db.commit() + db.refresh(user) + + return {'status': 'success', 'message': 'VIP status updated successfully'} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +# Add Tag to Guest +@router.post('/{user_id}/tags') +async def add_tag_to_guest( + user_id: int, + tag_data: dict, + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Add a tag to a guest""" + try: + user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() + if not user: + raise HTTPException(status_code=404, detail='Guest not found') + + tag_id = tag_data.get('tag_id') + tag = db.query(GuestTag).filter(GuestTag.id == tag_id).first() + if not tag: + raise HTTPException(status_code=404, detail='Tag not found') + + if tag not in user.guest_tags: + user.guest_tags.append(tag) + db.commit() + + return {'status': 'success', 'message': 'Tag added successfully'} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +# Remove Tag from Guest +@router.delete('/{user_id}/tags/{tag_id}') +async def remove_tag_from_guest( + user_id: int, + tag_id: int, + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Remove a tag from a guest""" + try: + user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() + if not user: + raise HTTPException(status_code=404, detail='Guest not found') + + tag = db.query(GuestTag).filter(GuestTag.id == tag_id).first() + if not tag: + raise HTTPException(status_code=404, detail='Tag not found') + + if tag in user.guest_tags: + user.guest_tags.remove(tag) + db.commit() + + return {'status': 'success', 'message': 'Tag removed successfully'} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +# Create Communication Record +@router.post('/{user_id}/communications') +async def create_communication( + user_id: int, + communication_data: dict, + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Create a communication record""" + try: + user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() + if not user: + raise HTTPException(status_code=404, detail='Guest not found') + + comm = GuestCommunication( + user_id=user_id, + staff_id=current_user.id, + communication_type=CommunicationType(communication_data.get('communication_type')), + direction=CommunicationDirection(communication_data.get('direction')), + subject=communication_data.get('subject'), + content=communication_data.get('content'), + booking_id=communication_data.get('booking_id'), + is_automated=communication_data.get('is_automated', False) + ) + db.add(comm) + db.commit() + db.refresh(comm) + + return {'status': 'success', 'message': 'Communication recorded successfully'} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +# Get Guest Analytics +@router.get('/{user_id}/analytics') +async def get_guest_analytics( + user_id: int, + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Get guest analytics""" + try: + user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() + if not user: + raise HTTPException(status_code=404, detail='Guest not found') + + analytics = GuestProfileService.get_guest_analytics(user_id, db) + + return {'status': 'success', 'data': {'analytics': analytics}} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +# Update Guest Metrics +@router.post('/{user_id}/update-metrics') +async def update_guest_metrics( + user_id: int, + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Update guest metrics (lifetime value, satisfaction score, etc.)""" + try: + user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() + if not user: + raise HTTPException(status_code=404, detail='Guest not found') + + metrics = GuestProfileService.update_guest_metrics(user_id, db) + + return {'status': 'success', 'message': 'Metrics updated successfully', 'data': {'metrics': metrics}} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +# Tag Management Routes +@router.get('/tags/all') +async def get_all_tags( + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Get all available tags""" + try: + tags = db.query(GuestTag).all() + tags_data = [{ + 'id': tag.id, + 'name': tag.name, + 'color': tag.color, + 'description': tag.description + } for tag in tags] + + return {'status': 'success', 'data': {'tags': tags_data}} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post('/tags') +async def create_tag( + tag_data: dict, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Create a new tag""" + try: + tag = GuestTag( + name=tag_data.get('name'), + color=tag_data.get('color', '#3B82F6'), + description=tag_data.get('description') + ) + db.add(tag) + db.commit() + db.refresh(tag) + + return {'status': 'success', 'message': 'Tag created successfully', 'data': {'tag': { + 'id': tag.id, + 'name': tag.name, + 'color': tag.color, + 'description': tag.description + }}} + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +# Segment Management Routes +@router.get('/segments/all') +async def get_all_segments( + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Get all available segments""" + try: + segments = db.query(GuestSegment).filter(GuestSegment.is_active == True).all() + segments_data = [{ + 'id': seg.id, + 'name': seg.name, + 'description': seg.description, + 'criteria': seg.criteria + } for seg in segments] + + return {'status': 'success', 'data': {'segments': segments_data}} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post('/{user_id}/segments') +async def assign_segment( + user_id: int, + segment_data: dict, + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Assign a guest to a segment""" + try: + segment_id = segment_data.get('segment_id') + success = GuestProfileService.assign_segment(user_id, segment_id, db) + + if not success: + raise HTTPException(status_code=404, detail='Guest or segment not found') + + return {'status': 'success', 'message': 'Segment assigned successfully'} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete('/{user_id}/segments/{segment_id}') +async def remove_segment( + user_id: int, + segment_id: int, + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Remove a guest from a segment""" + try: + success = GuestProfileService.remove_segment(user_id, segment_id, db) + + if not success: + raise HTTPException(status_code=404, detail='Guest or segment not found') + + return {'status': 'success', 'message': 'Segment removed successfully'} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/Backend/src/routes/loyalty_routes.py b/Backend/src/routes/loyalty_routes.py new file mode 100644 index 00000000..99eaac4c --- /dev/null +++ b/Backend/src/routes/loyalty_routes.py @@ -0,0 +1,981 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from sqlalchemy import func, desc +from typing import Optional, List +from datetime import datetime, date +import logging +from ..config.database import get_db +from ..middleware.auth import get_current_user, authorize_roles +from ..models.user import User +from ..models.user_loyalty import UserLoyalty +from ..models.loyalty_tier import LoyaltyTier, TierLevel +from ..models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionType, TransactionSource +from ..models.loyalty_reward import LoyaltyReward, RewardType, RewardStatus +from ..models.reward_redemption import RewardRedemption, RedemptionStatus +from ..models.referral import Referral, ReferralStatus +from ..services.loyalty_service import LoyaltyService +from pydantic import BaseModel, Field +from typing import Optional as Opt + +logger = logging.getLogger(__name__) +router = APIRouter(prefix='/loyalty', tags=['loyalty']) + +# Pydantic schemas for request/response +class UpdateUserLoyaltyRequest(BaseModel): + birthday: Optional[str] = None + anniversary_date: Optional[str] = None + +class RedeemRewardRequest(BaseModel): + reward_id: int + booking_id: Optional[int] = None + +class ApplyReferralRequest(BaseModel): + referral_code: str + +@router.get('/my-status') +async def get_my_loyalty_status( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get current user's loyalty status""" + try: + # Check if loyalty program is enabled + from ..models.system_settings import SystemSettings + setting = db.query(SystemSettings).filter( + SystemSettings.key == 'loyalty_program_enabled' + ).first() + is_enabled = True # Default to enabled + if setting: + is_enabled = setting.value.lower() == 'true' + + if not is_enabled: + raise HTTPException( + status_code=503, + detail='Loyalty program is currently disabled' + ) + user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id) + + tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == user_loyalty.tier_id).first() + + # Get next tier + next_tier = LoyaltyService.get_next_tier(db, tier) if tier else None + + # Calculate points needed for next tier + points_needed = None + if next_tier: + points_needed = next_tier.min_points - user_loyalty.lifetime_points + + tier_dict = None + if tier: + tier_dict = { + 'id': tier.id, + 'level': tier.level.value if hasattr(tier.level, 'value') else tier.level, + 'name': tier.name, + 'description': tier.description, + 'points_earn_rate': float(tier.points_earn_rate) if tier.points_earn_rate else 1.0, + 'discount_percentage': float(tier.discount_percentage) if tier.discount_percentage else 0, + 'benefits': tier.benefits, + 'icon': tier.icon, + 'color': tier.color + } + + next_tier_dict = None + if next_tier: + next_tier_dict = { + 'id': next_tier.id, + 'level': next_tier.level.value if hasattr(next_tier.level, 'value') else next_tier.level, + 'name': next_tier.name, + 'min_points': next_tier.min_points, + 'points_earn_rate': float(next_tier.points_earn_rate) if next_tier.points_earn_rate else 1.0, + 'discount_percentage': float(next_tier.discount_percentage) if next_tier.discount_percentage else 0, + 'points_needed': points_needed + } + + return { + 'status': 'success', + 'data': { + 'total_points': user_loyalty.total_points, + 'lifetime_points': user_loyalty.lifetime_points, + 'available_points': user_loyalty.available_points, + 'expired_points': user_loyalty.expired_points, + 'referral_code': user_loyalty.referral_code, + 'referral_count': user_loyalty.referral_count, + 'birthday': user_loyalty.birthday.isoformat() if user_loyalty.birthday else None, + 'anniversary_date': user_loyalty.anniversary_date.isoformat() if user_loyalty.anniversary_date else None, + 'tier': tier_dict, + 'next_tier': next_tier_dict, + 'points_needed_for_next_tier': points_needed, + 'tier_started_date': user_loyalty.tier_started_date.isoformat() if user_loyalty.tier_started_date else None + } + } + except HTTPException: + # Re-raise HTTPException (like 503 for disabled program) without modification + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get('/points/history') +async def get_points_history( + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + transaction_type: Optional[str] = Query(None), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get points transaction history""" + try: + user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id) + + query = db.query(LoyaltyPointTransaction).filter( + LoyaltyPointTransaction.user_loyalty_id == user_loyalty.id + ) + + if transaction_type: + try: + query = query.filter( + LoyaltyPointTransaction.transaction_type == TransactionType(transaction_type) + ) + except ValueError: + pass + + total = query.count() + offset = (page - 1) * limit + transactions = query.order_by(desc(LoyaltyPointTransaction.created_at)).offset(offset).limit(limit).all() + + result = [] + for transaction in transactions: + result.append({ + 'id': transaction.id, + 'transaction_type': transaction.transaction_type.value if hasattr(transaction.transaction_type, 'value') else transaction.transaction_type, + 'source': transaction.source.value if hasattr(transaction.source, 'value') else transaction.source, + 'points': transaction.points, + 'description': transaction.description, + 'reference_number': transaction.reference_number, + 'expires_at': transaction.expires_at.isoformat() if transaction.expires_at else None, + 'created_at': transaction.created_at.isoformat() if transaction.created_at else None, + 'booking_id': transaction.booking_id + }) + + return { + 'status': 'success', + 'data': { + 'transactions': result, + 'pagination': { + 'total': total, + 'page': page, + 'limit': limit, + 'totalPages': (total + limit - 1) // limit + } + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put('/my-status') +async def update_my_loyalty_status( + request: UpdateUserLoyaltyRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Update user loyalty information (birthday, anniversary)""" + try: + user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id) + + # Store original values to detect if date actually changed + original_birthday = user_loyalty.birthday + original_anniversary = user_loyalty.anniversary_date + + birthday_changed = False + anniversary_changed = False + + if request.birthday: + try: + new_birthday = datetime.fromisoformat(request.birthday).date() + # Check if birthday actually changed + if new_birthday != original_birthday: + birthday_changed = True + user_loyalty.birthday = new_birthday + except ValueError: + raise HTTPException(status_code=400, detail="Invalid birthday format. Use YYYY-MM-DD") + + if request.anniversary_date: + try: + new_anniversary = datetime.fromisoformat(request.anniversary_date).date() + # Check if anniversary actually changed + if new_anniversary != original_anniversary: + anniversary_changed = True + user_loyalty.anniversary_date = new_anniversary + except ValueError: + raise HTTPException(status_code=400, detail="Invalid anniversary date format. Use YYYY-MM-DD") + + db.commit() + db.refresh(user_loyalty) + + # IMPORTANT: Only check for rewards if date was NOT just changed (prevent abuse) + # If date was just changed, block rewards to prevent users from gaming the system + # by repeatedly changing their birthday/anniversary to today's date + if request.birthday and not birthday_changed: + # Date wasn't changed, safe to check for rewards + LoyaltyService.check_birthday_reward(db, current_user.id, prevent_recent_update=False) + elif request.birthday and birthday_changed: + # Date was just changed - block immediate rewards to prevent abuse + logger.warning(f"Birthday reward blocked for user {current_user.id}: birthday was just updated") + + if request.anniversary_date and not anniversary_changed: + # Date wasn't changed, safe to check for rewards + LoyaltyService.check_anniversary_reward(db, current_user.id, prevent_recent_update=False) + elif request.anniversary_date and anniversary_changed: + # Date was just changed - block immediate rewards to prevent abuse + logger.warning(f"Anniversary reward blocked for user {current_user.id}: anniversary was just updated") + + return {'status': 'success', 'message': 'Loyalty information updated successfully'} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +@router.get('/rewards') +async def get_available_rewards( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get available rewards for current user""" + try: + # Check if loyalty program is enabled + from ..models.system_settings import SystemSettings + setting = db.query(SystemSettings).filter( + SystemSettings.key == 'loyalty_program_enabled' + ).first() + is_enabled = True # Default to enabled + if setting: + is_enabled = setting.value.lower() == 'true' + + if not is_enabled: + return { + 'status': 'success', + 'data': { + 'rewards': [], + 'available_points': 0, + 'program_enabled': False + } + } + user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id) + + rewards = db.query(LoyaltyReward).filter(LoyaltyReward.is_active == True).all() + + result = [] + for reward in rewards: + # Get user's tier info for better availability checking + user_tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == user_loyalty.tier_id).first() + user_tier_min_points = user_tier.min_points if user_tier else None + + # If reward requires a specific tier, pre-load tier info for comparison + if reward.applicable_tier_id: + required_tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == reward.applicable_tier_id).first() + if required_tier: + reward._required_tier_min_points = required_tier.min_points + + is_available = reward.is_available(user_loyalty.tier_id, user_tier_min_points) + can_afford = user_loyalty.available_points >= reward.points_cost + + result.append({ + 'id': reward.id, + 'name': reward.name, + 'description': reward.description, + 'reward_type': reward.reward_type.value if hasattr(reward.reward_type, 'value') else reward.reward_type, + 'points_cost': reward.points_cost, + 'discount_percentage': float(reward.discount_percentage) if reward.discount_percentage else None, + 'discount_amount': float(reward.discount_amount) if reward.discount_amount else None, + 'max_discount_amount': float(reward.max_discount_amount) if reward.max_discount_amount else None, + 'min_booking_amount': float(reward.min_booking_amount) if reward.min_booking_amount else None, + 'icon': reward.icon, + 'image': reward.image, + 'is_available': is_available, + 'can_afford': can_afford, + 'stock_remaining': reward.stock_quantity - reward.redeemed_count if reward.stock_quantity else None, + 'valid_from': reward.valid_from.isoformat() if reward.valid_from else None, + 'valid_until': reward.valid_until.isoformat() if reward.valid_until else None + }) + + return { + 'status': 'success', + 'data': { + 'rewards': result, + 'available_points': user_loyalty.available_points + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post('/rewards/redeem') +async def redeem_reward( + request: RedeemRewardRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Redeem points for a reward""" + try: + # Check if loyalty program is enabled + from ..models.system_settings import SystemSettings + setting = db.query(SystemSettings).filter( + SystemSettings.key == 'loyalty_program_enabled' + ).first() + is_enabled = True # Default to enabled + if setting: + is_enabled = setting.value.lower() == 'true' + + if not is_enabled: + raise HTTPException( + status_code=503, + detail='Loyalty program is currently disabled. Cannot redeem rewards.' + ) + redemption = LoyaltyService.redeem_points( + db, + current_user.id, + request.reward_id, + request.booking_id + ) + + return { + 'status': 'success', + 'message': 'Reward redeemed successfully', + 'data': { + 'redemption_id': redemption.id, + 'code': redemption.code, + 'points_used': redemption.points_used, + 'status': redemption.status.value if hasattr(redemption.status, 'value') else redemption.status, + 'expires_at': redemption.expires_at.isoformat() if redemption.expires_at else None + } + } + except HTTPException: + # Re-raise HTTPException (like 503 for disabled program) without modification + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get('/rewards/my-redemptions') +async def get_my_redemptions( + status_filter: Optional[str] = Query(None), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get user's reward redemptions""" + try: + user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id) + + query = db.query(RewardRedemption).filter( + RewardRedemption.user_loyalty_id == user_loyalty.id + ).join(LoyaltyReward) + + if status_filter: + try: + query = query.filter(RewardRedemption.status == RedemptionStatus(status_filter)) + except ValueError: + pass + + redemptions = query.order_by(desc(RewardRedemption.created_at)).all() + + result = [] + for redemption in redemptions: + reward = redemption.reward + result.append({ + 'id': redemption.id, + 'reward': { + 'id': reward.id, + 'name': reward.name, + 'description': reward.description, + 'reward_type': reward.reward_type.value if hasattr(reward.reward_type, 'value') else reward.reward_type, + 'discount_percentage': float(reward.discount_percentage) if reward.discount_percentage else None, + 'discount_amount': float(reward.discount_amount) if reward.discount_amount else None, + 'max_discount_amount': float(reward.max_discount_amount) if reward.max_discount_amount else None, + 'min_booking_amount': float(reward.min_booking_amount) if reward.min_booking_amount else None + }, + 'points_used': redemption.points_used, + 'status': redemption.status.value if hasattr(redemption.status, 'value') else redemption.status, + 'code': redemption.code, + 'booking_id': redemption.booking_id, + 'expires_at': redemption.expires_at.isoformat() if redemption.expires_at else None, + 'used_at': redemption.used_at.isoformat() if redemption.used_at else None, + 'created_at': redemption.created_at.isoformat() if redemption.created_at else None + }) + + return { + 'status': 'success', + 'data': { + 'redemptions': result + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post('/referral/apply') +async def apply_referral_code( + request: ApplyReferralRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Apply referral code (typically during registration or first booking)""" + try: + user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id) + + # Check if user already has referrals (can't use referral code if already referred) + existing_referral = db.query(Referral).filter( + Referral.referred_user_id == current_user.id + ).first() + + if existing_referral: + raise HTTPException(status_code=400, detail="You have already used a referral code") + + # Process referral (will be completed when booking is made) + LoyaltyService.process_referral( + db, + current_user.id, + request.referral_code, + None # No booking yet + ) + + return { + 'status': 'success', + 'message': 'Referral code applied successfully. You will receive bonus points when you complete your first booking.' + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get('/referral/my-referrals') +async def get_my_referrals( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get user's referrals (people they referred)""" + try: + user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id) + + referrals = db.query(Referral).filter( + Referral.referrer_id == user_loyalty.id + ).order_by(desc(Referral.created_at)).all() + + result = [] + for referral in referrals: + referred_user = referral.referred_user + result.append({ + 'id': referral.id, + 'referred_user': { + 'id': referred_user.id, + 'name': referred_user.full_name, + 'email': referred_user.email + } if referred_user else None, + 'referral_code': referral.referral_code, + 'status': referral.status.value if hasattr(referral.status, 'value') else referral.status, + 'referrer_points_earned': referral.referrer_points_earned, + 'referred_points_earned': referral.referred_points_earned, + 'completed_at': referral.completed_at.isoformat() if referral.completed_at else None, + 'rewarded_at': referral.rewarded_at.isoformat() if referral.rewarded_at else None, + 'created_at': referral.created_at.isoformat() if referral.created_at else None + }) + + return { + 'status': 'success', + 'data': { + 'referrals': result, + 'total_referrals': len(result), + 'total_points_earned': sum(r.referrer_points_earned for r in referrals) + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +# Admin routes - Program Control +@router.get('/admin/status') +async def get_loyalty_program_status( + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Get loyalty program enabled/disabled status""" + try: + from ..models.system_settings import SystemSettings + setting = db.query(SystemSettings).filter( + SystemSettings.key == 'loyalty_program_enabled' + ).first() + + is_enabled = True # Default to enabled + if setting: + is_enabled = setting.value.lower() == 'true' + + return { + 'status': 'success', + 'data': { + 'enabled': is_enabled, + 'updated_at': setting.updated_at.isoformat() if setting and setting.updated_at else None, + 'updated_by': setting.updated_by.full_name if setting and setting.updated_by else None + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.put('/admin/status') +async def update_loyalty_program_status( + status_data: dict, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Enable/disable loyalty program""" + try: + from ..models.system_settings import SystemSettings + enabled = status_data.get('enabled', True) + + setting = db.query(SystemSettings).filter( + SystemSettings.key == 'loyalty_program_enabled' + ).first() + + if setting: + setting.value = str(enabled).lower() + setting.updated_by_id = current_user.id + else: + setting = SystemSettings( + key='loyalty_program_enabled', + value=str(enabled).lower(), + description='Enable or disable the loyalty program', + updated_by_id=current_user.id + ) + db.add(setting) + + db.commit() + db.refresh(setting) + + return { + 'status': 'success', + 'message': f'Loyalty program {"enabled" if enabled else "disabled"} successfully', + 'data': { + 'enabled': enabled, + 'updated_at': setting.updated_at.isoformat() if setting.updated_at else None + } + } + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +# Admin routes - Tiers Management +@router.get('/admin/tiers') +async def get_all_tiers( + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Get all loyalty tiers (admin)""" + try: + tiers = db.query(LoyaltyTier).order_by(LoyaltyTier.min_points.asc()).all() + + result = [] + for tier in tiers: + result.append({ + 'id': tier.id, + 'level': tier.level.value if hasattr(tier.level, 'value') else tier.level, + 'name': tier.name, + 'description': tier.description, + 'min_points': tier.min_points, + 'points_earn_rate': float(tier.points_earn_rate) if tier.points_earn_rate else 1.0, + 'discount_percentage': float(tier.discount_percentage) if tier.discount_percentage else 0, + 'benefits': tier.benefits, + 'icon': tier.icon, + 'color': tier.color, + 'is_active': tier.is_active, + 'created_at': tier.created_at.isoformat() if tier.created_at else None, + 'updated_at': tier.updated_at.isoformat() if tier.updated_at else None + }) + + return { + 'status': 'success', + 'data': { + 'tiers': result + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post('/admin/tiers') +async def create_tier( + tier_data: dict, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Create a new loyalty tier""" + try: + # Check if tier level already exists + existing = db.query(LoyaltyTier).filter( + LoyaltyTier.level == TierLevel(tier_data['level']) + ).first() + if existing: + raise HTTPException(status_code=400, detail=f"Tier level {tier_data['level']} already exists") + + tier = LoyaltyTier( + level=TierLevel(tier_data['level']), + name=tier_data['name'], + description=tier_data.get('description'), + min_points=tier_data.get('min_points', 0), + points_earn_rate=tier_data.get('points_earn_rate', 1.0), + discount_percentage=tier_data.get('discount_percentage', 0), + benefits=tier_data.get('benefits'), + icon=tier_data.get('icon'), + color=tier_data.get('color'), + is_active=tier_data.get('is_active', True) + ) + db.add(tier) + db.commit() + db.refresh(tier) + + return { + 'status': 'success', + 'message': 'Tier created successfully', + 'data': { + 'id': tier.id, + 'level': tier.level.value if hasattr(tier.level, 'value') else tier.level, + 'name': tier.name + } + } + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +@router.put('/admin/tiers/{tier_id}') +async def update_tier( + tier_id: int, + tier_data: dict, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Update a loyalty tier""" + try: + tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == tier_id).first() + if not tier: + raise HTTPException(status_code=404, detail='Tier not found') + + # Check if level is being changed and if new level exists + if 'level' in tier_data and tier_data['level'] != tier.level.value: + existing = db.query(LoyaltyTier).filter( + LoyaltyTier.level == TierLevel(tier_data['level']), + LoyaltyTier.id != tier_id + ).first() + if existing: + raise HTTPException(status_code=400, detail=f"Tier level {tier_data['level']} already exists") + tier.level = TierLevel(tier_data['level']) + + if 'name' in tier_data: + tier.name = tier_data['name'] + if 'description' in tier_data: + tier.description = tier_data['description'] + if 'min_points' in tier_data: + tier.min_points = tier_data['min_points'] + if 'points_earn_rate' in tier_data: + tier.points_earn_rate = tier_data['points_earn_rate'] + if 'discount_percentage' in tier_data: + tier.discount_percentage = tier_data['discount_percentage'] + if 'benefits' in tier_data: + tier.benefits = tier_data['benefits'] + if 'icon' in tier_data: + tier.icon = tier_data['icon'] + if 'color' in tier_data: + tier.color = tier_data['color'] + if 'is_active' in tier_data: + tier.is_active = tier_data['is_active'] + + db.commit() + db.refresh(tier) + + return { + 'status': 'success', + 'message': 'Tier updated successfully', + 'data': { + 'id': tier.id, + 'level': tier.level.value if hasattr(tier.level, 'value') else tier.level, + 'name': tier.name + } + } + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete('/admin/tiers/{tier_id}') +async def delete_tier( + tier_id: int, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Delete a loyalty tier""" + try: + tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == tier_id).first() + if not tier: + raise HTTPException(status_code=404, detail='Tier not found') + + # Check if any users are using this tier + users_count = db.query(UserLoyalty).filter(UserLoyalty.tier_id == tier_id).count() + if users_count > 0: + raise HTTPException( + status_code=400, + detail=f'Cannot delete tier. {users_count} user(s) are currently assigned to this tier. Please reassign them first.' + ) + + db.delete(tier) + db.commit() + + return { + 'status': 'success', + 'message': 'Tier deleted successfully' + } + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +@router.get('/admin/users') +async def get_users_loyalty_status( + search: Optional[str] = Query(None), + tier_id: Optional[int] = Query(None), + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Get all users' loyalty status (admin)""" + try: + query = db.query(UserLoyalty).join(User) + + if search: + query = query.filter( + User.full_name.like(f'%{search}%') | + User.email.like(f'%{search}%') + ) + + if tier_id: + query = query.filter(UserLoyalty.tier_id == tier_id) + + total = query.count() + offset = (page - 1) * limit + user_loyalties = query.order_by(desc(UserLoyalty.lifetime_points)).offset(offset).limit(limit).all() + + result = [] + for user_loyalty in user_loyalties: + user = user_loyalty.user + tier = user_loyalty.tier + + result.append({ + 'user_id': user.id, + 'user_name': user.full_name, + 'user_email': user.email, + 'tier': { + 'id': tier.id, + 'name': tier.name, + 'level': tier.level.value if hasattr(tier.level, 'value') else tier.level + } if tier else None, + 'total_points': user_loyalty.total_points, + 'lifetime_points': user_loyalty.lifetime_points, + 'available_points': user_loyalty.available_points, + 'referral_count': user_loyalty.referral_count, + 'tier_started_date': user_loyalty.tier_started_date.isoformat() if user_loyalty.tier_started_date else None + }) + + return { + 'status': 'success', + 'data': { + 'users': result, + 'pagination': { + 'total': total, + 'page': page, + 'limit': limit, + 'totalPages': (total + limit - 1) // limit + } + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +# Admin routes - Rewards Management +@router.get('/admin/rewards') +async def get_all_rewards_admin( + current_user: User = Depends(authorize_roles('admin', 'staff')), + db: Session = Depends(get_db) +): + """Get all rewards (admin)""" + try: + rewards = db.query(LoyaltyReward).order_by(LoyaltyReward.created_at.desc()).all() + + result = [] + for reward in rewards: + result.append({ + 'id': reward.id, + 'name': reward.name, + 'description': reward.description, + 'reward_type': reward.reward_type.value if hasattr(reward.reward_type, 'value') else reward.reward_type, + 'points_cost': reward.points_cost, + 'discount_percentage': float(reward.discount_percentage) if reward.discount_percentage else None, + 'discount_amount': float(reward.discount_amount) if reward.discount_amount else None, + 'max_discount_amount': float(reward.max_discount_amount) if reward.max_discount_amount else None, + 'applicable_tier_id': reward.applicable_tier_id, + 'min_booking_amount': float(reward.min_booking_amount) if reward.min_booking_amount else None, + 'icon': reward.icon, + 'image': reward.image, + 'is_active': reward.is_active, + 'stock_quantity': reward.stock_quantity, + 'redeemed_count': reward.redeemed_count, + 'valid_from': reward.valid_from.isoformat() if reward.valid_from else None, + 'valid_until': reward.valid_until.isoformat() if reward.valid_until else None, + 'created_at': reward.created_at.isoformat() if reward.created_at else None, + 'updated_at': reward.updated_at.isoformat() if reward.updated_at else None + }) + + return { + 'status': 'success', + 'data': { + 'rewards': result + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post('/admin/rewards') +async def create_reward( + reward_data: dict, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Create a new reward""" + try: + reward = LoyaltyReward( + name=reward_data['name'], + description=reward_data.get('description'), + reward_type=RewardType(reward_data['reward_type']), + points_cost=reward_data['points_cost'], + discount_percentage=reward_data.get('discount_percentage'), + discount_amount=reward_data.get('discount_amount'), + max_discount_amount=reward_data.get('max_discount_amount'), + applicable_tier_id=reward_data.get('applicable_tier_id'), + min_booking_amount=reward_data.get('min_booking_amount'), + icon=reward_data.get('icon'), + image=reward_data.get('image'), + is_active=reward_data.get('is_active', True), + stock_quantity=reward_data.get('stock_quantity'), + valid_from=datetime.fromisoformat(reward_data['valid_from']) if reward_data.get('valid_from') else None, + valid_until=datetime.fromisoformat(reward_data['valid_until']) if reward_data.get('valid_until') else None + ) + db.add(reward) + db.commit() + db.refresh(reward) + + return { + 'status': 'success', + 'message': 'Reward created successfully', + 'data': { + 'id': reward.id, + 'name': reward.name + } + } + except ValueError as e: + raise HTTPException(status_code=400, detail=f'Invalid reward type: {str(e)}') + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +@router.put('/admin/rewards/{reward_id}') +async def update_reward( + reward_id: int, + reward_data: dict, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Update a reward""" + try: + reward = db.query(LoyaltyReward).filter(LoyaltyReward.id == reward_id).first() + if not reward: + raise HTTPException(status_code=404, detail='Reward not found') + + if 'name' in reward_data: + reward.name = reward_data['name'] + if 'description' in reward_data: + reward.description = reward_data['description'] + if 'reward_type' in reward_data: + reward.reward_type = RewardType(reward_data['reward_type']) + if 'points_cost' in reward_data: + reward.points_cost = reward_data['points_cost'] + if 'discount_percentage' in reward_data: + reward.discount_percentage = reward_data['discount_percentage'] + if 'discount_amount' in reward_data: + reward.discount_amount = reward_data['discount_amount'] + if 'max_discount_amount' in reward_data: + reward.max_discount_amount = reward_data['max_discount_amount'] + if 'applicable_tier_id' in reward_data: + reward.applicable_tier_id = reward_data['applicable_tier_id'] + if 'min_booking_amount' in reward_data: + reward.min_booking_amount = reward_data['min_booking_amount'] + if 'icon' in reward_data: + reward.icon = reward_data['icon'] + if 'image' in reward_data: + reward.image = reward_data['image'] + if 'is_active' in reward_data: + reward.is_active = reward_data['is_active'] + if 'stock_quantity' in reward_data: + reward.stock_quantity = reward_data['stock_quantity'] + if 'valid_from' in reward_data: + reward.valid_from = datetime.fromisoformat(reward_data['valid_from']) if reward_data['valid_from'] else None + if 'valid_until' in reward_data: + reward.valid_until = datetime.fromisoformat(reward_data['valid_until']) if reward_data['valid_until'] else None + + db.commit() + db.refresh(reward) + + return { + 'status': 'success', + 'message': 'Reward updated successfully', + 'data': { + 'id': reward.id, + 'name': reward.name + } + } + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=f'Invalid data: {str(e)}') + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete('/admin/rewards/{reward_id}') +async def delete_reward( + reward_id: int, + current_user: User = Depends(authorize_roles('admin')), + db: Session = Depends(get_db) +): + """Delete a reward""" + try: + reward = db.query(LoyaltyReward).filter(LoyaltyReward.id == reward_id).first() + if not reward: + raise HTTPException(status_code=404, detail='Reward not found') + + # Check if any redemptions exist + redemptions_count = db.query(RewardRedemption).filter(RewardRedemption.reward_id == reward_id).count() + if redemptions_count > 0: + raise HTTPException( + status_code=400, + detail=f'Cannot delete reward. {redemptions_count} redemption(s) exist. Deactivate it instead.' + ) + + db.delete(reward) + db.commit() + + return { + 'status': 'success', + 'message': 'Reward deleted successfully' + } + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/Backend/src/routes/payment_routes.py b/Backend/src/routes/payment_routes.py index ed1e86f7..16334154 100644 --- a/Backend/src/routes/payment_routes.py +++ b/Backend/src/routes/payment_routes.py @@ -13,6 +13,7 @@ from ..utils.mailer import send_email from ..utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template from ..services.stripe_service import StripeService from ..services.paypal_service import PayPalService +from ..services.loyalty_service import LoyaltyService router = APIRouter(prefix='/payments', tags=['payments']) async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str='Payment failed or canceled'): @@ -137,6 +138,29 @@ async def create_payment(payment_data: dict, current_user: User=Depends(get_curr db.add(payment) db.commit() db.refresh(payment) + + # Award loyalty points if payment completed and booking is confirmed + if payment.payment_status == PaymentStatus.completed and booking: + try: + db.refresh(booking) + if booking.status == BookingStatus.confirmed and booking.user: + # Check if booking already earned points + from ..models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionSource + existing_points = db.query(LoyaltyPointTransaction).filter( + LoyaltyPointTransaction.booking_id == booking.id, + LoyaltyPointTransaction.source == TransactionSource.booking + ).first() + + if not existing_points: + # Award points based on payment amount + total_paid = sum(float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed) + if total_paid > 0: + LoyaltyService.earn_points_from_booking(db, booking.user_id, booking, total_paid) + except Exception as loyalty_error: + import logging + logger = logging.getLogger(__name__) + logger.error(f'Failed to award loyalty points: {loyalty_error}') + if payment.payment_status == PaymentStatus.completed and booking.user: try: from ..models.system_settings import SystemSettings diff --git a/Backend/src/schemas/__pycache__/auth.cpython-312.pyc b/Backend/src/schemas/__pycache__/auth.cpython-312.pyc index a6dd2072..95d42a92 100644 Binary files a/Backend/src/schemas/__pycache__/auth.cpython-312.pyc and b/Backend/src/schemas/__pycache__/auth.cpython-312.pyc differ diff --git a/Backend/src/services/__pycache__/guest_profile_service.cpython-312.pyc b/Backend/src/services/__pycache__/guest_profile_service.cpython-312.pyc new file mode 100644 index 00000000..1e7a985a Binary files /dev/null and b/Backend/src/services/__pycache__/guest_profile_service.cpython-312.pyc differ diff --git a/Backend/src/services/__pycache__/loyalty_service.cpython-312.pyc b/Backend/src/services/__pycache__/loyalty_service.cpython-312.pyc new file mode 100644 index 00000000..3c53755c Binary files /dev/null and b/Backend/src/services/__pycache__/loyalty_service.cpython-312.pyc differ diff --git a/Backend/src/services/guest_profile_service.py b/Backend/src/services/guest_profile_service.py new file mode 100644 index 00000000..01679be3 --- /dev/null +++ b/Backend/src/services/guest_profile_service.py @@ -0,0 +1,262 @@ +from sqlalchemy.orm import Session +from sqlalchemy import func, and_, or_, desc +from typing import List, Dict, Optional +from datetime import datetime, timedelta +from decimal import Decimal +from ..models.user import User +from ..models.booking import Booking, BookingStatus +from ..models.payment import Payment +from ..models.review import Review +from ..models.guest_preference import GuestPreference +from ..models.guest_note import GuestNote +from ..models.guest_tag import GuestTag +from ..models.guest_communication import GuestCommunication +from ..models.guest_segment import GuestSegment, guest_segment_association + +class GuestProfileService: + + @staticmethod + def calculate_lifetime_value(user_id: int, db: Session) -> Decimal: + """Calculate guest lifetime value from all bookings and payments""" + from ..models.payment import PaymentStatus + + # Get payments through bookings + total_revenue = db.query(func.coalesce(func.sum(Payment.amount), 0)).join( + Booking, Payment.booking_id == Booking.id + ).filter( + Booking.user_id == user_id, + Payment.payment_status == PaymentStatus.completed + ).scalar() + + # Also include service bookings + from ..models.service_booking import ServiceBooking, ServiceBookingStatus + service_revenue = db.query(func.coalesce(func.sum(ServiceBooking.total_amount), 0)).filter( + ServiceBooking.user_id == user_id, + ServiceBooking.status == ServiceBookingStatus.completed + ).scalar() + + return Decimal(str(total_revenue or 0)) + Decimal(str(service_revenue or 0)) + + @staticmethod + def calculate_satisfaction_score(user_id: int, db: Session) -> Optional[float]: + """Calculate average satisfaction score from reviews""" + avg_rating = db.query(func.avg(Review.rating)).filter( + Review.user_id == user_id, + Review.status == 'approved' + ).scalar() + + return float(avg_rating) if avg_rating else None + + @staticmethod + def get_booking_history(user_id: int, db: Session, limit: Optional[int] = None) -> List[Booking]: + """Get complete booking history for a guest""" + query = db.query(Booking).filter(Booking.user_id == user_id).order_by(desc(Booking.created_at)) + if limit: + query = query.limit(limit) + return query.all() + + @staticmethod + def get_booking_statistics(user_id: int, db: Session) -> Dict: + """Get booking statistics for a guest""" + total_bookings = db.query(Booking).filter(Booking.user_id == user_id).count() + completed_bookings = db.query(Booking).filter( + Booking.user_id == user_id, + Booking.status == BookingStatus.checked_out + ).count() + cancelled_bookings = db.query(Booking).filter( + Booking.user_id == user_id, + Booking.status == BookingStatus.cancelled + ).count() + + # Get last visit date + last_booking = db.query(Booking).filter( + Booking.user_id == user_id, + Booking.status == BookingStatus.checked_out + ).order_by(desc(Booking.check_in_date)).first() + + last_visit_date = last_booking.check_in_date if last_booking else None + + # Get total nights stayed + total_nights = db.query( + func.sum(func.extract('day', Booking.check_out_date - Booking.check_in_date)) + ).filter( + Booking.user_id == user_id, + Booking.status == BookingStatus.checked_out + ).scalar() or 0 + + return { + 'total_bookings': total_bookings, + 'completed_bookings': completed_bookings, + 'cancelled_bookings': cancelled_bookings, + 'last_visit_date': last_visit_date.isoformat() if last_visit_date else None, + 'total_nights_stayed': int(total_nights) + } + + @staticmethod + def update_guest_metrics(user_id: int, db: Session) -> Dict: + """Update guest metrics (lifetime value, satisfaction score, etc.)""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + return None + + # Calculate and update lifetime value + lifetime_value = GuestProfileService.calculate_lifetime_value(user_id, db) + user.lifetime_value = lifetime_value + + # Calculate and update satisfaction score + satisfaction_score = GuestProfileService.calculate_satisfaction_score(user_id, db) + if satisfaction_score: + user.satisfaction_score = Decimal(str(satisfaction_score)) + + # Update total visits + stats = GuestProfileService.get_booking_statistics(user_id, db) + user.total_visits = stats['completed_bookings'] + if stats['last_visit_date']: + user.last_visit_date = datetime.fromisoformat(stats['last_visit_date'].replace('Z', '+00:00')) + + db.commit() + db.refresh(user) + + return { + 'lifetime_value': float(lifetime_value), + 'satisfaction_score': satisfaction_score, + 'total_visits': user.total_visits, + 'last_visit_date': user.last_visit_date.isoformat() if user.last_visit_date else None + } + + @staticmethod + def get_guest_segments(user_id: int, db: Session) -> List[GuestSegment]: + """Get all segments a guest belongs to""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + return [] + return user.guest_segments + + @staticmethod + def assign_segment(user_id: int, segment_id: int, db: Session) -> bool: + """Assign a guest to a segment""" + user = db.query(User).filter(User.id == user_id).first() + segment = db.query(GuestSegment).filter(GuestSegment.id == segment_id).first() + + if not user or not segment: + return False + + if segment not in user.guest_segments: + user.guest_segments.append(segment) + db.commit() + return True + + @staticmethod + def remove_segment(user_id: int, segment_id: int, db: Session) -> bool: + """Remove a guest from a segment""" + user = db.query(User).filter(User.id == user_id).first() + segment = db.query(GuestSegment).filter(GuestSegment.id == segment_id).first() + + if not user or not segment: + return False + + if segment in user.guest_segments: + user.guest_segments.remove(segment) + db.commit() + return True + + @staticmethod + def get_guest_analytics(user_id: int, db: Session) -> Dict: + """Get comprehensive analytics for a guest""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + return None + + # Get booking statistics + booking_stats = GuestProfileService.get_booking_statistics(user_id, db) + + # Get lifetime value + lifetime_value = GuestProfileService.calculate_lifetime_value(user_id, db) + + # Get satisfaction score + satisfaction_score = GuestProfileService.calculate_satisfaction_score(user_id, db) + + # Get preferred room types + bookings = db.query(Booking).filter(Booking.user_id == user_id).all() + room_type_counts = {} + for booking in bookings: + if booking.room and booking.room.room_type: + room_type_name = booking.room.room_type.name + room_type_counts[room_type_name] = room_type_counts.get(room_type_name, 0) + 1 + + preferred_room_type = max(room_type_counts.items(), key=lambda x: x[1])[0] if room_type_counts else None + + # Get average booking value + avg_booking_value = db.query(func.avg(Booking.total_price)).filter( + Booking.user_id == user_id, + Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_out]) + ).scalar() or 0 + + # Get communication count + communication_count = db.query(GuestCommunication).filter( + GuestCommunication.user_id == user_id + ).count() + + return { + 'lifetime_value': float(lifetime_value), + 'satisfaction_score': satisfaction_score, + 'booking_statistics': booking_stats, + 'preferred_room_type': preferred_room_type, + 'average_booking_value': float(avg_booking_value), + 'communication_count': communication_count, + 'is_vip': user.is_vip, + 'total_visits': user.total_visits, + 'last_visit_date': user.last_visit_date.isoformat() if user.last_visit_date else None + } + + @staticmethod + def search_guests( + db: Session, + search: Optional[str] = None, + is_vip: Optional[bool] = None, + segment_id: Optional[int] = None, + min_lifetime_value: Optional[float] = None, + min_satisfaction_score: Optional[float] = None, + tag_id: Optional[int] = None, + page: int = 1, + limit: int = 10 + ) -> Dict: + """Search and filter guests with various criteria""" + query = db.query(User).filter(User.role_id == 3) # Only customers + + if search: + query = query.filter( + or_( + User.full_name.ilike(f'%{search}%'), + User.email.ilike(f'%{search}%'), + User.phone.ilike(f'%{search}%') + ) + ) + + if is_vip is not None: + query = query.filter(User.is_vip == is_vip) + + if segment_id: + query = query.join(User.guest_segments).filter(GuestSegment.id == segment_id) + + if min_lifetime_value is not None: + query = query.filter(User.lifetime_value >= Decimal(str(min_lifetime_value))) + + if min_satisfaction_score is not None: + query = query.filter(User.satisfaction_score >= Decimal(str(min_satisfaction_score))) + + if tag_id: + query = query.join(User.guest_tags).filter(GuestTag.id == tag_id) + + total = query.count() + offset = (page - 1) * limit + guests = query.order_by(desc(User.lifetime_value)).offset(offset).limit(limit).all() + + return { + 'guests': guests, + 'total': total, + 'page': page, + 'limit': limit, + 'total_pages': (total + limit - 1) // limit + } + diff --git a/Backend/src/services/loyalty_service.py b/Backend/src/services/loyalty_service.py new file mode 100644 index 00000000..617cecb3 --- /dev/null +++ b/Backend/src/services/loyalty_service.py @@ -0,0 +1,635 @@ +from sqlalchemy.orm import Session +from datetime import datetime, timedelta, date +from typing import Optional +import random +import string +from ..models.user_loyalty import UserLoyalty +from ..models.loyalty_tier import LoyaltyTier, TierLevel +from ..models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionType, TransactionSource +from ..models.loyalty_reward import LoyaltyReward +from ..models.reward_redemption import RewardRedemption, RedemptionStatus +from ..models.referral import Referral, ReferralStatus +from ..models.booking import Booking, BookingStatus +from ..models.user import User +import logging + +logger = logging.getLogger(__name__) + +class LoyaltyService: + # Points earning rates + POINTS_PER_DOLLAR = 10 # Base: 10 points per dollar spent + BIRTHDAY_BONUS_POINTS = 500 + ANNIVERSARY_BONUS_POINTS = 1000 + REFERRER_POINTS = 500 # Points for referrer when referral completes booking + REFERRED_BONUS_POINTS = 250 # Bonus points for new referred user + + # Points expiration (in days) + POINTS_EXPIRATION_DAYS = 365 # Points expire after 1 year + + @staticmethod + def is_loyalty_enabled(db: Session) -> bool: + """Check if loyalty program is enabled""" + try: + from ..models.system_settings import SystemSettings + setting = db.query(SystemSettings).filter( + SystemSettings.key == 'loyalty_program_enabled' + ).first() + + if not setting: + return True # Default to enabled + + return setting.value.lower() == 'true' + except Exception: + return True # Default to enabled on error + + @staticmethod + def get_or_create_user_loyalty(db: Session, user_id: int) -> UserLoyalty: + """Get or create loyalty record for user""" + user_loyalty = db.query(UserLoyalty).filter(UserLoyalty.user_id == user_id).first() + + if not user_loyalty: + # Get bronze tier (lowest tier) + bronze_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.bronze).first() + + if not bronze_tier: + # Create default tiers if they don't exist + LoyaltyService.create_default_tiers(db) + bronze_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.bronze).first() + + # Generate referral code + referral_code = LoyaltyService.generate_referral_code(db, user_id) + + user_loyalty = UserLoyalty( + user_id=user_id, + tier_id=bronze_tier.id, + total_points=0, + lifetime_points=0, + available_points=0, + expired_points=0, + referral_code=referral_code, + referral_count=0, + tier_started_date=datetime.utcnow() + ) + db.add(user_loyalty) + db.commit() + db.refresh(user_loyalty) + + return user_loyalty + + @staticmethod + def generate_referral_code(db: Session, user_id: int, length: int = 8) -> str: + """Generate unique referral code for user""" + max_attempts = 10 + for _ in range(max_attempts): + # Generate code: USER1234 format + code = f"USER{user_id:04d}{''.join(random.choices(string.ascii_uppercase + string.digits, k=length-8))}" + + # Check if code exists + existing = db.query(UserLoyalty).filter(UserLoyalty.referral_code == code).first() + if not existing: + return code + + # Fallback: timestamp-based + return f"REF{int(datetime.utcnow().timestamp())}{user_id}" + + @staticmethod + def create_default_tiers(db: Session): + """Create default loyalty tiers if they don't exist""" + tiers_data = [ + { + 'level': TierLevel.bronze, + 'name': 'Bronze', + 'description': 'Starting tier - Earn points on every booking', + 'min_points': 0, + 'points_earn_rate': 1.0, + 'discount_percentage': 0, + 'benefits': 'Welcome bonus points, Access to basic rewards', + 'color': '#CD7F32' + }, + { + 'level': TierLevel.silver, + 'name': 'Silver', + 'description': 'Earn points faster with 1.25x multiplier', + 'min_points': 5000, + 'points_earn_rate': 1.25, + 'discount_percentage': 2, + 'benefits': '25% faster point earning, 2% member discount, Priority customer support', + 'color': '#C0C0C0' + }, + { + 'level': TierLevel.gold, + 'name': 'Gold', + 'description': 'Premium tier with exclusive benefits', + 'min_points': 15000, + 'points_earn_rate': 1.5, + 'discount_percentage': 5, + 'benefits': '50% faster point earning, 5% member discount, Room upgrade priority, Exclusive rewards', + 'color': '#FFD700' + }, + { + 'level': TierLevel.platinum, + 'name': 'Platinum', + 'description': 'Elite tier with maximum benefits', + 'min_points': 50000, + 'points_earn_rate': 2.0, + 'discount_percentage': 10, + 'benefits': '100% faster point earning, 10% member discount, Guaranteed room upgrades, Concierge service, Birthday & anniversary bonuses', + 'color': '#E5E4E2' + } + ] + + for tier_data in tiers_data: + existing = db.query(LoyaltyTier).filter(LoyaltyTier.level == tier_data['level']).first() + if not existing: + tier = LoyaltyTier(**tier_data) + db.add(tier) + + db.commit() + + @staticmethod + def earn_points_from_booking( + db: Session, + user_id: int, + booking: Booking, + amount: float + ) -> int: + """Earn points from completed booking""" + try: + # Check if loyalty program is enabled + if not LoyaltyService.is_loyalty_enabled(db): + logger.info("Loyalty program is disabled. Skipping points earning.") + return 0 + user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, user_id) + tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == user_loyalty.tier_id).first() + + if not tier: + logger.error(f"Tier not found for user {user_id}") + return 0 + + # Calculate base points (points per dollar * amount) + base_points = int(amount * LoyaltyService.POINTS_PER_DOLLAR) + + # Apply tier multiplier + tier_multiplier = float(tier.points_earn_rate) if tier.points_earn_rate else 1.0 + points_earned = int(base_points * tier_multiplier) + + # Calculate expiration date + expires_at = datetime.utcnow() + timedelta(days=LoyaltyService.POINTS_EXPIRATION_DAYS) + + # Create transaction + transaction = LoyaltyPointTransaction( + user_loyalty_id=user_loyalty.id, + booking_id=booking.id, + transaction_type=TransactionType.earned, + source=TransactionSource.booking, + points=points_earned, + description=f"Points earned from booking {booking.booking_number}", + expires_at=expires_at, + reference_number=booking.booking_number + ) + db.add(transaction) + + # Update user loyalty + user_loyalty.total_points += points_earned + user_loyalty.lifetime_points += points_earned + user_loyalty.available_points += points_earned + user_loyalty.last_points_earned_date = datetime.utcnow() + + # Check for tier upgrade + LoyaltyService.check_and_upgrade_tier(db, user_loyalty) + + db.commit() + db.refresh(user_loyalty) + + logger.info(f"User {user_id} earned {points_earned} points from booking {booking.booking_number}") + return points_earned + + except Exception as e: + logger.error(f"Error earning points from booking: {str(e)}") + db.rollback() + return 0 + + @staticmethod + def check_and_upgrade_tier(db: Session, user_loyalty: UserLoyalty): + """Check if user should be upgraded to higher tier""" + current_tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == user_loyalty.tier_id).first() + if not current_tier: + return + + # Get all tiers ordered by min_points descending + all_tiers = db.query(LoyaltyTier).filter(LoyaltyTier.is_active == True).order_by(LoyaltyTier.min_points.desc()).all() + + for tier in all_tiers: + if user_loyalty.lifetime_points >= tier.min_points and tier.min_points > current_tier.min_points: + # Upgrade to this tier + user_loyalty.tier_id = tier.id + user_loyalty.tier_started_date = datetime.utcnow() + + # Calculate next tier points + next_tier = LoyaltyService.get_next_tier(db, tier) + if next_tier: + user_loyalty.next_tier_points_needed = next_tier.min_points - user_loyalty.lifetime_points + else: + user_loyalty.next_tier_points_needed = None + + logger.info(f"User {user_loyalty.user_id} upgraded to {tier.name} tier") + break + + @staticmethod + def get_next_tier(db: Session, current_tier: LoyaltyTier) -> Optional[LoyaltyTier]: + """Get next tier above current tier""" + all_tiers = db.query(LoyaltyTier).filter( + LoyaltyTier.is_active == True, + LoyaltyTier.min_points > current_tier.min_points + ).order_by(LoyaltyTier.min_points.asc()).first() + + return all_tiers + + @staticmethod + def redeem_points( + db: Session, + user_id: int, + reward_id: int, + booking_id: Optional[int] = None + ) -> Optional[RewardRedemption]: + """Redeem points for a reward""" + try: + user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, user_id) + reward = db.query(LoyaltyReward).filter(LoyaltyReward.id == reward_id).first() + + if not reward: + raise ValueError("Reward not found") + + # Get user's tier info for better availability checking + user_tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == user_loyalty.tier_id).first() + user_tier_min_points = user_tier.min_points if user_tier else None + + # If reward requires a specific tier, pre-load tier info for comparison + if reward.applicable_tier_id: + required_tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == reward.applicable_tier_id).first() + if required_tier: + reward._required_tier_min_points = required_tier.min_points + + if not reward.is_available(user_loyalty.tier_id, user_tier_min_points): + # Log detailed reason for debugging + logger.warning( + f"Reward {reward.id} not available for user {user_id}: " + f"reward_tier={reward.applicable_tier_id}, user_tier={user_loyalty.tier_id}, " + f"user_tier_points={user_tier_min_points}, reward_active={reward.is_active}, " + f"stock={reward.redeemed_count}/{reward.stock_quantity if reward.stock_quantity else 'unlimited'}" + ) + if not reward.is_active: + raise ValueError("Reward is not active") + if reward.applicable_tier_id and user_loyalty.tier_id != reward.applicable_tier_id: + raise ValueError(f"Reward is only available for specific tier") + if reward.valid_until and datetime.utcnow() > reward.valid_until: + raise ValueError("Reward has expired") + if reward.stock_quantity is not None and reward.redeemed_count >= reward.stock_quantity: + raise ValueError("Reward is out of stock") + raise ValueError("Reward is not available for your tier or has expired") + + if user_loyalty.available_points < reward.points_cost: + raise ValueError( + f"Insufficient points. Required: {reward.points_cost}, Available: {user_loyalty.available_points}" + ) + + # Generate redemption code + redemption_code = LoyaltyService.generate_redemption_code(db) + + # Create redemption + redemption = RewardRedemption( + user_loyalty_id=user_loyalty.id, + reward_id=reward.id, + booking_id=booking_id, + points_used=reward.points_cost, + status=RedemptionStatus.active, + code=redemption_code, + expires_at=datetime.utcnow() + timedelta(days=365) if reward.reward_type == 'voucher' else None + ) + db.add(redemption) + + # Deduct points + user_loyalty.available_points -= reward.points_cost + user_loyalty.total_points -= reward.points_cost + + # Create transaction + transaction = LoyaltyPointTransaction( + user_loyalty_id=user_loyalty.id, + booking_id=booking_id, + transaction_type=TransactionType.redeemed, + source=TransactionSource.redemption, + points=-reward.points_cost, + description=f"Points redeemed for {reward.name}", + reference_number=redemption_code + ) + db.add(transaction) + + # Update reward redemption count + reward.redeemed_count += 1 + + db.commit() + db.refresh(redemption) + + logger.info(f"User {user_id} redeemed {reward.points_cost} points for reward {reward.name}") + return redemption + + except Exception as e: + logger.error(f"Error redeeming points: {str(e)}") + db.rollback() + raise + + @staticmethod + def generate_redemption_code(db: Session, length: int = 12) -> str: + """Generate unique redemption code""" + max_attempts = 10 + for _ in range(max_attempts): + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=length)) + existing = db.query(RewardRedemption).filter(RewardRedemption.code == code).first() + if not existing: + return code + return f"RED{int(datetime.utcnow().timestamp())}" + + @staticmethod + def process_referral( + db: Session, + referred_user_id: int, + referral_code: str, + booking_id: Optional[int] = None + ): + """Process referral when new user books with referral code""" + try: + # Find referrer + referrer_loyalty = db.query(UserLoyalty).filter( + UserLoyalty.referral_code == referral_code + ).first() + + if not referrer_loyalty: + logger.warning(f"Invalid referral code: {referral_code}") + return + + if referrer_loyalty.user_id == referred_user_id: + logger.warning(f"User cannot refer themselves") + return + + # Check if referral already exists + existing_referral = db.query(Referral).filter( + Referral.referrer_id == referrer_loyalty.id, + Referral.referred_user_id == referred_user_id + ).first() + + if existing_referral and existing_referral.status == ReferralStatus.rewarded: + logger.info(f"Referral already processed for user {referred_user_id}") + return + + # Create or update referral + if not existing_referral: + referral = Referral( + referrer_id=referrer_loyalty.id, + referred_user_id=referred_user_id, + referral_code=referral_code, + booking_id=booking_id, + status=ReferralStatus.pending + ) + db.add(referral) + else: + referral = existing_referral + referral.booking_id = booking_id + + # Award points when booking is completed + if booking_id: + booking = db.query(Booking).filter(Booking.id == booking_id).first() + if booking and booking.status == BookingStatus.confirmed: + # Award referrer points + expires_at = datetime.utcnow() + timedelta(days=LoyaltyService.POINTS_EXPIRATION_DAYS) + + referrer_transaction = LoyaltyPointTransaction( + user_loyalty_id=referrer_loyalty.id, + transaction_type=TransactionType.earned, + source=TransactionSource.referral, + points=LoyaltyService.REFERRER_POINTS, + description=f"Referral bonus for user {referred_user_id}", + expires_at=expires_at, + reference_number=referral_code + ) + db.add(referrer_transaction) + + referrer_loyalty.total_points += LoyaltyService.REFERRER_POINTS + referrer_loyalty.lifetime_points += LoyaltyService.REFERRER_POINTS + referrer_loyalty.available_points += LoyaltyService.REFERRER_POINTS + referrer_loyalty.referral_count += 1 + referral.referrer_points_earned = LoyaltyService.REFERRER_POINTS + + # Award referred user bonus points + referred_loyalty = LoyaltyService.get_or_create_user_loyalty(db, referred_user_id) + + referred_transaction = LoyaltyPointTransaction( + user_loyalty_id=referred_loyalty.id, + transaction_type=TransactionType.bonus, + source=TransactionSource.referral, + points=LoyaltyService.REFERRED_BONUS_POINTS, + description=f"Welcome bonus for using referral code", + expires_at=expires_at, + reference_number=referral_code + ) + db.add(referred_transaction) + + referred_loyalty.total_points += LoyaltyService.REFERRED_BONUS_POINTS + referred_loyalty.lifetime_points += LoyaltyService.REFERRED_BONUS_POINTS + referred_loyalty.available_points += LoyaltyService.REFERRED_BONUS_POINTS + referral.referred_points_earned = LoyaltyService.REFERRED_BONUS_POINTS + + referral.status = ReferralStatus.rewarded + referral.completed_at = datetime.utcnow() + referral.rewarded_at = datetime.utcnow() + else: + referral.status = ReferralStatus.completed + referral.completed_at = datetime.utcnow() + + db.commit() + logger.info(f"Referral processed: referrer {referrer_loyalty.user_id} referred user {referred_user_id}") + + except Exception as e: + logger.error(f"Error processing referral: {str(e)}") + db.rollback() + + @staticmethod + def check_birthday_reward(db: Session, user_id: int, prevent_recent_update: bool = False): + """Check and award birthday bonus points + + Args: + db: Database session + user_id: User ID to check + prevent_recent_update: If True, prevents reward if birthday was updated recently (within 365 days) + """ + try: + user = db.query(User).filter(User.id == user_id).first() + if not user: + return + + user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, user_id) + + if not user_loyalty.birthday: + return + + today = date.today() + birthday = user_loyalty.birthday + + # Prevent abuse: Don't award if birthday was updated within last 365 days (once per year only) + if prevent_recent_update and user_loyalty.updated_at: + days_since_update = (datetime.utcnow() - user_loyalty.updated_at).days + if days_since_update < 365: + logger.warning(f"Birthday reward blocked for user {user_id}: birthday updated {days_since_update} days ago (minimum 365 days required - once per year only)") + return + + # Check if it's user's birthday + if today.month == birthday.month and today.day == birthday.day: + # Check if already awarded THIS YEAR (fix: check from Jan 1 of current year to now) + year_start = datetime(today.year, 1, 1) + now = datetime.utcnow() + + existing_birthday = db.query(LoyaltyPointTransaction).filter( + LoyaltyPointTransaction.user_loyalty_id == user_loyalty.id, + LoyaltyPointTransaction.source == TransactionSource.birthday, + LoyaltyPointTransaction.created_at >= year_start, + LoyaltyPointTransaction.created_at <= now + ).first() + + if not existing_birthday: + # Award birthday bonus + expires_at = datetime.utcnow() + timedelta(days=LoyaltyService.POINTS_EXPIRATION_DAYS) + + transaction = LoyaltyPointTransaction( + user_loyalty_id=user_loyalty.id, + transaction_type=TransactionType.bonus, + source=TransactionSource.birthday, + points=LoyaltyService.BIRTHDAY_BONUS_POINTS, + description=f"Birthday bonus - Happy Birthday!", + expires_at=expires_at + ) + db.add(transaction) + + user_loyalty.total_points += LoyaltyService.BIRTHDAY_BONUS_POINTS + user_loyalty.lifetime_points += LoyaltyService.BIRTHDAY_BONUS_POINTS + user_loyalty.available_points += LoyaltyService.BIRTHDAY_BONUS_POINTS + + db.commit() + logger.info(f"Birthday bonus awarded to user {user_id}") + else: + logger.info(f"Birthday reward already awarded this year for user {user_id}") + + except Exception as e: + logger.error(f"Error checking birthday reward: {str(e)}") + db.rollback() + + @staticmethod + def check_anniversary_reward(db: Session, user_id: int, prevent_recent_update: bool = False): + """Check and award anniversary bonus points + + Args: + db: Database session + user_id: User ID to check + prevent_recent_update: If True, prevents reward if anniversary was updated recently (within 365 days) + """ + try: + user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, user_id) + + if not user_loyalty.anniversary_date: + return + + today = date.today() + anniversary = user_loyalty.anniversary_date + + # Prevent abuse: Don't award if anniversary was updated within last 365 days (once per year only) + if prevent_recent_update and user_loyalty.updated_at: + days_since_update = (datetime.utcnow() - user_loyalty.updated_at).days + if days_since_update < 365: + logger.warning(f"Anniversary reward blocked for user {user_id}: anniversary updated {days_since_update} days ago (minimum 365 days required - once per year only)") + return + + # Check if it's anniversary + if today.month == anniversary.month and today.day == anniversary.day: + # Check if already awarded THIS YEAR (fix: check from Jan 1 of current year to now) + year_start = datetime(today.year, 1, 1) + now = datetime.utcnow() + + existing_anniversary = db.query(LoyaltyPointTransaction).filter( + LoyaltyPointTransaction.user_loyalty_id == user_loyalty.id, + LoyaltyPointTransaction.source == TransactionSource.anniversary, + LoyaltyPointTransaction.created_at >= year_start, + LoyaltyPointTransaction.created_at <= now + ).first() + + if not existing_anniversary: + # Award anniversary bonus + expires_at = datetime.utcnow() + timedelta(days=LoyaltyService.POINTS_EXPIRATION_DAYS) + + transaction = LoyaltyPointTransaction( + user_loyalty_id=user_loyalty.id, + transaction_type=TransactionType.bonus, + source=TransactionSource.anniversary, + points=LoyaltyService.ANNIVERSARY_BONUS_POINTS, + description=f"Anniversary bonus - Thank you for your loyalty!", + expires_at=expires_at + ) + db.add(transaction) + + user_loyalty.total_points += LoyaltyService.ANNIVERSARY_BONUS_POINTS + user_loyalty.lifetime_points += LoyaltyService.ANNIVERSARY_BONUS_POINTS + user_loyalty.available_points += LoyaltyService.ANNIVERSARY_BONUS_POINTS + + db.commit() + logger.info(f"Anniversary bonus awarded to user {user_id}") + else: + logger.info(f"Anniversary reward already awarded this year for user {user_id}") + + except Exception as e: + logger.error(f"Error checking anniversary reward: {str(e)}") + db.rollback() + + @staticmethod + def expire_points(db: Session): + """Expire points that have passed expiration date""" + try: + now = datetime.utcnow() + + # Find all expired point transactions + expired_transactions = db.query(LoyaltyPointTransaction).filter( + LoyaltyPointTransaction.transaction_type == TransactionType.earned, + LoyaltyPointTransaction.points > 0, + LoyaltyPointTransaction.expires_at.isnot(None), + LoyaltyPointTransaction.expires_at < now + ).all() + + for transaction in expired_transactions: + # Check if already expired + if transaction.description and 'expired' in transaction.description.lower(): + continue + + user_loyalty = transaction.user_loyalty + + # Create expiration transaction + expiration_transaction = LoyaltyPointTransaction( + user_loyalty_id=user_loyalty.id, + transaction_type=TransactionType.expired, + source=transaction.source, + points=-transaction.points, + description=f"Points expired from {transaction.description or 'earned points'}", + reference_number=transaction.reference_number + ) + db.add(expiration_transaction) + + # Update user loyalty + user_loyalty.total_points -= transaction.points + user_loyalty.available_points -= transaction.points + user_loyalty.expired_points += transaction.points + + # Mark transaction as expired + transaction.description = f"{transaction.description or ''} [EXPIRED]" + + db.commit() + logger.info(f"Expired {len(expired_transactions)} point transactions") + + except Exception as e: + logger.error(f"Error expiring points: {str(e)}") + db.rollback() + diff --git a/ENTERPRISE_FEATURES_ROADMAP.md b/ENTERPRISE_FEATURES_ROADMAP.md new file mode 100644 index 00000000..d87cdac0 --- /dev/null +++ b/ENTERPRISE_FEATURES_ROADMAP.md @@ -0,0 +1,698 @@ +# Enterprise Hotel Booking Platform - Feature Roadmap + +## Overview +This document outlines enterprise-level features and enhancements to transform your **single hotel booking platform** into a comprehensive, scalable enterprise solution for one hotel property. + +## Current Feature Assessment + +### āœ… Already Implemented +- Basic booking system with check-in/check-out +- Payment processing (Stripe, PayPal, Cash) +- Multi-role user management (Admin, Staff, Accountant, Customer) +- Room management and inventory +- Reviews and ratings +- Promotions and discounts +- Services/Add-ons booking +- Basic reporting and analytics +- Email notifications +- Invoice generation (proforma & regular) +- Chat system +- Audit logging +- MFA support +- Favorites functionality +- Currency support (basic) + +--- + +## šŸš€ Enterprise Features to Add + +### 1. **Channel Manager Integration** ⭐ HIGH PRIORITY + +#### Backend: +- Channel Manager API service +- Rate synchronization service +- Inventory sync across channels (availability sync) +- Booking import from external channels +- Two-way sync with booking platforms + +#### Integration Support: +- Booking.com API +- Expedia API +- Airbnb API +- Agoda API +- Custom channel support +- Sync with your direct booking system + +#### Features: +- Real-time availability synchronization +- Automatic room blocking when booked externally +- Rate parity management (ensuring consistent pricing) +- Booking distribution tracking across channels +- Channel performance analytics (which channels bring most bookings) +- Automatic inventory updates when rooms are booked/cancelled + +--- + +### 2. **Advanced Revenue Management & Dynamic Pricing** ⭐ HIGH PRIORITY + +#### Backend: +- Dynamic pricing engine +- Rate rules and strategies +- Seasonal pricing management +- Competitor rate monitoring +- Revenue optimization algorithms + +#### Frontend: +- Pricing dashboard +- Rate strategy builder +- Forecast and optimization tools +- Competitor analysis dashboard + +#### Features: +- Demand-based pricing +- Length-of-stay pricing +- Early bird/last minute discounts +- Room type pricing optimization +- Group pricing rules + +--- + + + +--- + +### 4. **Advanced Guest Profile & CRM** ⭐ HIGH PRIORITY + +#### Backend: +- Enhanced guest profile model +- Guest preferences tracking +- Guest history analytics +- Communication history +- Guest segmentation + +#### Features: +- Complete booking history +- Preference profiles (room location, amenities, special requests) +- Guest notes and tags +- VIP status management +- Guest lifetime value calculation +- Personalized marketing automation +- Guest satisfaction scoring + +#### Frontend: +- Comprehensive guest profile page +- Guest search and filtering +- Guest communication log +- Preference management UI + +--- + +### 5. **Workflow Automation & Task Management** + +#### Backend: +- Workflow engine +- Task assignment system +- Automated task creation +- Task completion tracking + +#### Features: +- Pre-arrival checklists +- Room preparation workflows +- Maintenance request workflows +- Guest communication automation +- Follow-up task automation +- Staff task assignment and tracking +- SLA management + +#### Frontend: +- Task management dashboard +- Workflow builder +- Staff assignment interface +- Task completion tracking + +--- + +### 6. **Advanced Analytics & Business Intelligence** ⭐ HIGH PRIORITY + +#### Backend: +- Data warehouse/modeling +- Advanced query optimization +- Real-time analytics engine +- Predictive analytics + +#### Analytics Categories: +- Revenue Analytics + - RevPAR (Revenue Per Available Room) + - ADR (Average Daily Rate) + - Occupancy rates + - Revenue forecasting + - Market penetration analysis +- Operational Analytics + - Staff performance metrics + - Service usage analytics + - Maintenance cost tracking + - Operational efficiency metrics +- Guest Analytics + - Guest lifetime value + - Customer acquisition cost + - Repeat guest rate + - Guest satisfaction trends +- Financial Analytics + - Profit & Loss reports + - Cost analysis + - Payment method analytics + - Refund analysis + +#### Frontend: +- Interactive dashboards with charts +- Custom report builder +- Data export (CSV, Excel, PDF) +- Scheduled report delivery +- Real-time KPI widgets + +--- + +### 7. **Multi-Language & Internationalization (i18n)** ⭐ HIGH PRIORITY + +#### Backend: +- Translation management system +- Language-specific content storage +- API locale support + +#### Frontend: +- Full i18n implementation (react-i18next) +- Language switcher +- RTL language support +- Currency localization + +#### Supported Languages: +- English, Spanish, French, German, Japanese, Chinese, Arabic, etc. + +--- + +### 8. **Advanced Notification System** + +#### Backend: +- Multi-channel notification service +- Notification preferences +- Notification templates +- Delivery tracking + +#### Channels: +- Email (enhanced templates) +- SMS (Twilio, AWS SNS) +- Push notifications (web & mobile) +- WhatsApp Business API +- In-app notifications + +#### Notification Types: +- Booking confirmations +- Payment receipts +- Pre-arrival reminders +- Check-in/out reminders +- Marketing campaigns +- Loyalty updates +- System alerts + +--- + +### 9. **Mobile Applications** ⭐ HIGH PRIORITY + +#### Native Apps: +- iOS app (Swift/SwiftUI) +- Android app (Kotlin/React Native) +- Guest app features: + - Mobile booking + - Check-in/out + - Digital key (if supported) + - Service requests + - Chat support + - Mobile payments + - Loyalty tracking + +#### Staff App: +- Mobile check-in/out +- Task management +- Guest requests +- Room status updates +- Quick booking creation + +--- + +### 10. **Group Booking Management** + +#### Backend: +- Group booking model +- Group rate management +- Room blocking +- Group payment tracking + +#### Features: +- Block multiple rooms +- Group discounts +- Group coordinator management +- Individual guest information +- Group payment options +- Group cancellation policies + +--- + +### 11. **Advanced Room Management** + +#### Backend Enhancements: +- Room assignment optimization +- Room maintenance scheduling +- Room attribute tracking +- Housekeeping status + +#### Features: +- Visual room status board +- Maintenance scheduling +- Housekeeping workflow +- Room upgrade management +- Room blocking for maintenance +- Room inspection checklists + +--- + +### 12. **Rate Plans & Packages** + +#### Backend: +- Rate plan model +- Package builder +- Plan-specific rules + +#### Features: +- Multiple rate plans (BAR, Non-refundable, Advance Purchase) +- Package deals (Room + Breakfast, Room + Activities) +- Corporate rates +- Government/military rates +- Long-stay discounts +- Plan comparison tools + +--- + +### 13. **Payment Gateway Enhancements** + +#### Additional Integrations: +- Square +- Adyen +- Razorpay (for India) +- PayU +- Alipay/WeChat Pay (for China) +- Bank transfers with tracking +- Buy now, pay later options + +#### Features: +- Multiple payment methods per booking +- Payment plan options +- Refund automation +- Payment reconciliation +- Split payment support +- Currency conversion at payment time + +--- + +### 14. **API & Third-Party Integrations** + +#### Public API: +- RESTful API documentation (OpenAPI/Swagger) +- API versioning +- API key management +- Rate limiting per client +- Webhook support + +#### Integration Partners: +- PMS systems (Opera, Mews, Cloudbeds) +- CRM systems (Salesforce, HubSpot) +- Accounting software (QuickBooks, Xero) +- Marketing automation (Mailchimp, SendGrid) +- Analytics platforms (Google Analytics, Mixpanel) + +--- + +### 15. **Document Management & E-Signatures** + +#### Features: +- Contract generation +- E-signature integration (DocuSign, HelloSign) +- Document storage +- Terms and conditions acceptance tracking +- Guest consent management + +--- + +### 16. **Advanced Security Features** + +#### Backend: +- OAuth 2.0 / OpenID Connect +- API authentication (JWT, OAuth) +- IP whitelisting +- Advanced audit logging +- Data encryption at rest +- PCI DSS compliance tools + +#### Features: +- SSO (Single Sign-On) +- Role-based access control enhancements +- Security event monitoring +- Automated security scans +- Data backup and recovery +- GDPR compliance tools + +--- + +### 17. **Email Marketing & Campaigns** + +#### Features: +- Email campaign builder +- Segmentation tools +- A/B testing +- Email analytics +- Drip campaigns +- Newsletter management +- Abandoned booking recovery emails + +--- + +### 18. **Review Management System** + +#### Enhancements: +- Multi-platform review aggregation +- Automated review requests +- Review response management +- Review analytics +- Review moderation tools +- Review-based improvements tracking + +--- + +### 19. **Event & Meeting Room Management** + +#### Features: +- Event space booking +- Event packages +- Equipment booking +- Catering management +- Event timeline management +- Invoice generation for events + +--- + +### 20. **Inventory Management for Services** + +#### Features: +- Service inventory tracking +- Service availability calendar +- Service capacity management +- Service booking conflicts prevention + +--- + +### 21. **Advanced Search & Filters** + +#### Features: +- Elasticsearch integration +- Faceted search +- Fuzzy search +- Price range filtering +- Amenity-based filtering +- Map-based search +- Saved searches +- Search analytics + +--- + +### 22. **Guest Communication Hub** + +#### Features: +- Unified inbox for all communications +- Email integration +- SMS integration +- WhatsApp integration +- Message templates +- Auto-responders +- Communication history +- Staff assignment to conversations + +--- + +### 23. **Reporting & Export Enhancements** + +#### Features: +- Custom report builder +- Scheduled reports +- Report templates library +- Export to multiple formats (PDF, Excel, CSV) +- Data visualization tools +- Comparative reporting +- Automated report delivery + +--- + +### 24. **Check-in/Check-out Enhancements** + +#### Features: +- Self-service kiosk integration +- Mobile check-in +- Digital key distribution +- Early check-in/late checkout management +- Express checkout +- Automated room ready notifications + +--- + +### 25. **Accounting & Financial Management** + +#### Features: +- General ledger integration +- Accounts receivable/payable +- Financial reconciliation +- Tax management (multi-jurisdiction) +- Multi-currency accounting +- Financial reporting suite +- Budget planning and tracking + +--- + +### 26. **Inventory Forecasting & Demand Planning** + +#### Features: +- Demand forecasting models +- Seasonal demand analysis +- Market trend analysis +- Capacity planning +- Revenue impact forecasting + +--- + +### 27. **Guest Experience Enhancements** + +#### Features: +- Virtual room tours (360°) +- AR room visualization +- Pre-arrival preferences form +- Concierge service booking +- Local recommendations integration +- Weather updates +- Transportation booking integration + +--- + +### 28. **Staff Management & Scheduling** + +#### Features: +- Staff scheduling system +- Shift management +- Time tracking +- Performance metrics +- Staff communication +- Task assignment +- Department management + +--- + +### 29. **Compliance & Legal** + +#### Features: +- Terms and conditions versioning +- Privacy policy management +- Data retention policies +- Consent management +- Regulatory compliance tracking +- Legal document generation + +--- + +## Technical Infrastructure Enhancements + +### 1. **Performance & Scalability** +- Redis caching layer +- CDN integration for static assets +- Database read replicas +- Load balancing +- Microservices architecture (optional) +- Message queue (RabbitMQ, Kafka) + +### 2. **Monitoring & Observability** +- Application Performance Monitoring (APM) +- Log aggregation (ELK Stack, Splunk) +- Real-time alerting +- Health check endpoints +- Performance metrics dashboard +- Error tracking (Sentry) + +### 3. **Testing & Quality** +- Comprehensive test suite (unit, integration, E2E) +- Automated testing pipeline +- Performance testing +- Security testing +- Load testing +- A/B testing framework + +### 4. **DevOps & Deployment** +- CI/CD pipeline +- Docker containerization +- Kubernetes orchestration +- Infrastructure as Code (Terraform) +- Automated backups +- Disaster recovery plan + +--- + +## Priority Implementation Roadmap + +### Phase 1: Foundation & Distribution (Months 1-3) +1. Channel Manager Integration ⭐ +2. Advanced Analytics Dashboard ⭐ +3. Multi-Language Support ⭐ +4. Advanced Notification System + +### Phase 2: Revenue & Guest Management (Months 4-6) +5. Dynamic Pricing Engine ⭐ +6. Loyalty Program ⭐ +7. Advanced Guest CRM ⭐ +8. Workflow Automation & Task Management + +### Phase 3: Experience & Integration (Months 7-9) +9. Mobile Applications ⭐ +10. Public API & Webhooks +11. Payment Gateway Enhancements +12. Advanced Room Management + +### Phase 4: Advanced Features (Months 10-12) +13. Group Booking Management +14. Email Marketing Platform +15. Advanced Reporting Suite +16. Performance & Scalability Improvements + +--- + +## Recommended Technology Stack Additions + +### Backend: +- **Redis** - Caching and session management +- **Celery** - Background task processing +- **Elasticsearch** - Advanced search +- **PostgreSQL** - Advanced features (already using SQLAlchemy) +- **Apache Kafka** - Event streaming +- **Prometheus** - Metrics collection +- **Grafana** - Monitoring dashboards + +### Frontend: +- **react-i18next** - Internationalization +- **Chart.js / Recharts** - Advanced charts +- **React Query / SWR** - Advanced data fetching +- **PWA** - Progressive Web App capabilities +- **WebSockets** - Real-time updates + +### Mobile: +- **React Native** - Cross-platform mobile app +- **Expo** - Development framework + +--- + +## Success Metrics to Track + +1. **Business Metrics:** + - Revenue per available room (RevPAR) + - Average daily rate (ADR) + - Occupancy rate + - Guest lifetime value + - Repeat booking rate + +2. **Technical Metrics:** + - API response times + - System uptime + - Error rates + - Page load times + - Mobile app crash rates + +3. **User Experience Metrics:** + - Booking conversion rate + - User satisfaction scores + - Task completion rates + - Support ticket volume + +--- + +## Estimated Development Effort + +- **Small features**: 1-2 weeks +- **Medium features**: 2-4 weeks +- **Large features**: 1-3 months +- **Platform-level changes**: 3-6 months + +--- + +## Notes + +- Prioritize based on business needs and customer feedback +- Consider phased rollouts for major features +- Ensure backward compatibility when adding features +- Maintain comprehensive documentation +- Regular security audits and updates + +--- + +--- + +## Single Hotel Platform Focus + +This roadmap is specifically tailored for a **single hotel property**. Features have been optimized for: +- Direct hotel booking operations +- Channel distribution management +- Guest relationship management +- Revenue optimization for one property +- Operational efficiency improvements +- Enhanced guest experience + +Multi-property features have been removed from this roadmap. If you need to expand to multiple properties in the future, multi-tenant architecture can be added as an enhancement. + +--- + +**Last Updated**: 2024 +**Version**: 2.0 (Single Hotel Focus) + diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index b566429b..2c36e2f4 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -51,6 +51,7 @@ const PayPalReturnPage = lazy(() => import('./pages/customer/PayPalReturnPage')) const PayPalCancelPage = lazy(() => import('./pages/customer/PayPalCancelPage')); const InvoicePage = lazy(() => import('./pages/customer/InvoicePage')); const ProfilePage = lazy(() => import('./pages/customer/ProfilePage')); +const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage')); const AboutPage = lazy(() => import('./pages/AboutPage')); const ContactPage = lazy(() => import('./pages/ContactPage')); const PrivacyPolicyPage = lazy(() => import('./pages/PrivacyPolicyPage')); @@ -64,12 +65,14 @@ const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage')); const InvoiceManagementPage = lazy(() => import('./pages/admin/InvoiceManagementPage')); const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage')); const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage')); +const GuestProfilePage = lazy(() => import('./pages/admin/GuestProfilePage')); const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage')); const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard')); const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage')); const BusinessDashboardPage = lazy(() => import('./pages/admin/BusinessDashboardPage')); const SettingsPage = lazy(() => import('./pages/admin/SettingsPage')); const ReceptionDashboardPage = lazy(() => import('./pages/admin/ReceptionDashboardPage')); +const LoyaltyManagementPage = lazy(() => import('./pages/admin/LoyaltyManagementPage')); const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage')); const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage')); @@ -280,6 +283,14 @@ function App() { } /> + + + + } + /> {} @@ -338,6 +349,14 @@ function App() { path="payments" element={} /> + } + /> + } + /> {} @@ -370,10 +389,18 @@ function App() { path="reports" element={} /> - } /> + } + /> + } + /> {/* Accountant Routes */} diff --git a/Frontend/src/components/booking/LuxuryBookingModal.tsx b/Frontend/src/components/booking/LuxuryBookingModal.tsx index 6fcc0051..341ad79e 100644 --- a/Frontend/src/components/booking/LuxuryBookingModal.tsx +++ b/Frontend/src/components/booking/LuxuryBookingModal.tsx @@ -71,6 +71,8 @@ const LuxuryBookingModal: React.FC = ({ const [promotionDiscount, setPromotionDiscount] = useState(0); const [validatingPromotion, setValidatingPromotion] = useState(false); const [promotionError, setPromotionError] = useState(null); + const [referralCode, setReferralCode] = useState(''); + const [referralError, setReferralError] = useState(null); const [showInvoiceModal, setShowInvoiceModal] = useState(false); const [showPaymentModal, setShowPaymentModal] = useState(false); const [paymentMethod, setPaymentMethod] = useState<'stripe' | 'paypal' | 'cash' | null>(null); @@ -123,6 +125,8 @@ const LuxuryBookingModal: React.FC = ({ setPromotionCode(''); setSelectedPromotion(null); setPromotionDiscount(0); + setReferralCode(''); + setReferralError(null); setShowPaymentModal(false); setPaymentMethod(null); setCreatedBookingId(null); @@ -376,6 +380,7 @@ const LuxuryBookingModal: React.FC = ({ quantity: item.quantity, })), promotion_code: selectedPromotion?.code || undefined, + referral_code: referralCode.trim() || undefined, invoice_info: (data as any).invoiceInfo ? { company_name: (data as any).invoiceInfo.company_name || undefined, company_address: (data as any).invoiceInfo.company_address || undefined, @@ -823,6 +828,32 @@ const LuxuryBookingModal: React.FC = ({ )} {promotionError &&

{promotionError}

} + + {/* Referral Code */} +
+
+ +

Referral Code (Optional)

+
+
+ { + setReferralCode(e.target.value.toUpperCase().trim()); + setReferralError(null); + }} + placeholder="Enter referral code from a friend" + className="w-full px-3 py-2 bg-[#0a0a0a] border border-[#d4af37]/20 rounded-lg text-white text-sm focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37]" + /> + {referralCode && ( +

+ You'll both earn bonus points when your booking is confirmed! +

+ )} +
+ {referralError &&

{referralError}

} +
)} diff --git a/Frontend/src/components/layout/Header.tsx b/Frontend/src/components/layout/Header.tsx index 3c9e411f..9bfbb189 100644 --- a/Frontend/src/components/layout/Header.tsx +++ b/Frontend/src/components/layout/Header.tsx @@ -12,6 +12,7 @@ import { Phone, Mail, Calendar, + Star, } from 'lucide-react'; import { useClickOutside } from '../../hooks/useClickOutside'; import { useCompanySettings } from '../../contexts/CompanySettingsContext'; @@ -280,6 +281,18 @@ const Header: React.FC = ({ My Bookings + setIsUserMenuOpen(false)} + className="flex items-center space-x-3 + px-4 py-2.5 text-white/90 + hover:bg-[#d4af37]/10 hover:text-[#d4af37] + transition-all duration-300 border-l-2 border-transparent + hover:border-[#d4af37]" + > + + Loyalty Program + )} {userInfo?.role === 'admin' && ( diff --git a/Frontend/src/components/layout/SidebarAdmin.tsx b/Frontend/src/components/layout/SidebarAdmin.tsx index 7f6d5219..ebdc9bf7 100644 --- a/Frontend/src/components/layout/SidebarAdmin.tsx +++ b/Frontend/src/components/layout/SidebarAdmin.tsx @@ -12,7 +12,9 @@ import { LogIn, LogOut, Menu, - X + X, + Award, + User } from 'lucide-react'; import useAuthStore from '../../store/useAuthStore'; @@ -92,6 +94,16 @@ const SidebarAdmin: React.FC = ({ icon: Users, label: 'Users' }, + { + path: '/admin/guest-profiles', + icon: User, + label: 'Guest Profiles' + }, + { + path: '/admin/loyalty', + icon: Award, + label: 'Loyalty Program' + }, { path: '/admin/business', icon: FileText, @@ -123,7 +135,7 @@ const SidebarAdmin: React.FC = ({ if (location.pathname === path) return true; - if (path === '/admin/settings' || path === '/admin/analytics' || path === '/admin/business' || path === '/admin/reception' || path === '/admin/page-content') { + if (path === '/admin/settings' || path === '/admin/analytics' || path === '/admin/business' || path === '/admin/reception' || path === '/admin/page-content' || path === '/admin/loyalty') { return location.pathname === path; } diff --git a/Frontend/src/components/layout/SidebarStaff.tsx b/Frontend/src/components/layout/SidebarStaff.tsx index b20caca2..5e6de804 100644 --- a/Frontend/src/components/layout/SidebarStaff.tsx +++ b/Frontend/src/components/layout/SidebarStaff.tsx @@ -11,7 +11,9 @@ import { Menu, X, CreditCard, - MessageCircle + MessageCircle, + Award, + Users } from 'lucide-react'; import useAuthStore from '../../store/useAuthStore'; import { useChatNotifications } from '../../contexts/ChatNotificationContext'; @@ -104,6 +106,16 @@ const SidebarStaff: React.FC = ({ icon: CreditCard, label: 'Payments' }, + { + path: '/staff/loyalty', + icon: Award, + label: 'Loyalty Program' + }, + { + path: '/staff/guest-profiles', + icon: Users, + label: 'Guest Profiles' + }, { path: '/staff/chats', icon: MessageCircle, diff --git a/Frontend/src/pages/admin/GuestProfilePage.tsx b/Frontend/src/pages/admin/GuestProfilePage.tsx new file mode 100644 index 00000000..4c5af304 --- /dev/null +++ b/Frontend/src/pages/admin/GuestProfilePage.tsx @@ -0,0 +1,1319 @@ +import React, { useState, useEffect } from 'react'; +import { + Search, + User, + Star, + MessageSquare, + Calendar, + DollarSign, + Filter, + X, + Plus, + Edit, + Eye, + Award, + Mail, + Phone, + MapPin, +} from 'lucide-react'; +import { guestProfileService, GuestProfile, GuestListItem, GuestTag, GuestSegment, GuestSearchParams, GuestPreference } from '../../services/api'; +import { toast } from 'react-toastify'; +import Loading from '../../components/common/Loading'; +import Pagination from '../../components/common/Pagination'; +import { useFormatCurrency } from '../../hooks/useFormatCurrency'; + +type TabType = 'list' | 'profile'; + +const GuestProfilePage: React.FC = () => { + const [activeTab, setActiveTab] = useState('list'); + const [selectedGuestId, setSelectedGuestId] = useState(null); + const [guests, setGuests] = useState([]); + const [guestProfile, setGuestProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [profileLoading, setProfileLoading] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [totalItems, setTotalItems] = useState(0); + const [searchTerm, setSearchTerm] = useState(''); + const [filters, setFilters] = useState({ + page: 1, + limit: 10, + }); + const [showFilters, setShowFilters] = useState(false); + const [allTags, setAllTags] = useState([]); + const [allSegments, setAllSegments] = useState([]); + const { formatCurrency } = useFormatCurrency(); + + useEffect(() => { + fetchGuests(); + fetchTags(); + fetchSegments(); + }, [filters, currentPage]); + + useEffect(() => { + if (selectedGuestId) { + fetchGuestProfile(selectedGuestId); + } + }, [selectedGuestId]); + + const fetchGuests = async () => { + try { + setLoading(true); + const params = { ...filters, page: currentPage }; + const response = await guestProfileService.searchGuests(params); + setGuests(response.data.guests); + setTotalPages(response.data.pagination.total_pages); + setTotalItems(response.data.pagination.total); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to load guests'); + } finally { + setLoading(false); + } + }; + + const fetchGuestProfile = async (userId: number) => { + try { + setProfileLoading(true); + const response = await guestProfileService.getGuestProfile(userId); + setGuestProfile(response.data.profile); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to load guest profile'); + } finally { + setProfileLoading(false); + } + }; + + const fetchTags = async () => { + try { + const response = await guestProfileService.getAllTags(); + setAllTags(response.data.tags); + } catch (error: any) { + console.error('Failed to load tags:', error); + } + }; + + const fetchSegments = async () => { + try { + const response = await guestProfileService.getAllSegments(); + setAllSegments(response.data.segments); + } catch (error: any) { + console.error('Failed to load segments:', error); + } + }; + + const handleSearch = () => { + setFilters({ ...filters, search: searchTerm || undefined, page: 1 }); + setCurrentPage(1); + }; + + const handleFilterChange = (key: keyof GuestSearchParams, value: any) => { + setFilters({ ...filters, [key]: value, page: 1 }); + setCurrentPage(1); + }; + + const handleViewProfile = (userId: number) => { + setSelectedGuestId(userId); + setActiveTab('profile'); + }; + + const handleToggleVip = async (userId: number, currentStatus: boolean) => { + try { + await guestProfileService.toggleVipStatus(userId, !currentStatus); + toast.success('VIP status updated'); + if (selectedGuestId === userId) { + fetchGuestProfile(userId); + } + fetchGuests(); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to update VIP status'); + } + }; + + const handleUpdateMetrics = async (userId: number) => { + try { + await guestProfileService.updateMetrics(userId); + toast.success('Guest metrics updated'); + fetchGuestProfile(userId); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to update metrics'); + } + }; + + if (loading && activeTab === 'list') { + return ; + } + + return ( +
+
+
+

Guest Profiles & CRM

+

Manage guest profiles, preferences, and communications

+
+
+ +
+
+ + {activeTab === 'list' && ( +
+ {/* Search and Filters */} +
+
+
+ +
+
+ + setSearchTerm(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSearch()} + placeholder="Search by name, email, or phone..." + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ +
+
+ +
+ + {showFilters && ( +
+
+ + +
+
+ + +
+
+ + handleFilterChange('min_lifetime_value', e.target.value ? parseFloat(e.target.value) : undefined)} + placeholder="0" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+ + handleFilterChange('min_satisfaction_score', e.target.value ? parseFloat(e.target.value) : undefined)} + placeholder="0" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+ )} +
+ + {/* Guests List */} +
+
+ + + + + + + + + + + + + {guests.map((guest) => ( + + + + + + + + + ))} + +
GuestLifetime ValueVisitsSatisfactionTagsActions
+
+
+ +
+
+
+
{guest.full_name}
+ {guest.is_vip && ( +
+ +
+ )} +
+
{guest.email}
+ {guest.phone &&
{guest.phone}
} +
+
+
+
+ {formatCurrency(guest.lifetime_value)} +
+
+
{guest.total_visits}
+ {guest.last_visit_date && ( +
+ {new Date(guest.last_visit_date).toLocaleDateString()} +
+ )} +
+ {guest.satisfaction_score ? ( +
+ + {guest.satisfaction_score.toFixed(1)} +
+ ) : ( + N/A + )} +
+
+ {guest.tags.slice(0, 3).map((tag) => ( + + {tag.name} + + ))} + {guest.tags.length > 3 && ( + + +{guest.tags.length - 3} + + )} +
+
+
+ + +
+
+
+ {guests.length === 0 && !loading && ( +
No guests found
+ )} +
+ +
+
+
+ )} + + {activeTab === 'profile' && selectedGuestId && ( + { + setActiveTab('list'); + setSelectedGuestId(null); + }} + onRefresh={() => fetchGuestProfile(selectedGuestId)} + onToggleVip={handleToggleVip} + onUpdateMetrics={handleUpdateMetrics} + allTags={allTags} + allSegments={allSegments} + /> + )} +
+ ); +}; + +interface GuestProfileDetailProps { + guestProfile: GuestProfile | null; + loading: boolean; + onBack: () => void; + onRefresh: () => void; + onToggleVip: (userId: number, currentStatus: boolean) => void; + onUpdateMetrics: (userId: number) => void; + allTags: GuestTag[]; + allSegments: GuestSegment[]; +} + +const GuestProfileDetail: React.FC = ({ + guestProfile, + loading, + onBack, + onRefresh, + onToggleVip, + onUpdateMetrics, + allTags, + allSegments, +}) => { + const { formatCurrency } = useFormatCurrency(); + const [activeSection, setActiveSection] = useState<'overview' | 'preferences' | 'notes' | 'communications' | 'bookings'>('overview'); + + // Modal states + const [showPreferencesModal, setShowPreferencesModal] = useState(false); + const [showNoteModal, setShowNoteModal] = useState(false); + const [showTagModal, setShowTagModal] = useState(false); + const [showCommunicationModal, setShowCommunicationModal] = useState(false); + const [showSegmentModal, setShowSegmentModal] = useState(false); + const [saving, setSaving] = useState(false); + + // Form states + const [preferencesForm, setPreferencesForm] = useState>({}); + const [noteForm, setNoteForm] = useState({ note: '', is_important: false, is_private: false }); + const [selectedTagId, setSelectedTagId] = useState(null); + const [communicationForm, setCommunicationForm] = useState({ + communication_type: 'email' as 'email' | 'phone' | 'sms' | 'chat' | 'in_person' | 'other', + direction: 'outbound' as 'inbound' | 'outbound', + subject: '', + content: '', + }); + const [selectedSegmentId, setSelectedSegmentId] = useState(null); + + // Handler functions + const handleSavePreferences = async () => { + if (!guestProfile) return; + try { + setSaving(true); + await guestProfileService.updatePreferences(guestProfile.id, preferencesForm); + toast.success('Preferences updated successfully'); + setShowPreferencesModal(false); + onRefresh(); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to update preferences'); + } finally { + setSaving(false); + } + }; + + const handleSaveNote = async () => { + if (!guestProfile || !noteForm.note.trim()) { + toast.error('Please enter a note'); + return; + } + try { + setSaving(true); + await guestProfileService.createNote(guestProfile.id, noteForm); + toast.success('Note added successfully'); + setShowNoteModal(false); + setNoteForm({ note: '', is_important: false, is_private: false }); + onRefresh(); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to add note'); + } finally { + setSaving(false); + } + }; + + const handleAddTag = async () => { + if (!guestProfile || !selectedTagId) { + toast.error('Please select a tag'); + return; + } + try { + setSaving(true); + await guestProfileService.addTag(guestProfile.id, selectedTagId); + toast.success('Tag added successfully'); + setShowTagModal(false); + setSelectedTagId(null); + onRefresh(); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to add tag'); + } finally { + setSaving(false); + } + }; + + const handleRemoveTag = async (tagId: number) => { + if (!guestProfile) return; + try { + await guestProfileService.removeTag(guestProfile.id, tagId); + toast.success('Tag removed successfully'); + onRefresh(); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to remove tag'); + } + }; + + const handleSaveCommunication = async () => { + if (!guestProfile || !communicationForm.content.trim()) { + toast.error('Please enter communication content'); + return; + } + try { + setSaving(true); + await guestProfileService.createCommunication(guestProfile.id, communicationForm); + toast.success('Communication recorded successfully'); + setShowCommunicationModal(false); + setCommunicationForm({ + communication_type: 'email', + direction: 'outbound', + subject: '', + content: '', + }); + onRefresh(); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to record communication'); + } finally { + setSaving(false); + } + }; + + const handleAssignSegment = async () => { + if (!guestProfile || !selectedSegmentId) { + toast.error('Please select a segment'); + return; + } + try { + setSaving(true); + await guestProfileService.assignSegment(guestProfile.id, selectedSegmentId); + toast.success('Segment assigned successfully'); + setShowSegmentModal(false); + setSelectedSegmentId(null); + onRefresh(); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to assign segment'); + } finally { + setSaving(false); + } + }; + + const handleRemoveSegment = async (segmentId: number) => { + if (!guestProfile) return; + try { + await guestProfileService.removeSegment(guestProfile.id, segmentId); + toast.success('Segment removed successfully'); + onRefresh(); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to remove segment'); + } + }; + + const handleOpenPreferencesModal = () => { + if (guestProfile?.preferences) { + setPreferencesForm(guestProfile.preferences); + } else { + setPreferencesForm({}); + } + setShowPreferencesModal(true); + }; + + if (loading) { + return ; + } + + if (!guestProfile) { + return ( +
+

Guest profile not found

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+
+ {guestProfile.avatar ? ( + {guestProfile.full_name} + ) : ( + + )} +
+
+
+

{guestProfile.full_name}

+ {guestProfile.is_vip && ( +
+ +
+ )} +
+
+
+ + {guestProfile.email} +
+ {guestProfile.phone && ( +
+ + {guestProfile.phone} +
+ )} + {guestProfile.address && ( +
+ + {guestProfile.address} +
+ )} +
+
+
+
+ + + +
+
+ + {/* Key Metrics */} +
+
+
+ + Lifetime Value +
+
{formatCurrency(guestProfile.lifetime_value)}
+
+
+
+ + Total Visits +
+
{guestProfile.total_visits}
+
+
+
+ + Satisfaction +
+
+ {guestProfile.satisfaction_score ? guestProfile.satisfaction_score.toFixed(1) : 'N/A'} +
+
+
+
+ + Communications +
+
{guestProfile.communications.length}
+
+
+
+ + {/* Navigation Tabs */} +
+
+ +
+ +
+ {activeSection === 'overview' && ( +
+
+

Analytics

+
+
+
Average Booking Value
+
+ {formatCurrency(guestProfile.analytics.average_booking_value)} +
+
+
+
Preferred Room Type
+
+ {guestProfile.analytics.preferred_room_type || 'N/A'} +
+
+
+
Total Nights Stayed
+
+ {guestProfile.analytics.booking_statistics.total_nights_stayed} +
+
+
+
Completed Bookings
+
+ {guestProfile.analytics.booking_statistics.completed_bookings} +
+
+
+
+ +
+
+

Tags & Segments

+
+ + +
+
+
+
+
Tags
+
+ {guestProfile.tags.length > 0 ? ( + guestProfile.tags.map((tag) => ( + + {tag.name} + + + )) + ) : ( + No tags assigned + )} +
+
+
+
Segments
+
+ {guestProfile.segments.length > 0 ? ( + guestProfile.segments.map((segment) => ( + + {segment.name} + + + )) + ) : ( + No segments assigned + )} +
+
+
+
+
+ )} + + {activeSection === 'preferences' && ( +
+
+ +
+ {guestProfile.preferences ? ( +
+
+ +
{guestProfile.preferences.preferred_room_location || 'N/A'}
+
+
+ +
{guestProfile.preferences.preferred_floor || 'N/A'}
+
+
+ +
{guestProfile.preferences.preferred_contact_method || 'N/A'}
+
+
+ +
{guestProfile.preferences.preferred_language || 'N/A'}
+
+ {guestProfile.preferences.preferred_amenities && ( +
+ +
+ {guestProfile.preferences.preferred_amenities.map((amenity, idx) => ( + + {amenity} + + ))} +
+
+ )} + {guestProfile.preferences.special_requests && ( +
+ +
{guestProfile.preferences.special_requests}
+
+ )} +
+ ) : ( +
No preferences recorded
+ )} +
+ )} + + {activeSection === 'notes' && ( +
+
+ +
+ {guestProfile.notes.length > 0 ? ( + guestProfile.notes.map((note) => ( +
+
+
+ {note.is_important && } + {note.is_private && Private} + + {note.created_by} • {new Date(note.created_at).toLocaleString()} + +
+
+
{note.note}
+
+ )) + ) : ( +
No notes recorded
+ )} +
+ )} + + {activeSection === 'communications' && ( +
+
+ +
+ {guestProfile.communications.length > 0 ? ( + guestProfile.communications.map((comm) => ( +
+
+
+ + {comm.direction} + + {comm.communication_type} + {comm.staff_name && by {comm.staff_name}} +
+ {new Date(comm.created_at).toLocaleString()} +
+ {comm.subject &&
{comm.subject}
} +
{comm.content}
+
+ )) + ) : ( +
No communications recorded
+ )} +
+ )} + + {activeSection === 'bookings' && ( +
+ {guestProfile.recent_bookings.length > 0 ? ( +
+ + + + + + + + + + + + {guestProfile.recent_bookings.map((booking) => ( + + + + + + + + ))} + +
Booking #Check-inCheck-outStatusTotal
{booking.booking_number} + {new Date(booking.check_in_date).toLocaleDateString()} + + {new Date(booking.check_out_date).toLocaleDateString()} + + + {booking.status} + + + {formatCurrency(booking.total_price)} +
+
+ ) : ( +
No bookings found
+ )} +
+ )} +
+
+ + {/* Preferences Modal */} + {showPreferencesModal && ( +
+
+
+

Edit Preferences

+ +
+
+
+
+ + setPreferencesForm({ ...preferencesForm, preferred_room_location: e.target.value })} + placeholder="e.g., high floor, ocean view" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setPreferencesForm({ ...preferencesForm, preferred_floor: parseInt(e.target.value) || undefined })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+ + +
+
+ + setPreferencesForm({ ...preferencesForm, preferred_language: e.target.value })} + placeholder="e.g., en, es, fr" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
+
+ +