From be078020668033ae67960fd1f5c96aca6e19b544 Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Fri, 21 Nov 2025 22:40:44 +0200 Subject: [PATCH] updates --- ...d_guest_profile_crm_tables.cpython-312.pyc | Bin 0 -> 12179 bytes .../add_loyalty_system_tables.cpython-312.pyc | Bin 0 -> 20228 bytes .../versions/add_guest_profile_crm_tables.py | 160 ++ .../versions/add_loyalty_system_tables.py | 215 +++ Backend/seeds_data/seed_loyalty_rewards.py | 303 ++++ Backend/src/__pycache__/main.cpython-312.pyc | Bin 18818 -> 19260 bytes Backend/src/main.py | 6 +- Backend/src/models/__init__.py | 13 +- .../__pycache__/__init__.cpython-312.pyc | Bin 1642 -> 2645 bytes .../guest_communication.cpython-312.pyc | Bin 0 -> 2301 bytes .../__pycache__/guest_note.cpython-312.pyc | Bin 0 -> 1456 bytes .../guest_preference.cpython-312.pyc | Bin 0 -> 1946 bytes .../__pycache__/guest_segment.cpython-312.pyc | Bin 0 -> 1759 bytes .../__pycache__/guest_tag.cpython-312.pyc | Bin 0 -> 1538 bytes .../loyalty_point_transaction.cpython-312.pyc | Bin 0 -> 2372 bytes .../loyalty_reward.cpython-312.pyc | Bin 0 -> 4124 bytes .../__pycache__/loyalty_tier.cpython-312.pyc | Bin 0 -> 2061 bytes .../__pycache__/referral.cpython-312.pyc | Bin 0 -> 2125 bytes .../reward_redemption.cpython-312.pyc | Bin 0 -> 2237 bytes .../models/__pycache__/user.cpython-312.pyc | Bin 2554 -> 3579 bytes .../__pycache__/user_loyalty.cpython-312.pyc | Bin 0 -> 2243 bytes Backend/src/models/guest_communication.py | 36 + Backend/src/models/guest_note.py | 19 + Backend/src/models/guest_preference.py | 40 + Backend/src/models/guest_segment.py | 26 + Backend/src/models/guest_tag.py | 24 + .../src/models/loyalty_point_transaction.py | 40 + Backend/src/models/loyalty_reward.py | 89 ++ Backend/src/models/loyalty_tier.py | 32 + Backend/src/models/referral.py | 30 + Backend/src/models/reward_redemption.py | 33 + Backend/src/models/user.py | 21 +- Backend/src/models/user_loyalty.py | 31 + .../booking_routes.cpython-312.pyc | Bin 93619 -> 97304 bytes .../guest_profile_routes.cpython-312.pyc | Bin 0 -> 28343 bytes .../loyalty_routes.cpython-312.pyc | Bin 0 -> 45509 bytes .../payment_routes.cpython-312.pyc | Bin 50943 -> 52535 bytes Backend/src/routes/booking_routes.py | 67 + Backend/src/routes/guest_profile_routes.py | 564 +++++++ Backend/src/routes/loyalty_routes.py | 981 ++++++++++++ Backend/src/routes/payment_routes.py | 24 + .../schemas/__pycache__/auth.cpython-312.pyc | Bin 6872 -> 6872 bytes .../guest_profile_service.cpython-312.pyc | Bin 0 -> 15484 bytes .../loyalty_service.cpython-312.pyc | Bin 0 -> 31023 bytes Backend/src/services/guest_profile_service.py | 262 ++++ Backend/src/services/loyalty_service.py | 635 ++++++++ ENTERPRISE_FEATURES_ROADMAP.md | 698 +++++++++ Frontend/src/App.tsx | 29 +- .../components/booking/LuxuryBookingModal.tsx | 31 + Frontend/src/components/layout/Header.tsx | 13 + .../src/components/layout/SidebarAdmin.tsx | 16 +- .../src/components/layout/SidebarStaff.tsx | 14 +- Frontend/src/pages/admin/GuestProfilePage.tsx | 1319 +++++++++++++++++ .../src/pages/admin/LoyaltyManagementPage.tsx | 1161 +++++++++++++++ Frontend/src/pages/admin/index.ts | 1 + Frontend/src/pages/customer/LoyaltyPage.tsx | 709 +++++++++ Frontend/src/services/api/apiClient.ts | 17 +- .../src/services/api/guestProfileService.ts | 242 +++ Frontend/src/services/api/index.ts | 4 + Frontend/src/services/api/loyaltyService.ts | 293 ++++ 60 files changed, 8189 insertions(+), 9 deletions(-) create mode 100644 Backend/alembic/versions/__pycache__/add_guest_profile_crm_tables.cpython-312.pyc create mode 100644 Backend/alembic/versions/__pycache__/add_loyalty_system_tables.cpython-312.pyc create mode 100644 Backend/alembic/versions/add_guest_profile_crm_tables.py create mode 100644 Backend/alembic/versions/add_loyalty_system_tables.py create mode 100644 Backend/seeds_data/seed_loyalty_rewards.py create mode 100644 Backend/src/models/__pycache__/guest_communication.cpython-312.pyc create mode 100644 Backend/src/models/__pycache__/guest_note.cpython-312.pyc create mode 100644 Backend/src/models/__pycache__/guest_preference.cpython-312.pyc create mode 100644 Backend/src/models/__pycache__/guest_segment.cpython-312.pyc create mode 100644 Backend/src/models/__pycache__/guest_tag.cpython-312.pyc create mode 100644 Backend/src/models/__pycache__/loyalty_point_transaction.cpython-312.pyc create mode 100644 Backend/src/models/__pycache__/loyalty_reward.cpython-312.pyc create mode 100644 Backend/src/models/__pycache__/loyalty_tier.cpython-312.pyc create mode 100644 Backend/src/models/__pycache__/referral.cpython-312.pyc create mode 100644 Backend/src/models/__pycache__/reward_redemption.cpython-312.pyc create mode 100644 Backend/src/models/__pycache__/user_loyalty.cpython-312.pyc create mode 100644 Backend/src/models/guest_communication.py create mode 100644 Backend/src/models/guest_note.py create mode 100644 Backend/src/models/guest_preference.py create mode 100644 Backend/src/models/guest_segment.py create mode 100644 Backend/src/models/guest_tag.py create mode 100644 Backend/src/models/loyalty_point_transaction.py create mode 100644 Backend/src/models/loyalty_reward.py create mode 100644 Backend/src/models/loyalty_tier.py create mode 100644 Backend/src/models/referral.py create mode 100644 Backend/src/models/reward_redemption.py create mode 100644 Backend/src/models/user_loyalty.py create mode 100644 Backend/src/routes/__pycache__/guest_profile_routes.cpython-312.pyc create mode 100644 Backend/src/routes/__pycache__/loyalty_routes.cpython-312.pyc create mode 100644 Backend/src/routes/guest_profile_routes.py create mode 100644 Backend/src/routes/loyalty_routes.py create mode 100644 Backend/src/services/__pycache__/guest_profile_service.cpython-312.pyc create mode 100644 Backend/src/services/__pycache__/loyalty_service.cpython-312.pyc create mode 100644 Backend/src/services/guest_profile_service.py create mode 100644 Backend/src/services/loyalty_service.py create mode 100644 ENTERPRISE_FEATURES_ROADMAP.md create mode 100644 Frontend/src/pages/admin/GuestProfilePage.tsx create mode 100644 Frontend/src/pages/admin/LoyaltyManagementPage.tsx create mode 100644 Frontend/src/pages/customer/LoyaltyPage.tsx create mode 100644 Frontend/src/services/api/guestProfileService.ts create mode 100644 Frontend/src/services/api/loyaltyService.ts 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 0000000000000000000000000000000000000000..129879637727c649b232d3a95c49fbc852f270f7 GIT binary patch literal 12179 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2ed5f96f1716f67ca4b451be6203c4fcffc684d5 GIT binary patch literal 20228 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 uEG7SL+4sHOQdsebeY 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 bdb57e06676a100e0181a6563edf729cb3bbf604..10b06b5888d0be570bd4d16e08769f61851887db 100644 GIT binary patch delta 598 zcmZpg%(!P7Bj0IWUM>b82t2NkIm>7wp9E8f^hS+3PLABxsMg%JsJ6)uI3zdk=age( zY@K|7!;-NLL{8q%DLUCfz;d&;z#DeP)=5IXjBP+-vbe+y_V(1^35;2SlebA|vUft+ z&m=TuyHdMT0Z`3Fr@J2^hEUlY3>}UT z8A0x1V2J7iiuM4Fl!Ne*$&{eU_ar4IZ;)hVRGR#NUv%Ew zPe_Wf6fvjvP1ci&WajCS*xV&$%*QCVd7s7^M)_NOIr)`|IVF|xMfs&Asl~TM(o0i| zOX3TP^3yVNQa3kiS+X(qZr*DU!pOQBXz;?#Vnz#@xk1dL1Q4-c@*z`q)(t@B$<2Ib z8jOq!CYzh0ay9W0b8*p#A>nPNDRPlBmHYNJLS=VS)~y~zPws*{%sh=S89_EOFhunLMZ19JNKKw2CCMl=*+58y1;~{HGDIgomXesf zRZ18npgeiMhSX#oX-USO$!^k-%-lUan-@tN^D)Y7zNUGGaq|gnBR0nF&A$yp7+F^W z4W7F>#CRbyH;7r303zm1GIwWP2V@=H>|?IM$T)9usf8})P> zyAPyx0*IJ4`Jhz|AZWcRGh>;Na8LZ~USZKaBFOMXkhR2=`HPrHi2?H$0}vYkix-Mx 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 76f051e0d6fe92eeed8565f5e4c46126f7ab407a..0f1848e2358f04ed4e23ba6df5b71701f79d0608 100644 GIT binary patch delta 1114 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b362f264e302e3344339b05c2ba60053c7b0cc69 GIT binary patch literal 1946 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 Hccp&;Bz=;c literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b03f97187012083b0e53b24e1ccb5f396e3c4c13 GIT binary patch literal 2372 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..39652193fbeb9d3a25e919ca8e8a8d3f91316bc9 GIT binary patch literal 2061 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 literal 0 HcmV?d00001 diff --git a/Backend/src/models/__pycache__/user.cpython-312.pyc b/Backend/src/models/__pycache__/user.cpython-312.pyc index 55f5045e71421593c0572889660edbba2f76b52c..5419cfbef494fe65f0dc1795e1b6a8b5834d8536 100644 GIT binary patch delta 1758 zcmaKrO-vg{6vubh>o1JWhp~f=A%;NALSjNfXwv|Zk`gNgIZa9wYLQwS&)C{@*M{9S zDnyE1sfY9u6|EvU>V>KoBsd^Nl{n_sR=unmOOZ!HRgdY3O6euNbY>R2cB|IXKEL_x z|9#Ax*|+{b`+Prny=@Hs-TFQ%J@Rh(dfDt&EViJyQZAMO4B%w9(w1tYzD@Qho|Fgs zJh02{iZ|tD8IDOX!0`nGoY1>#BdHiB<=b!x?$^W>Tw71)+{l?MDo~X&TQ9G0zuBJm zuwSyJZeR!Yy}yG_a17+}(+hl{W0xb*3~m5)Hfcf)ji*_&=cwybVWNP2fNNqA%5b&j zT%}aMC4~bI=B#4P7Pr>b*iZETl>Zsuy~|q}Q-e5u2>1cJ*VAaUbc(_N2sXiC%1(eF z=-mr7%8yVQ27Ms1*WaM0kLdt7*+gTMMq9FuQa0F{wUiPl9%_+}QFaOpgOR4BJ-_f9o>_u#~CBcR~tE!|JN4EDJ0enEJ-*}G?UnVYc z0+I~vU1&qS*WU_ycw!{ag`zn(xlV01dz9dppHZlYWjy1!h8lI zHwDRp4mZ0RZoF8s!g1jkE=8*!7p0;lF^7UVk}9o>xwNQhg^VQXQX#Ji9CB+gQ^*5R z-LTs3rZLwFic-u=87kWh|C`0@J+~){0#5xA%qcLhqp(#-vz9HzlEReno&D##$RTOz zb!k;4Lq|SY%0gXIV0vAYix72dxTcmBGo-<^mMN%EbrWHSEaDxkOPZvo$vL2Qy`YP- zMKrbDc;XmlJ;rm#6M&qubr#5NO;nUM)Ma(H+8C#XJQm91foiDDTHq0BsK@d%%~Q~j z&!X1ej4RITot~jS3`0A zSSl~neBsLMLGXuwxhz+GN;y&Mj8szQ^eyxLTD4Ox-@uhaBj(8Ti*@tvikaQ4_C7TE zK&^YA^3c4rXx_^lF-)Gl%DrJ2|1}OL?CP&PFsJYvQ0;={4{Cieb9BaB$eEu#!qOYu zJtB>K!kK(;e=RV$9XDr|O=-0nSo=JwX6u_z3LVD0dxBHjj5T*p(L;DUHmC#NtgMua zLDs59?g)$-dopPE3cT9m(0o`6FX%3 U51FAi{5Y$!kEi}%$UjZ<7tJW*5dZ)H delta 722 zcmew@{Y#kdG%qg~0}wokP{@?zn8+u=m^4vc-JOvkg&~D0ha;CWiWAIc&f&`CisAyY zSyEVYxN~`;co-R&7~C0B*jgA;*i(5{GlPs_V2I+aBt@qs5B<3V%q~=!YxbVg!QVsUB_FHCchH;4r` z%NNM{#bJ}1pHiBWYF88pUSAr?=mQTVUc2t RW$ZBd${@wa9?J+e2LLBGj=lf@ 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 0000000000000000000000000000000000000000..095380071190e3a599c49f8e4f1e53180e65905b GIT binary patch literal 2243 zcmaJ?&2Jk;6yLSi-u1VWIB~uyRX;GIv8YfH&@a-oO+%X&FlxI}yBd2Y&Nlnu%HNoYuku;NPlFAZ{7%4NYrbQtnToz#TfdFNc-U~M3uz!S+?NmP#ciqtTNS8rnMXT90 zkG2tVNTM5JiEeOyWDI?Y z-yS_63+@A*A5afTqqUlj5$x)ibw5c3`=^E~HVrquPxUMpCm2U5>)2g}piZ*Dvh@4( zfLX!tvxzZ6-T;oF3oL{nf;h!2MLoA-*`Ja) ztvzZ0YQUvu$;*JrILSy7%LS2`Z8FH?*WA5hbx5oig!5QmJMS4caaEd(P-Ey0$)$sr|#0UB?3C=vdOCJb7 z??YTWHL7(z%VBkM6a`pH9EETl(%|`}Q3_eYbJBi?<8!J)J)N z#A}ys`RUsf9vW*NZ(ZDd(;qt1xY`++Y~E~nyTBhg-?-k%Pc^GMk=<24|9)G_b%rOJ zms@D}Lx1>OW3iJPe{^c|@R#qlAa#@TbBm2@|6*i@H{fQ;&%EB4XQp7QI~}UaXj99 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 ef2fbfa7f0f2b260cce5509ac953e69e80666237..d195e172334879c7db295b10a69a16d7827236d6 100644 GIT binary patch delta 18310 zcmb_^34ByV*6*!evhV5aTW3#a-yi`(pu@f;Vao;*LT)D{bSJU8BP86WQ4s}Ef~C_c zAmHdI13JtXeW)`kIO^!=Xd?5z#GY|Sea>b?KX*UhId%JXccRYx-uHX`lm9(+>eQ*a zr%s)!TUBxN&)Pe`)P}tj8X6?P&-9$W=TPa9uz0fW*SY@0K?KoXj5ZfrO4>>+rER4= ztuvQd%G=5<6>Sxe7Q`5{V5w}Y6|gY|K3|nl_`DZc2sp z{4R}{0qHtZP4A8Dh%TCBvfq*vb=KKz_7`H9l_Tby%~cdbWm+*$%n=P|^UtW|%7CYW zfAM00Sg6Rc3#zy^0b-FU%(Nf@#Kd9`RpOyaJye-#u~-iODolxDrOCf5LY%cvdq!3- zv@P+pY&Hu~=cZMeDm{{ZVzrOtQjk21@1V{+Ls+fGkfdnz6X*KKEmt(M->LI5YoKOd zodM!}AK4Wkdk42mEw3G=y;>LeNUj3OyHGNHp4TR4i2NGL6S`^P85IjtXY+dJKqo2M z0z{*awlzu{NgY}Po+OG7*QL@RojUazO~S!+;= zb_J$JcL$}a$QpKc$Z9g5eHgNs7+7KG<@`3tr0W0-8oCPURs_!71e8?+0IE>e+^}5j zR!HWsuCUk0N;W4vJD?fT1{D=YyTadzCY@|~<~^uBvy3cb30b)yWz0J0$JMQ2<5`WQ zfn{VbAVX|hb{^TsZqA+q_*`~@-#$=8kFw9RSEq7vEv82RTp>V4@9d zYfi?jAc@(_XD{6x_Znu=#v{*WA1#%O~}%A_7C>iTmk)5G|~2LLloDc88>aq6pS#2GCohJSYGzBZT7R&i>@33~*Y}BQ;+9+*6ei}j}!u1IJ z?e`<)xDn750D@h%9*c<{MCQ#1o=&5;VDeUk!w9z_FoZh*T!B5-{;qy%>9El|k$DW^ zIKn*$_agip;V8lb2oD0d0+;OSG!3wu^9o~lKVV3qsR(Bg9%3)&m96bTvKL_of*HYM zd)kl50fff@Tv{*(wYk)$K9T+kxz8XJVU`~?SqIHFx{B2s`jcMbEOUQ%H*|x931XsS z@U$j6k8C6C)`UbFzN2Bt$C6hrG`P@L#PGHbo2)hBS z7*D{9F>SwXSx&??NbNybiQr&G&u8!+rw1_YHTEG)${vi=42%0A5B-!LLEt?)y%!8x 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 DMAee( 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 0000000000000000000000000000000000000000..994b24cff34b3ac7e3f68c43d5dcd62d3e8fdda2 GIT binary patch literal 28343 zcmeHvX>=6XxmZB{g@b^_?%?&tAU9#GtWQreYcjb zZdEf3Gxo{xNge6dcbEF^()U~M@++IoOu=*cS04Yr*h5kO9V61?7J(-1ZIMUapRD2+%#l@xR%ug&Eu9K3xV}P>$q*m zHf|rXlei)17Oad&&k&M^rHoA-CMeM#laYA$T33oQti;j zXKkm3ZBW#sXB!puG(tTuiIpl|Y}0es)9h=O`($JnDe77Tb?sABB7$tobJw*vqpns( zU5lZvgDQ2kJ$GI0iZL{@OB8jrR6tmFTD%vkW!>Kaw-Ma)Ue7Kr)T>VwXDd|lYS+xXXMTrnG0#z zjfy%}=IF`5bJc;IbyLPn8&uTQ1$C)$q0P@-S9t~nZBf*<>Xf~C>j%j4nzcz&&yLMI zCr^ZZT+-0*JMNocLrL4>`#IkT76oKhf?$_#Y#f>f@q7~B!A*|(gT7rp?p6PYZ`+ikeGJ>wdR#~kP1!T! z&_(@mXt4ieQXBS;g+8I%KR}fP5NgJJAE2i80gG`BR|c=7Cg@A*yzICi#xoT5j*cev zA)l8UIhxe_L&LB7k0;F`p-IDjHtB+L!$JS3FYF)p4ZrFQo`4=D4=QKO=$ zfT(?8QR#`8B2-_Izu07r>JRb#2U~_ z->2V@s!zux2vR|F&3+(N*={i#Qmxq~DGE~U3sbqmF!q)_>Lnx!o1j?+O1Pxb>30?m zl@baZTEJ0|{#Jq@RnpO=BzSYB>gfR-$gD@w_@JFwM`>0zMEj|CY$KX6YD9CW0mk1J zv8j%~El|GjTPRz(FqPF^U}iOMH)#PU;Ijri3(G*QcCl8N>C^UzUA0!bTCJ@9f(Gzc z5d3!5aKSR|h*$!(3u}ouq%o#pe!aa^OR>fX&N&kf6}-*j+akWL_-1Vvz&f~~VI3DV zyPyv=>BnmlWPgpSrNVVmZFLk^Hth^IN^#a1ZkFJPQ>s60oxBOZ#I*Ab=WFz9)Jf(5 z^&0)R%xiSBYpQq`A#IO$g7pAb0p{u<+#}R%;uZl$t{Q_H46v&lwt%b00Dd=$!v=6o z5CA_#CKTe3Ah%s+k{YD*3GX=2`N(8&k^}D2hJ}ILsAda+lcv!V!Qik^$T#lw2a~$v zM<*wIq`ss*i>tU)IEpnq8TJN&DTVyuP|^|fhQdN*nDs(mim_!zkz0k5hR}(T5fBVX z9r;YrdW(Pu`BJD^%dN(i%6;S3V4F4EV#H>KGX4p|%r66D1qK;D5lU*II`0P`0$+ke zi;y@xGRXqZV|`)hS#u$WM3uA&taMl$Eixn>SldzzIx!%Fy$lQMMuU^yFt;32S75Lb zQ%(NRe8lJCx z@Arj{g(r`<4}v5Ow)Rd=9`jF(wfA~QjzQzwL)=I^htfYJh`V7?+<}C~#J1z7xPELP z2FUh8XW+;9pVUWxL8Z!Dblnf~>f(7z`MjkGM_wYo1pkVv6IC6F+EobW6((FoALg4~ z<{5pem?|onu_Ox1&JFT~^-n0fsbyy4LwDKPqq9d76*U)!&krX`Dle9uFOy!SRTmeZ zU!3R}i1+Nd*R$ul^_O(i&ZyP#Y$ehR~#U<9O5@0`gQr?>$P)z-y8V$z}3DV4gO#- zR8Y-mQ*QaD~?+$d28jlsb5 z;s1#SAW3(KXachKAeW@VvK}Gv&H$1~){`!!suxhlfD_8!iyDU#g(MfJnFtfg3tOZ- zmS!~(>YEhHe2clFl{_?W(h){Ydti0PfDdqL-|I8K4?Cn90!|4^zd=jVNOetXbBv3u zLCF158yC5hsy^}T34pHg%lv`DmyUesc zN8dcsH!>Er5td6mXW!D_KX2buG#%C;xA|#3Hz?+ztiedV28{^jA#1#1lDw7EMiT`y zAN7l@=Oi5hy=V-zN0UN(G(}9R^k@p8W|H+lS=4K?9@Z>rkJIL96R96XbJnA%-=e7B z0(zjDonTR|-x5F}o%OI5xqj=k#ZR$T7y%WWU#D3c9I@_}vo!BKT4CXFXZ?RZ1xE7Xkj_jJ_4Kg^IqFWYjHt(zym``9N01 z5Cw}#H9fF-VOfcjKrgWs_@xS+5{xS8&w#Nkq6NI=5gm63{;d&RV9UZ*VC`b=3s246 z_SD=RPtD!=)ZE?bxopJ+y*M_N5i|6pDq@Au6VWRy$=5|oQiX27X570FGo1q^8M%R|(cU4XQwBMDjzMu!2bXu@^zfMOC3JdpNix#fhXxIaX7xpFa z%EC0TkdG`(%~6tFSQ5S;RZTs*Fcrqq9#BcoIhOVuW2u_{sbg7l!2n}3F&*FqC&Qg%=`aBek%uCe?jx_=xHb0z&1vl zewuAkcu-8lZ-SOPdBAT1>&f5)G#{YOFmL~F$r~d2TP!(BTS@8voK@PU^v;MF90Y?| zQ6Fe5Z&G1p3zkpkK_7~OYo=Xnvtr!xq&|WruqfgRoRYAl-;$j8CiN}tbiTrZXo=)2 zEeEz}I`56VEN{=^sj5xBuxDg)9NfW2eG?)7t3JWo;}MPN5557m(Uc8+-JaL{;iI0- z{hleaXJRt!8J#>a!E!y&ln>Cxnkx19LnOm1qG%d95ekDX&ozHg00lVo+%?n0ImERh z%%mMaNek->jc|VAnHQ2Xoghhrmu11r5K8L3uYz}hBVLgK4%HjtVj@nFZ3?f5d4%(U z%ZD8Xw}{C*;SHV&`$s}a3wpi!SlnNg0kRe0VfFf z^h3lW#i7EL)WN8Nvq>+w!n7DmTEW5YA3r|Hg}oEt-GSI~&JX?fCFSlNImMy8X*LMo zx!~7KFE0#-aY4bvN^1z{@j4QK^@OAWTL-HM;JZhcK5@DcL`fa-*-P!P9})a6!xJaQ zkHAuhZRDtLXK8H8#!*0^S~;?$Wie6WX{Ufo+NGNHbA-NwMiWeJa>w-BM!abu@pu^H|pnL zpowS8fXg=E7!Z7J`MAI%c*^97!J%o)p|-&}5tVV0W5JgojxPA~V16bUZ!pNAZ7aAq z2_9*Dj>1~fBs2s)8fo7fhguakh`|92GG{f1dIfh7f}{c3mDeI^g{F-4-nA#h$ z0P**stB*sik{ib06%4!>pgiVAFhF_Bp?KxSFhJ1>zcZ4-B*&px;wCXTh5^bWZX5#- z2FTXAFJZu8fIlO*6$9krNmH2fj`)s%=PhX#JdMM?lOxRq!gw`fxkXq`i(VJ*5XMoT z;CvV$cTXDSAA38dp&yppfdNi^(&nTVr%BQv7ED?te;zs$IaCt4w;)Jbp@vCrSn#5T zkh5g_5HZf}!m@ekA6lASZ$mn2t067uL4rHbK1}O+>4%QIcTS!@c|rTNH{y;u-ck46 z-QU~y?R{4beErIpqbu%M$2-==92;h|_Z|5UTveC)uC?AZTt5}vF%<1OIPZEnYJEAS zr>xF*x=(kX>$qnr&nOg#b{(5{1*6vBhkB~8DPFLKFIaPJ)AgOPg25RRd{|V1?!*gU zj=9_8?iIXy#Sgz6U%8E6xh=Z$0Kf7;%smu$zrwp;iMfx=SfG4f{#oO!@j^+=SsQmQ z;+>1GtctgE^DW)i4SdU{m~$}he2I6yG{5s;eCH^?b2Ps57{BvaZ0C5)IT3eqypxML z!!tU-R9b$q_(Rriu*7B5&G8KJjrV1M4`K$T-)z?;D@4b6u zKL4c|V@l7M+=)F0;(J(r4|{J-^x|UFSM4t~kHqQs?)(E_YoUdC&i2KMYDV zKRDA5LCVOON)tYR+;@`qoxJCJBN}<*_Y_s!qlq4RQEX0FooDLKHqJJt7>GTz7M&}&Sb4rOrGprZfUV%n?z2O)Ln#Bs zjp?`v<7Ud{J`+A0nT@0@7`IZk!ZYmIW3$InHjLXL{*AG7?A+ibU(CHM<-kNIWvlqv zrtAIDEkpC&2cxgB@mE6kUI|_B&GpCYR`PW#=PSFSr>0VQSlC6`s&8+)+aKLII^RDQ z4IckL6zqWO*E|+p;6B#Io7VG9>*s4WK(DrFwrNuN_+bHME4#31uK!Zowdz=Tcd8H* z-IT5J_R71NZ68 z9Js%G_|n$+;vM%E?}+;R_g~m|$r5iGyw@}sJ>-3CGnCt5U~3ywEm+}V%9ekoKh=tG z8)YjxXH2ytyaawKC|2hAy~E(H1}MKMWuuD9&W_EFC5p>0n$DXNp1QcFgZFeKDr+uI zo}Wx~b-!o*iS-0uUvMoa@pOw#M++shJP{)iBKY5xstDh-)B{H=iVnaLy`S4 zg~&Uw!W}eK*8KZCW7Q(~g`qNsR9VBKc*9!0VQsvjk8kKpR8(I)eEx8}qJyvKNR(Aw z>^|Quy;iLLQQ+SPpsK+6K)iD4y~?H6+1&Lzj2@^T>+?{R4bV+bBX+YCyIFZQFdInJ zG{D#LgZBmw&K1S0SMb#<;?*1Y>J9VNy-}|(F}OWGc=+Dn;kibkbiAgQ zuj!qy>5sBUA1qyUt?|9KAGh6YoL{>0T;s*I^KJ3cwtJ;*^ZO4*Uml6S9N=FL#P%Oc ztnH7l-EnX2jteK|_QmVF`1-E-s#Vdw`(T)=ahN=fk9W~9PBYfjD2!CyCI7YZXzKv) z*c3Hx`uOoFnkpElKYHLOhE)OJ1^IxYE^llu>kjY!c_PHCd3)PxV(%UgNGe$9wnk zy?bN5`=dPvqD4b9R5ON{XrD@5We5qrE#2e{&EE&HuZ!pJ~+!a^f#C9p#WcoPMd{k2S@ilo%g z2-jkp63bl&x|#ciB?neA_gCr=?q&ezlsmoT(Jm;SxCaIFQbmNU#*Jv9m(pVZ;H&We zBm$bdybi+rao0fA=c_He?IqS`K+w4V%42qPCuc z`XPBFn@V*(M=qtlK9aGX^RzbVBDzpT+RGiGuIQwCr*+`V%a>Y{en;dIu&-Djp=!Vl{2sMfI340NA!}3V5 z7CBGYpP(C-SCPOgEqzD!EI>P*a(y;A&nC7dPtLQ;d3G@mwQBN!Z(73X5cA~Gf{{aI zGwYFN292u(Em@fm%@|oHo5#A)9{{^2I>`>G~A2FhATOm5k`6 z-#WtrFPPT9q0h3w3#W?r97jV+HgII)l-mdRICN5eBJ45Cvbw>PCf15<4J6%Z)05b; zX#1hfBU=ecSGoi!>bP4BR?(pbWk%3DF?IsBJrHXm=?1eKOx;PapTYD@H*OVtHRain zPVt-|{a`)fmI~a-aq6;$SQ1IYI9Tq8a|3*~t+|9d32$Puai4K6>Ui6xPX(DLV*&B0TU9;3ulwA8#Wo_ zG%kKt@Lu_k%kSp#tG2`(TjP$Gc*jdI$F8)U5$)PM@7fZzZh7b~{tPz4Zylu%N}I1X z&X=y9*_yE0-x)YPaAwWCwd$dzF51+^Te=cUd!v0X^GgrSY@N5ZCoJVr&l29UB(Z39 zbj=I=q8Bl_F=45VHm>9?D-#_((cU4x;~*xtB`lt3-7?;?EYY?ux_&p`wg;125|)Z+ zbq8o&7#-|Y_;^%!h`032b@ zAka`VN;2S);{FOhJd43O3{>jj1~6j_2In!@3V|f6zKIE0vWlQkmsNQ}A4OS(W650v z4F3!ML#xw5s_csrQonN)Cko0EMYU(YJp1KDUg_D2*@|y)P$R zZuI9Q3X0Eep52@%_r%Lv`SR98X(f6u606p#x-uSF^usi4RMYRJEsrR@(+Z%;`q78> zY>V#6))!LbZsnX{+)xw7r7aa#9=E=!d+P38Cdf<9m>(gGNvHB{iI z?AwspKwPnEres4b`7l#==Dq=6iVCw;;4jEQ6bduZTxEqBHx^^{0%OTy9eRNq&LM&{ z>qv7AJ!A+PuIM-ya4DI_cc6$9CSvF^0HS7VOEV2q%uy3}H1m$;E6v|ue0lM;Mt)Jx z?N#x<-F)Bf=>B28Z#d?7CGHsI9iuVFQKfb}IPcmVwQd#_wlj5aJ7m%q$enk*5H-H= zdGxhDJN;I#sn<^3w9x?Hw7dE&%uP>2pPsp;*8zOX!a({|p@K%?U`wmGo7)KRLY+-^ zqx{!D4Sf>Ydpdo7fo>E!phBNWaS{zjw8OF^2dGOn(qVdtPnuTMsZUAmtbsMMrkvF5 zmT=0{jJ|=aCtaU{n$6)dshEPA6`N{ViwZScrz&<&j*SIaZo4n?_$X`k^dz|iE6Nb$it+Ec!FzK=ppylP`C++ z{2Blvk=xTmuC_MzS?%;Ao##?GW6nG}oe@qoxz2 ztrVhz$hv^k)ATlPaMV7XYsoeq;aUdZf-)~s!Z^Ry+L(P^+}_XI`(yTj z8RovjCF}-p%)17o*1?AsJFFm2&Q9`{dRUu&XZz{xacd24t(ogYmm4_iHlN=7_Ew_) z4Dyc6QRC*%!wNT=z(mpZo2c7H8sOU|*G4^ayR2a&&D^1N0N>FwkUmwM#R}VB$FT(i zMV%9(F7ju`3DuXul4l!%h7N}qe2l<@L zZGjZTM@>6RYeR(3J=Hp5!PCYXpHVx*Ws_&s&P)pJOnuqJtl}NAOqJ~PjZeaEMS<0g za+BjvWp-$@2#Q%TayO2AoSVNrlSU@9Haz}_jZx)p;sOKVWLexT&Da(!FcbU9SG+%x!Z+{}Sf*5*@2KSLh(>Hc`d z#RIe&??`**C4Lx|`3HF7Rct}98%541y>pb4wVfRK#!va)^eOq^m6t5oE7o^K3zq1& zDwsTe>p56ewJ`t{W$hqMnx#FkaKs;XEW>RnlW-c{#||_L${ifJC!366FZ?L z7jW(Z(cPXgpxocX2N7i4iy#mgckvg+xPRxUMvr@8_1WRs;RjVqq8)wlj!k^Wrs&|y zv5rHrs>2W5i=(aUj1vfz(D#`fr8)58@n@3fXgns3HTjk)%A~j0{jbe+2JYiOtmm$ z=Tn-ktEqsQr@PUS7tz9!pCPAGhZI8|U`J)sI)!m+R4{vai8h1Xm{eS9=BfNmaN2|{ z;lcGH;(-iRCWCB1*hY?r0k{niY=b*Oo?(#<{e)Zu147MP0fLhZ#8aL{!(^8_pF8e0eki) z-Mbw8T~x1*x~WC@rq0#7l(|{t?rmjmw(1aG%0POCRwTG4Tj7KXoYe@alggR@Pby&j z5MnXrTpw2wOABZd8a2<7s_ZtYt~M$C(`FT7vP`Ytque+q{(_z+>5d!^ImNTsqr4-> zLvGz#?14iyZOu6eZ>s!7VLGU8>B7BSWU_GIf>|e$rno+Z=C5LXDr-X*l4i0XW9O8F zDogUJ6YPIL?5YI&Bh10zhe)tj01ye*`h^j!t3K{p&AZ5PhL~&9jDfgjwnYydiL%F{ z`-8k=JZcI)@`H*jF<6!;$(4ToJ-yOooz_G&aQ2lE&%SoR_8E{vstyh&Aga@Hs{nwqW@TJ} zAywXena*YD3tBjH+o`3dwbKml>?LHUMHwZYr)0=^O4-pO$|khM$;0Zxvhk5ty|2iN zR&ei?L4}-*Q*M+^FsL3lUg!^ckHE?g_`X7ywZdMIcEC<9QxcAG5dnqXU2VtL}BsS1G5JbWtA7#pI`sPq;tcb2`CH)mGFfpF6u6gUiC*?H}DSJO#ks?2c&=W zq4QHO{K1g}S>>kRYV2vJ-d)kNNPDB&2;hxoOuey4hj6>8H=nuDke@=gWV+AY=qgrQMe|A;VB zZQyCs1W*ahdL&{a`KGK)h$=A%zPQgqR!~+Zm^5xkb*5ty1Z%LU7Q*!>WM4(5T*FI& zWIJE_Lb)E2mSwCZTq2c7uk(V%Eix@2MBB9X4QOC(wwN9dJUhFL~oPVzmEh|)Byh)WHlvAp_(tMu&R*~RBx zyJU*FmL`fl7p>>5bBAKZogW&s4)Y8HL@e%`3aKorcE0T((rQa|&rrfun3K-oIMF`gGUHr*-zTMlBi6L2Gga~M3=a4Lc|_Wp+Jd3bBM!d7IB}67Bv~- zkb%oHkv4S;#G#T((NvRZl+~vxQXvlIU9&)i>U)_=7m7pax7rQ&A*QL~0Q?+DBOKj_ zokxBzao`ANde=gRQZR}-A~ucahhyTJ&PD! z1C0hTx`KYI$I$DbZdg|Inzc7+BfvLJn10i&L)c;J?F22--P^|8Y||m!$w2N@zQR=^ z4N1}}LQmQJ@H?ni_M51ZV_LLMYs0eWRlQf0g8O(GS$GPIUb1k^KG%gF3)x$NCOdgR z{}WE`$eJh72=(DK9%K4ej2zBUlZtcHJXO{MH%5<5c%r=Rz0ibDN;l!f z4<6W_EaoZJ(|e$R;M1(wCCpKemmqzLH&ZV-D2cUX7fMT$RuDIYCWziBdMIyUEos41 zD^;rQq2zv!`50J16o*~|K$!hbR2K`Ym%yR@gaXet>q*GTyc9>b`4%yEQS_ zi!+AbI^1L}JKD8--nA!c-9v=ScHXfgYTU8F0(zR?q7mwTlkQnUzqJNN>OF*RP_CX< z=7!7N)5P3p(jnZ+K>CzNWx}?*l2+Ri&hG8qmk*Ao^7kJkg8RF7jrPE#(6ddfH=?0px`|7yxPV) zwnvTIKhwObhq_>1Rnc!PYN!4Vf$zI|%9$IqyQhe`QKUn-oPp%#)k(uBToLR&?&lb6 zF8-1NmEkX#w1>VF^akOIlkrn+aKSEzOa(42KXn|gB;ktR}qhR z;bfhKq>lSpHt2iJ%lX>y{&)@zUJe%)la_I~2{;&PBiCi1YMv|-Q^=32IB7?Y!6oxD za>>48RNa%#jC52eQIAcQW+Z2>M4+l5tb^bZNU|WKsPxODkWN-gM+DoXMv-ggffTC-dE4tr$UYJqr#zbn*I&d9izJ6r)qvfRliTw{f1iqK2`ZXwfudm`F*PKH`FLk zjlNH{zfW!WN6PYBlPg-<5``0LOZ(?d8~;KN7e;7||E%;Yr89kBt@>?w^<3RIx8AR) znOpYFgZDl4bL@94_ZPKa)xO(uzpe9X|GUTTi$$D;XT0ZnzG1(g=RU)o^Un3n=QZ6g z@qD}LYSFdESaol#r0>3~1`<;z)*c&$+`Sl zTgrejBUMs8S9so$GGWY2Rn*VzIlnz+!5F!z0&&I}8LOhWA5vX-WrgKnjgEAY?@JSO$?dq%@eul17(( zr7UWziqW3jsB1*j@P6pXPwC(-qDDwS4G^NPPH8Z03qf7+m5QjNB}OmKjk-WY4ey80 zdL6t))CdWv0YcQRDGjEz$>UKQqw8{GE)+4t`=Q;H(!pEAjF5mCAjDjs(qP&W#7u^x zGDcVB##}05hBv_sZxJ&>0%m{^vnQp&wAF~2j7D>eUX&YiiHI5A4`Jfz;4NZCNWcsb zVs1%kFs(~&b5o3N&W*WB#0>9;#pNj-yhY3i377#w%!^VQOzW4XbktfCqib`|={ylN zyvclqH=xGZ3=kr$OKC8zDYL0LX~ZSsgEzqkZxJ6rVgoU45#mGUvNc8*|5lwn=$wEI zh>U^vL!;v{#t}tHN&`@;PHfW5z8GC7_b^rs_kypMIE%p>=pGh6rgL2S)fc0TKrv)5zeH8Q2k{m*$=) i@Iw*I@P6n7QpX=o;35bKm;pk}osaNm<=zK@@qYnz730PL literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..22a83a0838f25a4bb2da59043ec0459b9027a7e4 GIT binary patch literal 45509 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

77H?E4b#5pc96b|_1;y;_WaV~cp?zr*{*CHJ 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%0AjF1x`}xsTx5_8^r>hn({5iVJ z-P=}o4ty{z|oAT zSQ4wVGIY?11_yj>h)xu~uDgdqiW&$vPnoJ$n5tJxx(a&?6MP?lgj2VL;VyGjk*iSp zHZo+SKW=n7RWAz~bA)>bSZJuXFBID83+~{Tt|Of{V3unE^?+3Xkx>?)B#z`dl&%N3 z0LctenEoEL3Hu{CG4dJme~G;Um;tzfKu>3(K%mdtztdC79zrWPD{3T@*hR`gis2$J zz)2-{tLLe^ePNAspW4qAn>4;steq4@D+N5mzg{;bn ztjfo4xlq$FQPc6;td18;CQXGo8{~`mh4*J3&OF+7KEL{+z3l$Z!<`8!d&jAgU%Os% zy-MvKf%C4R$!ywJZ2#JKOp!1NqYnA<4=8q9A> zwLP`wH*2jZH<){xV{2~wO>U*&`3FcpgXiferM>C#40v3oXVXQuR#xnSz%c*_{D06D z?>^v+K7*cT0Y3)B06ziz9pI;cp8=i&fX3MKfENfTc!&$tf`+TOOt7Nx&eza8k6?4Z zOc?)Is4!oWcz*8zH+!5a>&BT`WE1AfH>72lr91U z@z_B;)kg5v9V5dEy|9Q!>|T28*G9tZ-%0xU81H*OUykzI!*@9LVR({1O{l)bbG5DV zdwfuP!6BSnBr_>_3|~0%cC8pBeD5wAL@z;vt)QZwKBQ`1THecQV(qu>lqqgLIqJ!1 zr#_FFNix4US|^`AvU_9Y7@8XO5_-XN8wJ~NGrc?T4XZ@0qRuYy@b1c}a2RnO-6YR$ z1gs~}ExsUy`3Ax5}DF&6i{A z_6BmYWJriDy~ls`?dpboG>j}SnuVi@zC29k0SKmOM5*cjN=SxTY{ogJg!7j%dxD3U z3DwsDqXd^{#(7iZGJO0%kqh2paGQ-v^wv$VjJO-8`9B?4lLOx|2&aaf;dAeD93 zLw~t9tVFN4?pJZcH%(fE8;U!fq<|@}B=Qq($s(pi$`x*PeR*aWQg3r>K6GO$U^k_wc zrljdA!?~FY-Q|G$FcNx)(+S*DpQ%h~>i&v~XYC zKf{m2RXK_3dw@>}e8=vRCH9}Fe#|o--Lh_4B188d0G|Rr1N;$i8SpvaPXOa)P)X0E zt&A{wet)!>|HGrJ>x3c+64bNs7M2JM4`ggMHtPZW7jHSXmPl~ySY4%XYxsu89Dj#P z&w=!7&6LNS=KpxCyH@aGa|$og3(jG0Ai;~Zkgwy59&1e@$2REtl@!Qq828Os@5@P# zrD2w7onBlFVXo_-%=Lf@0zG>w1r~3ujSwo#mb@E+AdA>6%$+1iW24kYk2wJ!-UpMl zB*;wpKr0o+`3q*qMo$hP$((}R5{IE%g$n%M>nkd@LNj|ZIfsP}1?STgx5s|>{f9HF z#X9h-y~R^cKBR)%7j{oY6MDzVA3IrNdl!2h<*%P?jLv`p#0HY5f}KIL6(EjB1e-;u zig|}nDkjiv#tA2a&h+MA4EZ3S1n>a>`BO^HQ!0Un5^i>N0)^X}@-jH>YBCaPC3$EI ziHFk&uDGU-e*B0KKz0F}(HCCTvsT*oGqM!>!w-L)xxNubNE)xm_`Tw4)6vWnn1rn0 z4l&@*7ns-rXtfJA5LW!ksKg2(YzGtpPD}jW=eIrQL=w!9EwN%~$Pi-~ZoF_{hPH8uo@}WB z3~@QVgWjl&1k_Hpmb&P_6uyxke`&e-9ce-A_bq=`zCcf`*9YPQL;hQ|LlvZsL| delta 7010 zcmbtYdvuiLng71IWpbM&Os?cMAvY!ofhYz70UA(Hf+4AjfVgqU`z4t$nS^&HL1L1z zs8tk|oVU9wRz)jn3hrsobWc5}H_%_cj>~LwBj3P2O9IPwSQ>jdlv>)iqIa zX6gJ)++Qk6&y-!&QZ7nG#hJ>}c2!}kZgUBrk!>`W(3GNzN{w7vW;Bax`d?!dV;}ul zqV_@OX=_<+@5=eT=ymF%t-jKiBIy$KDUz!alFM1rf0blIiew8(8aSU`%#w}9^@-J8 zqA5jkHAxl(rQb}$rq7S|7V%_1jQnRA#$ntR2j-yMI@+m`OzM=hT?Fat^-(|`WWX2~`x z$bkB%vajmv)vEGk!Hv{lX7ouT5H&<^AUbQaWL82DJDGKGgXstdh7GwL&7FW-2+Xv| zSYMwJiTE9+GddCtgk&H31o|?#p*t{OMEo}CQ$HvlENDe<0bng409dcqR$Ldu#!P45 z$XGZk*PuBIz^dSt+#^yLvUmSsDIw_)Mx@Vljr8|NjHtDqJrD}X13XQCXe1CdGlG#| zI1&wn`wSVvY=wZWfMGxpAPgAMVuYh89t3;^0L@5NL*me;D=<20goSjXZwKI3Ko?*q zpqs$7N21c7Yn^UCS`$i<2C8gZR>dJyj}o*noNz?WP}7R|-&BUPV{|IZ1wK(kfoA&F5xGAogb|iO z^b7$a0M>I9B`8~t1AKr9z%+q>x#S&#?JVQ5E3HG7webK-4*}S|uwhJQqMk6KrIClx z1|!Nx0FMG-Hu)If7~spSMI!4VR}8qe*;r+rka--y_IiR!{&Z_6Nn`L9eGqyQ0QJk` z089}{C-BYxUnU~^`6tlfS zAy1IZK>V$?NxSW$JKnM3dAs|#O{Pz#4K=H;JzS9eMY@K}9x5I#nMzlATT0Z)eHrRl zN1=MN$ZHc`)w{*7{%@aKZ&muA71%R|TGh-A-pu+an1p}o!=ijO{772*hD1wCL%v7k zi-P&AHkGM9{cfRpVPEF_%Z(L1b?rp@n6--SDYz24fe`&sQd+hp#U z zIYke7^E02xn78H;-J0!?en`95zDGNxzivOI`&V{Z;mx6eOCs}$>F5hY2ATOyNBe!LL?mmoWhQ7|UQ`lKF+KSdM;cE5dj^bE51U-OF1ZH~Q$na>0E+)ca zSRBi=2ZWHDA+rSl_mwvS{D7MPoK^Es;`44p>1IF~fa!#@C?E3}+W4$bu;vpdmOnyI zDWDUu1AtIim|@*=Box{o=sV!gm(QX&|O)m>dHmi`!Z z#K*!Un@>r$u*qj}n#Z~%%_myzdV-Yp5VT-gC)j zyPT;Wx&2VApTv&nJ$3qn+e*6YwDT3)T6b1!7xML;RgMb<4wOsMsN;eULKmuZv{a{e zSKBW%t>~_>Uo3G_@5KrywOp*Wqg=P7hstZ~dMfPiS2(Hsezl#(PGqgTFDRx~(zPc# z7BQUxF&qrXXYY8!6JvLO2B&1DgR>{x-PRSuH>=B9YWUT@FL(Pz_$P|15Q-{Uje)dcnvTM_%`6Q@|-Pdq5xx8Su7%8FUgkr z2}&)h!#KIouXp*KDR+UtM%QBiIGW#P+L~9H`e+0!;Ir&_CB1#X2pji~%8g3zZ_xi% zHT8GqN8BWFWZ5SD)Y475JWCZht{&+3$JlGmq0TY3{!3EyTaaWz;JXPjk?EF3|5#X1 zNO3^2gF^dqDWDm!2e1`z1i%5oXNe_3)4G{%MmI;{f&g4B_So$?S{FoNLPHC$Teuyi z#NV){zCBR60~`Tn2=@o&FdgH0)a{Y6Ve5{_L5ol|okUf1e}9|Gey??BxIpEU=eFiG@N@yY zHES2fm$12fN7YR1(J!g#i60as`FeKBD#g5i{*jK!$_i0gHMo&|Yvg(e;u^=@lZ`H&{uZsp(kRR`yhw z-Jd5v!t_ZF{yC<}h!`|cwD*8**_r==K0fCqWj~hkCtYyVup*On{X-13s0bzYwzDXWtJj~XD}#erWQI_uS2Ys}JN`NKnrB=SBwmw-I+l&?{^x6)w1-%^63&NkdV=)dW!w9z8c9hr?7i8Hv$0VIvZ`l8Z zAa>1xY%x+ds_+2+l7$I3X`AE`|G?P3D&IONl`f%#TVSIn*7Wf*XFA!xGXBExr_%Ip zwdU#9%HPB$VETz%%N#2r!GSO(I%&vD>Win>m$5JK(<=F@^2py}T(-LHnblS}vX^@X z17Q&|dJh@<2S-K@SjS`^XA``Qv$|y_?ZJAlXH&`a%t`lJzAbamoeOvpYwRN6Op<&o zQj6)ac1ufTT5A>3h^~0vi7gpf@PLUKO2gfOkm`B5Rz39c!1Xv|O3bitap1g&j!S@# z34$lTu50oasQ#;}Jay;hMHvp=p8$Rd_&30>0KW!&3iu7ciWc?8sloaH>g+S1Lo*ZK zTFCpO!I7{m#bL_;m({jcI*AU|EA>_E?Qj{t-JJVULwDv1h_NG`Q~&Tv7x7}ltCg-2 zv@TUUUu{hx$7|kK6BH=_2fO;iSnt}TC$C_ZIgc-=;!f1r<2fo=3)|3kv#NTnD3&C> zBWUC>fPc`vvk2!%a-MZ8YNJdU3gWX3Y2b4V3IGlO*oMpjze82S47?ZSLME+*eoZHR z@k*yJgX~m%6Rn!NiF%U+JsJr0@|Q7LN0ar%|Le8qGF-giC3PZRrpMIx{q@ zcT+v#EDJ9{h`E%nG-(T&hPRa@8CFahjhTx!qK#jEuhi+M6Fwc!_^a<_#42%E9)*~h z$hW>B(#19y@y#Vv!GuD&gk_mOct8&J@+an!?1eYn;?3*^%dsuK0ZyRJL6@~CeXYh9+@G@0=vY*Kh<;peByyZYI^g(~@Lk^3tOn|!C;V>0EAIB; z5iu4rHp&eoK>xJJ4)wu19r2oX-_&dC^!Ag{%NqT8V>SP|*xUaZs-N36+mg#RDqZf5 NXTG=G=4hv}{|^-c85{rr 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 a6dd20722d6200a73479c1fa129a3aa71293ede8..95d42a92f4046825d1ecea9693374265b76cd2c1 100644 GIT binary patch delta 174 zcmV;f08#(gHP|%{%MA?*00000$)6x;?+URFGzkG2lS2v70obz@3K;tIScRs9+N5z+5se!1r4VGC$p^$asmM|lOPbo0W_235W@jBlc*6@0WOpN5mo^z zvsDt60Rcmk>l0l8I1OWj?lTHlXDry3>X0skZJS)7y%LVG?PUdlbjAV1poj5 delta 174 zcmV;f08#(gHP|%{%MA?*00000C!Zi`OAE0MGzkG3lS2v70pPP03K;l0l8JF`g?1OWj@lTHg6tkZJS)837UWHj_mflieXS-~a#s 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 0000000000000000000000000000000000000000..1e7a985afff46320062bfdf4394f60c4b43428f7 GIT binary patch literal 15484 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 literal 0 HcmV?d00001 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" + /> +
+
+ +