updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
160
Backend/alembic/versions/add_guest_profile_crm_tables.py
Normal file
160
Backend/alembic/versions/add_guest_profile_crm_tables.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""add guest profile crm tables
|
||||||
|
|
||||||
|
Revision ID: add_guest_profile_crm
|
||||||
|
Revises: ff515d77abbe
|
||||||
|
Create Date: 2024-01-01 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'add_guest_profile_crm'
|
||||||
|
down_revision = '9bb08492a382'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add new fields to users table
|
||||||
|
op.add_column('users', sa.Column('is_vip', sa.Boolean(), nullable=False, server_default='0'))
|
||||||
|
op.add_column('users', sa.Column('lifetime_value', sa.Numeric(10, 2), nullable=True, server_default='0'))
|
||||||
|
op.add_column('users', sa.Column('satisfaction_score', sa.Numeric(3, 2), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('last_visit_date', sa.DateTime(), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('total_visits', sa.Integer(), nullable=False, server_default='0'))
|
||||||
|
|
||||||
|
# Create guest_preferences table
|
||||||
|
op.create_table(
|
||||||
|
'guest_preferences',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('preferred_room_location', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('preferred_floor', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('preferred_room_type_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('preferred_amenities', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('special_requests', sa.Text(), nullable=True),
|
||||||
|
sa.Column('preferred_services', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('preferred_contact_method', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('preferred_language', sa.String(length=10), nullable=True, server_default='en'),
|
||||||
|
sa.Column('dietary_restrictions', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('additional_preferences', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['preferred_room_type_id'], ['room_types.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_guest_preferences_id'), 'guest_preferences', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_guest_preferences_user_id'), 'guest_preferences', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
# Create guest_notes table
|
||||||
|
op.create_table(
|
||||||
|
'guest_notes',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('note', sa.Text(), nullable=False),
|
||||||
|
sa.Column('is_important', sa.Boolean(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('is_private', sa.Boolean(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_guest_notes_id'), 'guest_notes', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_guest_notes_user_id'), 'guest_notes', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
# Create guest_tags table
|
||||||
|
op.create_table(
|
||||||
|
'guest_tags',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('color', sa.String(length=7), nullable=True, server_default='#3B82F6'),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_guest_tags_id'), 'guest_tags', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_guest_tags_name'), 'guest_tags', ['name'], unique=True)
|
||||||
|
|
||||||
|
# Create guest_tag_associations table
|
||||||
|
op.create_table(
|
||||||
|
'guest_tag_associations',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('tag_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['tag_id'], ['guest_tags.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('user_id', 'tag_id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create guest_communications table
|
||||||
|
op.create_table(
|
||||||
|
'guest_communications',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('staff_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('communication_type', sa.Enum('email', 'phone', 'sms', 'chat', 'in_person', 'other', name='communicationtype'), nullable=False),
|
||||||
|
sa.Column('direction', sa.Enum('inbound', 'outbound', name='communicationdirection'), nullable=False),
|
||||||
|
sa.Column('subject', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('booking_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('is_automated', sa.Boolean(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('communication_metadata', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['staff_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_guest_communications_id'), 'guest_communications', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_guest_communications_user_id'), 'guest_communications', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
# Create guest_segments table
|
||||||
|
op.create_table(
|
||||||
|
'guest_segments',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('criteria', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_guest_segments_id'), 'guest_segments', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_guest_segments_name'), 'guest_segments', ['name'], unique=True)
|
||||||
|
|
||||||
|
# Create guest_segment_associations table
|
||||||
|
op.create_table(
|
||||||
|
'guest_segment_associations',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('segment_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('assigned_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['segment_id'], ['guest_segments.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('user_id', 'segment_id')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop tables in reverse order
|
||||||
|
op.drop_table('guest_segment_associations')
|
||||||
|
op.drop_table('guest_segments')
|
||||||
|
op.drop_table('guest_communications')
|
||||||
|
op.drop_table('guest_tag_associations')
|
||||||
|
op.drop_table('guest_tags')
|
||||||
|
op.drop_table('guest_notes')
|
||||||
|
op.drop_table('guest_preferences')
|
||||||
|
|
||||||
|
# Remove columns from users table
|
||||||
|
op.drop_column('users', 'total_visits')
|
||||||
|
op.drop_column('users', 'last_visit_date')
|
||||||
|
op.drop_column('users', 'satisfaction_score')
|
||||||
|
op.drop_column('users', 'lifetime_value')
|
||||||
|
op.drop_column('users', 'is_vip')
|
||||||
|
|
||||||
215
Backend/alembic/versions/add_loyalty_system_tables.py
Normal file
215
Backend/alembic/versions/add_loyalty_system_tables.py
Normal file
@@ -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')
|
||||||
|
|
||||||
303
Backend/seeds_data/seed_loyalty_rewards.py
Normal file
303
Backend/seeds_data/seed_loyalty_rewards.py
Normal file
@@ -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()
|
||||||
|
|
||||||
Binary file not shown.
@@ -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()}
|
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='/api')
|
||||||
app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX)
|
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(room_routes.router, prefix='/api')
|
||||||
app.include_router(booking_routes.router, prefix='/api')
|
app.include_router(booking_routes.router, prefix='/api')
|
||||||
app.include_router(payment_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(accessibility_routes.router, prefix='/api')
|
||||||
app.include_router(faq_routes.router, prefix='/api')
|
app.include_router(faq_routes.router, prefix='/api')
|
||||||
app.include_router(chat_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(room_routes.router, prefix=settings.API_V1_PREFIX)
|
||||||
app.include_router(booking_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)
|
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(accessibility_routes.router, prefix=settings.API_V1_PREFIX)
|
||||||
app.include_router(faq_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(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='/api')
|
||||||
app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX)
|
app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX)
|
||||||
logger.info('All routes registered successfully')
|
logger.info('All routes registered successfully')
|
||||||
|
|||||||
@@ -21,4 +21,15 @@ from .system_settings import SystemSettings
|
|||||||
from .invoice import Invoice, InvoiceItem
|
from .invoice import Invoice, InvoiceItem
|
||||||
from .page_content import PageContent, PageType
|
from .page_content import PageContent, PageType
|
||||||
from .chat import Chat, ChatMessage, ChatStatus
|
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']
|
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']
|
||||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/guest_note.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/guest_note.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/guest_preference.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/guest_preference.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/guest_segment.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/guest_segment.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/guest_tag.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/guest_tag.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/loyalty_reward.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/loyalty_reward.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/loyalty_tier.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/loyalty_tier.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/referral.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/referral.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/reward_redemption.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/reward_redemption.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/user_loyalty.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/user_loyalty.cpython-312.pyc
Normal file
Binary file not shown.
36
Backend/src/models/guest_communication.py
Normal file
36
Backend/src/models/guest_communication.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Enum, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import enum
|
||||||
|
from ..config.database import Base
|
||||||
|
|
||||||
|
class CommunicationType(str, enum.Enum):
|
||||||
|
email = 'email'
|
||||||
|
phone = 'phone'
|
||||||
|
sms = 'sms'
|
||||||
|
chat = 'chat'
|
||||||
|
in_person = 'in_person'
|
||||||
|
other = 'other'
|
||||||
|
|
||||||
|
class CommunicationDirection(str, enum.Enum):
|
||||||
|
inbound = 'inbound' # Guest to hotel
|
||||||
|
outbound = 'outbound' # Hotel to guest
|
||||||
|
|
||||||
|
class GuestCommunication(Base):
|
||||||
|
__tablename__ = 'guest_communications'
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||||
|
staff_id = Column(Integer, ForeignKey('users.id'), nullable=True) # Staff member who handled communication
|
||||||
|
communication_type = Column(Enum(CommunicationType), nullable=False)
|
||||||
|
direction = Column(Enum(CommunicationDirection), nullable=False)
|
||||||
|
subject = Column(String(255), nullable=True)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True) # Related booking if applicable
|
||||||
|
is_automated = Column(Boolean, nullable=False, default=False) # If this was an automated communication
|
||||||
|
communication_metadata = Column(Text, nullable=True) # JSON string for additional data
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
user = relationship('User', foreign_keys=[user_id], back_populates='guest_communications')
|
||||||
|
staff = relationship('User', foreign_keys=[staff_id])
|
||||||
|
booking = relationship('Booking')
|
||||||
|
|
||||||
19
Backend/src/models/guest_note.py
Normal file
19
Backend/src/models/guest_note.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from ..config.database import Base
|
||||||
|
|
||||||
|
class GuestNote(Base):
|
||||||
|
__tablename__ = 'guest_notes'
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||||
|
created_by = Column(Integer, ForeignKey('users.id'), nullable=False) # Staff/admin who created the note
|
||||||
|
note = Column(Text, nullable=False)
|
||||||
|
is_important = Column(Boolean, nullable=False, default=False)
|
||||||
|
is_private = Column(Boolean, nullable=False, default=False) # Private notes only visible to admins
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
user = relationship('User', foreign_keys=[user_id], back_populates='guest_notes')
|
||||||
|
creator = relationship('User', foreign_keys=[created_by])
|
||||||
|
|
||||||
40
Backend/src/models/guest_preference.py
Normal file
40
Backend/src/models/guest_preference.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey, DateTime, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from ..config.database import Base
|
||||||
|
|
||||||
|
class GuestPreference(Base):
|
||||||
|
__tablename__ = 'guest_preferences'
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Room preferences
|
||||||
|
preferred_room_location = Column(String(100), nullable=True) # e.g., "high floor", "near elevator", "ocean view"
|
||||||
|
preferred_floor = Column(Integer, nullable=True)
|
||||||
|
preferred_room_type_id = Column(Integer, ForeignKey('room_types.id'), nullable=True)
|
||||||
|
|
||||||
|
# Amenity preferences
|
||||||
|
preferred_amenities = Column(JSON, nullable=True) # Array of amenity names
|
||||||
|
|
||||||
|
# Special requests
|
||||||
|
special_requests = Column(Text, nullable=True) # General special requests
|
||||||
|
|
||||||
|
# Service preferences
|
||||||
|
preferred_services = Column(JSON, nullable=True) # Array of service preferences
|
||||||
|
|
||||||
|
# Communication preferences
|
||||||
|
preferred_contact_method = Column(String(50), nullable=True) # email, phone, sms
|
||||||
|
preferred_language = Column(String(10), nullable=True, default='en')
|
||||||
|
|
||||||
|
# Dietary preferences
|
||||||
|
dietary_restrictions = Column(JSON, nullable=True) # Array of dietary restrictions
|
||||||
|
|
||||||
|
# Other preferences
|
||||||
|
additional_preferences = Column(JSON, nullable=True) # Flexible JSON for other preferences
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
user = relationship('User', back_populates='guest_preferences')
|
||||||
|
preferred_room_type = relationship('RoomType')
|
||||||
|
|
||||||
26
Backend/src/models/guest_segment.py
Normal file
26
Backend/src/models/guest_segment.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Boolean, JSON, Table
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from ..config.database import Base
|
||||||
|
|
||||||
|
# Association table for many-to-many relationship between users and segments
|
||||||
|
guest_segment_association = Table(
|
||||||
|
'guest_segment_associations',
|
||||||
|
Base.metadata,
|
||||||
|
Column('user_id', Integer, ForeignKey('users.id'), primary_key=True),
|
||||||
|
Column('segment_id', Integer, ForeignKey('guest_segments.id'), primary_key=True),
|
||||||
|
Column('assigned_at', DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
class GuestSegment(Base):
|
||||||
|
__tablename__ = 'guest_segments'
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
name = Column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
criteria = Column(JSON, nullable=True) # JSON object defining segment criteria
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
users = relationship('User', secondary=guest_segment_association, back_populates='guest_segments')
|
||||||
|
|
||||||
24
Backend/src/models/guest_tag.py
Normal file
24
Backend/src/models/guest_tag.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Table
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from ..config.database import Base
|
||||||
|
|
||||||
|
# Association table for many-to-many relationship between users and tags
|
||||||
|
guest_tag_association = Table(
|
||||||
|
'guest_tag_associations',
|
||||||
|
Base.metadata,
|
||||||
|
Column('user_id', Integer, ForeignKey('users.id'), primary_key=True),
|
||||||
|
Column('tag_id', Integer, ForeignKey('guest_tags.id'), primary_key=True),
|
||||||
|
Column('created_at', DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
class GuestTag(Base):
|
||||||
|
__tablename__ = 'guest_tags'
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
name = Column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
color = Column(String(7), nullable=True, default='#3B82F6') # Hex color code
|
||||||
|
description = Column(String(255), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
users = relationship('User', secondary=guest_tag_association, back_populates='guest_tags')
|
||||||
|
|
||||||
40
Backend/src/models/loyalty_point_transaction.py
Normal file
40
Backend/src/models/loyalty_point_transaction.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, ForeignKey, Enum
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import enum
|
||||||
|
from ..config.database import Base
|
||||||
|
|
||||||
|
class TransactionType(str, enum.Enum):
|
||||||
|
earned = 'earned'
|
||||||
|
redeemed = 'redeemed'
|
||||||
|
expired = 'expired'
|
||||||
|
bonus = 'bonus'
|
||||||
|
adjustment = 'adjustment'
|
||||||
|
|
||||||
|
class TransactionSource(str, enum.Enum):
|
||||||
|
booking = 'booking'
|
||||||
|
referral = 'referral'
|
||||||
|
birthday = 'birthday'
|
||||||
|
anniversary = 'anniversary'
|
||||||
|
redemption = 'redemption'
|
||||||
|
promotion = 'promotion'
|
||||||
|
manual = 'manual'
|
||||||
|
|
||||||
|
class LoyaltyPointTransaction(Base):
|
||||||
|
__tablename__ = 'loyalty_point_transactions'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
user_loyalty_id = Column(Integer, ForeignKey('user_loyalty.id'), nullable=False, index=True)
|
||||||
|
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True, index=True)
|
||||||
|
transaction_type = Column(Enum(TransactionType), nullable=False, index=True)
|
||||||
|
source = Column(Enum(TransactionSource), nullable=False)
|
||||||
|
points = Column(Integer, nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
expires_at = Column(DateTime, nullable=True)
|
||||||
|
reference_number = Column(String(100), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user_loyalty = relationship('UserLoyalty', back_populates='point_transactions')
|
||||||
|
booking = relationship('Booking', foreign_keys=[booking_id])
|
||||||
|
|
||||||
89
Backend/src/models/loyalty_reward.py
Normal file
89
Backend/src/models/loyalty_reward.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, Enum, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
import enum
|
||||||
|
from ..config.database import Base
|
||||||
|
|
||||||
|
class RewardType(str, enum.Enum):
|
||||||
|
discount = 'discount'
|
||||||
|
room_upgrade = 'room_upgrade'
|
||||||
|
amenity = 'amenity'
|
||||||
|
cashback = 'cashback'
|
||||||
|
voucher = 'voucher'
|
||||||
|
|
||||||
|
class RewardStatus(str, enum.Enum):
|
||||||
|
available = 'available'
|
||||||
|
redeemed = 'redeemed'
|
||||||
|
expired = 'expired'
|
||||||
|
|
||||||
|
class LoyaltyReward(Base):
|
||||||
|
__tablename__ = 'loyalty_rewards'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
reward_type = Column(Enum(RewardType), nullable=False, index=True)
|
||||||
|
points_cost = Column(Integer, nullable=False)
|
||||||
|
discount_percentage = Column(Numeric(5, 2), nullable=True)
|
||||||
|
discount_amount = Column(Numeric(10, 2), nullable=True)
|
||||||
|
max_discount_amount = Column(Numeric(10, 2), nullable=True)
|
||||||
|
applicable_tier_id = Column(Integer, ForeignKey('loyalty_tiers.id'), nullable=True)
|
||||||
|
min_booking_amount = Column(Numeric(10, 2), nullable=True)
|
||||||
|
icon = Column(String(255), nullable=True)
|
||||||
|
image = Column(String(255), nullable=True)
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
stock_quantity = Column(Integer, nullable=True)
|
||||||
|
redeemed_count = Column(Integer, nullable=False, default=0)
|
||||||
|
valid_from = Column(DateTime, nullable=True)
|
||||||
|
valid_until = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
applicable_tier = relationship('LoyaltyTier', foreign_keys=[applicable_tier_id])
|
||||||
|
redemptions = relationship('RewardRedemption', back_populates='reward')
|
||||||
|
|
||||||
|
def is_available(self, user_tier_id=None, user_tier_min_points=None):
|
||||||
|
"""
|
||||||
|
Check if reward is available for user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_tier_id: User's current tier ID
|
||||||
|
user_tier_min_points: User's tier minimum points (for tier level comparison)
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
if not self.is_active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If reward has a tier requirement, check if user's tier qualifies
|
||||||
|
if self.applicable_tier_id:
|
||||||
|
if user_tier_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Exact tier match - always allowed
|
||||||
|
if user_tier_id == self.applicable_tier_id:
|
||||||
|
pass # Continue to other checks
|
||||||
|
# If we have tier point info, allow users with higher tier level (more points)
|
||||||
|
elif user_tier_min_points is not None and hasattr(self, '_required_tier_min_points'):
|
||||||
|
# User tier must have equal or more minimum points (higher tier)
|
||||||
|
if user_tier_min_points < self._required_tier_min_points:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Without tier point info, use exact match only
|
||||||
|
# Convert to int for comparison to avoid type mismatch issues
|
||||||
|
try:
|
||||||
|
if int(user_tier_id) != int(self.applicable_tier_id):
|
||||||
|
return False
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.valid_from and now < self.valid_from:
|
||||||
|
return False
|
||||||
|
if self.valid_until and now > self.valid_until:
|
||||||
|
return False
|
||||||
|
if self.stock_quantity is not None and self.redeemed_count >= self.stock_quantity:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
32
Backend/src/models/loyalty_tier.py
Normal file
32
Backend/src/models/loyalty_tier.py
Normal file
@@ -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')
|
||||||
|
|
||||||
30
Backend/src/models/referral.py
Normal file
30
Backend/src/models/referral.py
Normal file
@@ -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])
|
||||||
|
|
||||||
33
Backend/src/models/reward_redemption.py
Normal file
33
Backend/src/models/reward_redemption.py
Normal file
@@ -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])
|
||||||
|
|
||||||
@@ -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 sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from ..config.database import Base
|
from ..config.database import Base
|
||||||
@@ -18,6 +18,14 @@ class User(Base):
|
|||||||
mfa_enabled = Column(Boolean, nullable=False, default=False)
|
mfa_enabled = Column(Boolean, nullable=False, default=False)
|
||||||
mfa_secret = Column(String(255), nullable=True)
|
mfa_secret = Column(String(255), nullable=True)
|
||||||
mfa_backup_codes = Column(Text, 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)
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
role = relationship('Role', back_populates='users')
|
role = relationship('Role', back_populates='users')
|
||||||
@@ -30,3 +38,12 @@ class User(Base):
|
|||||||
service_bookings = relationship('ServiceBooking', back_populates='user')
|
service_bookings = relationship('ServiceBooking', back_populates='user')
|
||||||
visitor_chats = relationship('Chat', foreign_keys='Chat.visitor_id', back_populates='visitor')
|
visitor_chats = relationship('Chat', foreign_keys='Chat.visitor_id', back_populates='visitor')
|
||||||
staff_chats = relationship('Chat', foreign_keys='Chat.staff_id', back_populates='staff')
|
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')
|
||||||
31
Backend/src/models/user_loyalty.py
Normal file
31
Backend/src/models/user_loyalty.py
Normal file
@@ -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')
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/routes/__pycache__/loyalty_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/loyalty_routes.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -15,10 +15,13 @@ from ..models.room import Room, RoomStatus
|
|||||||
from ..models.room_type import RoomType
|
from ..models.room_type import RoomType
|
||||||
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
||||||
from ..models.service_usage import ServiceUsage
|
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 ..services.room_service import normalize_images, get_base_url
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from ..utils.mailer import send_email
|
from ..utils.mailer import send_email
|
||||||
from ..utils.email_templates import booking_confirmation_email_template, booking_status_changed_email_template
|
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'])
|
router = APIRouter(prefix='/bookings', tags=['bookings'])
|
||||||
|
|
||||||
def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str:
|
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')
|
notes = booking_data.get('notes')
|
||||||
payment_method = booking_data.get('payment_method', 'cash')
|
payment_method = booking_data.get('payment_method', 'cash')
|
||||||
promotion_code = booking_data.get('promotion_code')
|
promotion_code = booking_data.get('promotion_code')
|
||||||
|
referral_code = booking_data.get('referral_code')
|
||||||
invoice_info = booking_data.get('invoice_info', {})
|
invoice_info = booking_data.get('invoice_info', {})
|
||||||
missing_fields = []
|
missing_fields = []
|
||||||
if not room_id:
|
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.add(booking)
|
||||||
db.flush()
|
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']:
|
if payment_method in ['stripe', 'paypal']:
|
||||||
from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType
|
from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType
|
||||||
if payment_method == 'stripe':
|
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)
|
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:
|
if guest_email:
|
||||||
await send_email(to=guest_email, subject=f'Booking Confirmed - {booking.booking_number}', html=email_html)
|
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:
|
elif booking.status == BookingStatus.cancelled:
|
||||||
guest_name = booking.user.full_name if booking.user else 'Guest'
|
guest_name = booking.user.full_name if booking.user else 'Guest'
|
||||||
guest_email = booking.user.email if booking.user else None
|
guest_email = booking.user.email if booking.user else None
|
||||||
|
|||||||
564
Backend/src/routes/guest_profile_routes.py
Normal file
564
Backend/src/routes/guest_profile_routes.py
Normal file
@@ -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))
|
||||||
|
|
||||||
981
Backend/src/routes/loyalty_routes.py
Normal file
981
Backend/src/routes/loyalty_routes.py
Normal file
@@ -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))
|
||||||
|
|
||||||
@@ -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 ..utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template
|
||||||
from ..services.stripe_service import StripeService
|
from ..services.stripe_service import StripeService
|
||||||
from ..services.paypal_service import PayPalService
|
from ..services.paypal_service import PayPalService
|
||||||
|
from ..services.loyalty_service import LoyaltyService
|
||||||
router = APIRouter(prefix='/payments', tags=['payments'])
|
router = APIRouter(prefix='/payments', tags=['payments'])
|
||||||
|
|
||||||
async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str='Payment failed or canceled'):
|
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.add(payment)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(payment)
|
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:
|
if payment.payment_status == PaymentStatus.completed and booking.user:
|
||||||
try:
|
try:
|
||||||
from ..models.system_settings import SystemSettings
|
from ..models.system_settings import SystemSettings
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/services/__pycache__/loyalty_service.cpython-312.pyc
Normal file
BIN
Backend/src/services/__pycache__/loyalty_service.cpython-312.pyc
Normal file
Binary file not shown.
262
Backend/src/services/guest_profile_service.py
Normal file
262
Backend/src/services/guest_profile_service.py
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
635
Backend/src/services/loyalty_service.py
Normal file
635
Backend/src/services/loyalty_service.py
Normal file
@@ -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()
|
||||||
|
|
||||||
698
ENTERPRISE_FEATURES_ROADMAP.md
Normal file
698
ENTERPRISE_FEATURES_ROADMAP.md
Normal file
@@ -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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- ### 3. **Comprehensive Loyalty Program** ⭐ HIGH PRIORITY
|
||||||
|
|
||||||
|
#### Backend Models:
|
||||||
|
- `LoyaltyTier` (Bronze, Silver, Gold, Platinum)
|
||||||
|
- `LoyaltyPoints` (accumulation and redemption)
|
||||||
|
- `LoyaltyRewards` (discounts, upgrades, amenities)
|
||||||
|
- `ReferralProgram` (referral tracking and rewards)
|
||||||
|
|
||||||
|
#### Features:
|
||||||
|
- Points accumulation system
|
||||||
|
- Tier progression tracking
|
||||||
|
- Points redemption for bookings/discounts
|
||||||
|
- Special member-only rates
|
||||||
|
- Birthday rewards
|
||||||
|
- Anniversary bonuses
|
||||||
|
- Referral rewards
|
||||||
|
- Points expiration management
|
||||||
|
|
||||||
|
#### Frontend:
|
||||||
|
- Customer loyalty dashboard
|
||||||
|
- Points balance and history
|
||||||
|
- Rewards catalog
|
||||||
|
- Referral tracking -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ const PayPalReturnPage = lazy(() => import('./pages/customer/PayPalReturnPage'))
|
|||||||
const PayPalCancelPage = lazy(() => import('./pages/customer/PayPalCancelPage'));
|
const PayPalCancelPage = lazy(() => import('./pages/customer/PayPalCancelPage'));
|
||||||
const InvoicePage = lazy(() => import('./pages/customer/InvoicePage'));
|
const InvoicePage = lazy(() => import('./pages/customer/InvoicePage'));
|
||||||
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
|
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
|
||||||
|
const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage'));
|
||||||
const AboutPage = lazy(() => import('./pages/AboutPage'));
|
const AboutPage = lazy(() => import('./pages/AboutPage'));
|
||||||
const ContactPage = lazy(() => import('./pages/ContactPage'));
|
const ContactPage = lazy(() => import('./pages/ContactPage'));
|
||||||
const PrivacyPolicyPage = lazy(() => import('./pages/PrivacyPolicyPage'));
|
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 InvoiceManagementPage = lazy(() => import('./pages/admin/InvoiceManagementPage'));
|
||||||
const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage'));
|
const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage'));
|
||||||
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
|
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
|
||||||
|
const GuestProfilePage = lazy(() => import('./pages/admin/GuestProfilePage'));
|
||||||
const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage'));
|
const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage'));
|
||||||
const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard'));
|
const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard'));
|
||||||
const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage'));
|
const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage'));
|
||||||
const BusinessDashboardPage = lazy(() => import('./pages/admin/BusinessDashboardPage'));
|
const BusinessDashboardPage = lazy(() => import('./pages/admin/BusinessDashboardPage'));
|
||||||
const SettingsPage = lazy(() => import('./pages/admin/SettingsPage'));
|
const SettingsPage = lazy(() => import('./pages/admin/SettingsPage'));
|
||||||
const ReceptionDashboardPage = lazy(() => import('./pages/admin/ReceptionDashboardPage'));
|
const ReceptionDashboardPage = lazy(() => import('./pages/admin/ReceptionDashboardPage'));
|
||||||
|
const LoyaltyManagementPage = lazy(() => import('./pages/admin/LoyaltyManagementPage'));
|
||||||
|
|
||||||
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
|
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
|
||||||
const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage'));
|
const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage'));
|
||||||
@@ -280,6 +283,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="loyalty"
|
||||||
|
element={
|
||||||
|
<CustomerRoute>
|
||||||
|
<LoyaltyPage />
|
||||||
|
</CustomerRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{}
|
{}
|
||||||
@@ -338,6 +349,14 @@ function App() {
|
|||||||
path="payments"
|
path="payments"
|
||||||
element={<PaymentManagementPage />}
|
element={<PaymentManagementPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="loyalty"
|
||||||
|
element={<LoyaltyManagementPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="guest-profiles"
|
||||||
|
element={<GuestProfilePage />}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{}
|
{}
|
||||||
@@ -374,6 +393,14 @@ function App() {
|
|||||||
path="chats"
|
path="chats"
|
||||||
element={<ChatManagementPage />}
|
element={<ChatManagementPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="loyalty"
|
||||||
|
element={<LoyaltyManagementPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="guest-profiles"
|
||||||
|
element={<GuestProfilePage />}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Accountant Routes */}
|
{/* Accountant Routes */}
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
|
|||||||
const [promotionDiscount, setPromotionDiscount] = useState(0);
|
const [promotionDiscount, setPromotionDiscount] = useState(0);
|
||||||
const [validatingPromotion, setValidatingPromotion] = useState(false);
|
const [validatingPromotion, setValidatingPromotion] = useState(false);
|
||||||
const [promotionError, setPromotionError] = useState<string | null>(null);
|
const [promotionError, setPromotionError] = useState<string | null>(null);
|
||||||
|
const [referralCode, setReferralCode] = useState('');
|
||||||
|
const [referralError, setReferralError] = useState<string | null>(null);
|
||||||
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
|
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
|
||||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||||
const [paymentMethod, setPaymentMethod] = useState<'stripe' | 'paypal' | 'cash' | null>(null);
|
const [paymentMethod, setPaymentMethod] = useState<'stripe' | 'paypal' | 'cash' | null>(null);
|
||||||
@@ -123,6 +125,8 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
|
|||||||
setPromotionCode('');
|
setPromotionCode('');
|
||||||
setSelectedPromotion(null);
|
setSelectedPromotion(null);
|
||||||
setPromotionDiscount(0);
|
setPromotionDiscount(0);
|
||||||
|
setReferralCode('');
|
||||||
|
setReferralError(null);
|
||||||
setShowPaymentModal(false);
|
setShowPaymentModal(false);
|
||||||
setPaymentMethod(null);
|
setPaymentMethod(null);
|
||||||
setCreatedBookingId(null);
|
setCreatedBookingId(null);
|
||||||
@@ -376,6 +380,7 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
|
|||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
})),
|
})),
|
||||||
promotion_code: selectedPromotion?.code || undefined,
|
promotion_code: selectedPromotion?.code || undefined,
|
||||||
|
referral_code: referralCode.trim() || undefined,
|
||||||
invoice_info: (data as any).invoiceInfo ? {
|
invoice_info: (data as any).invoiceInfo ? {
|
||||||
company_name: (data as any).invoiceInfo.company_name || undefined,
|
company_name: (data as any).invoiceInfo.company_name || undefined,
|
||||||
company_address: (data as any).invoiceInfo.company_address || undefined,
|
company_address: (data as any).invoiceInfo.company_address || undefined,
|
||||||
@@ -823,6 +828,32 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
{promotionError && <p className="text-xs text-red-400 mt-2">{promotionError}</p>}
|
{promotionError && <p className="text-xs text-red-400 mt-2">{promotionError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Referral Code */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-[#d4af37]/20">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Users className="w-4 h-4 text-[#d4af37]" />
|
||||||
|
<h4 className="text-sm font-semibold text-white">Referral Code (Optional)</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={referralCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
You'll both earn bonus points when your booking is confirmed!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{referralError && <p className="text-xs text-red-400 mt-2">{referralError}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Phone,
|
Phone,
|
||||||
Mail,
|
Mail,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Star,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||||
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
||||||
@@ -280,6 +281,18 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
<span className="font-light tracking-wide">My Bookings</span>
|
<span className="font-light tracking-wide">My Bookings</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/loyalty"
|
||||||
|
onClick={() => 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]"
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
<span className="font-light tracking-wide">Loyalty Program</span>
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{userInfo?.role === 'admin' && (
|
{userInfo?.role === 'admin' && (
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
LogIn,
|
LogIn,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
X
|
X,
|
||||||
|
Award,
|
||||||
|
User
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import useAuthStore from '../../store/useAuthStore';
|
import useAuthStore from '../../store/useAuthStore';
|
||||||
|
|
||||||
@@ -92,6 +94,16 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
label: 'Users'
|
label: 'Users'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/guest-profiles',
|
||||||
|
icon: User,
|
||||||
|
label: 'Guest Profiles'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/loyalty',
|
||||||
|
icon: Award,
|
||||||
|
label: 'Loyalty Program'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/business',
|
path: '/admin/business',
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
@@ -123,7 +135,7 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
|||||||
|
|
||||||
if (location.pathname === path) return true;
|
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;
|
return location.pathname === path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
MessageCircle
|
MessageCircle,
|
||||||
|
Award,
|
||||||
|
Users
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import useAuthStore from '../../store/useAuthStore';
|
import useAuthStore from '../../store/useAuthStore';
|
||||||
import { useChatNotifications } from '../../contexts/ChatNotificationContext';
|
import { useChatNotifications } from '../../contexts/ChatNotificationContext';
|
||||||
@@ -104,6 +106,16 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
|
|||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
label: 'Payments'
|
label: 'Payments'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/staff/loyalty',
|
||||||
|
icon: Award,
|
||||||
|
label: 'Loyalty Program'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/staff/guest-profiles',
|
||||||
|
icon: Users,
|
||||||
|
label: 'Guest Profiles'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/staff/chats',
|
path: '/staff/chats',
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
|
|||||||
1319
Frontend/src/pages/admin/GuestProfilePage.tsx
Normal file
1319
Frontend/src/pages/admin/GuestProfilePage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1161
Frontend/src/pages/admin/LoyaltyManagementPage.tsx
Normal file
1161
Frontend/src/pages/admin/LoyaltyManagementPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
export { default as DashboardPage } from './DashboardPage';
|
export { default as DashboardPage } from './DashboardPage';
|
||||||
export { default as RoomManagementPage } from './RoomManagementPage';
|
export { default as RoomManagementPage } from './RoomManagementPage';
|
||||||
export { default as UserManagementPage } from './UserManagementPage';
|
export { default as UserManagementPage } from './UserManagementPage';
|
||||||
|
export { default as GuestProfilePage } from './GuestProfilePage';
|
||||||
export { default as BookingManagementPage } from './BookingManagementPage';
|
export { default as BookingManagementPage } from './BookingManagementPage';
|
||||||
export { default as PaymentManagementPage } from './PaymentManagementPage';
|
export { default as PaymentManagementPage } from './PaymentManagementPage';
|
||||||
export { default as ServiceManagementPage } from './ServiceManagementPage';
|
export { default as ServiceManagementPage } from './ServiceManagementPage';
|
||||||
|
|||||||
709
Frontend/src/pages/customer/LoyaltyPage.tsx
Normal file
709
Frontend/src/pages/customer/LoyaltyPage.tsx
Normal file
@@ -0,0 +1,709 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Star,
|
||||||
|
Gift,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Calendar,
|
||||||
|
Award,
|
||||||
|
ArrowRight,
|
||||||
|
Copy,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Target,
|
||||||
|
History,
|
||||||
|
CreditCard
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { Loading, EmptyState } from '../../components/common';
|
||||||
|
import loyaltyService, {
|
||||||
|
UserLoyaltyStatus,
|
||||||
|
PointsTransaction,
|
||||||
|
LoyaltyReward,
|
||||||
|
RewardRedemption,
|
||||||
|
Referral
|
||||||
|
} from '../../services/api/loyaltyService';
|
||||||
|
import { formatDate } from '../../utils/format';
|
||||||
|
import { useAsync } from '../../hooks/useAsync';
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'rewards' | 'history' | 'referrals';
|
||||||
|
|
||||||
|
const LoyaltyPage: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
|
const [loyaltyStatus, setLoyaltyStatus] = useState<UserLoyaltyStatus | null>(null);
|
||||||
|
const [rewards, setRewards] = useState<LoyaltyReward[]>([]);
|
||||||
|
const [redemptions, setRedemptions] = useState<RewardRedemption[]>([]);
|
||||||
|
const [referrals, setReferrals] = useState<Referral[]>([]);
|
||||||
|
const [transactions, setTransactions] = useState<PointsTransaction[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isDisabled, setIsDisabled] = useState(false);
|
||||||
|
const [redeeming, setRedeeming] = useState<number | null>(null);
|
||||||
|
const [copiedCode, setCopiedCode] = useState(false);
|
||||||
|
const [birthday, setBirthday] = useState('');
|
||||||
|
const [anniversaryDate, setAnniversaryDate] = useState('');
|
||||||
|
const [showRedemptionModal, setShowRedemptionModal] = useState(false);
|
||||||
|
const [redemptionData, setRedemptionData] = useState<{ code: string; rewardName: string; pointsUsed: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLoyaltyStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'rewards') {
|
||||||
|
fetchRewards();
|
||||||
|
fetchRedemptions();
|
||||||
|
} else if (activeTab === 'history') {
|
||||||
|
fetchTransactions();
|
||||||
|
} else if (activeTab === 'referrals') {
|
||||||
|
fetchReferrals();
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const fetchLoyaltyStatus = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setIsDisabled(false);
|
||||||
|
const response = await loyaltyService.getMyStatus();
|
||||||
|
setLoyaltyStatus(response.data);
|
||||||
|
if (response.data.birthday) {
|
||||||
|
setBirthday(response.data.birthday);
|
||||||
|
}
|
||||||
|
if (response.data.anniversary_date) {
|
||||||
|
setAnniversaryDate(response.data.anniversary_date);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Check if the error is about loyalty program being disabled
|
||||||
|
// FastAPI returns detail in error.response.data.detail
|
||||||
|
// The apiClient might transform it, so check both locations
|
||||||
|
const statusCode = error.response?.status || error.status;
|
||||||
|
const errorData = error.response?.data || {};
|
||||||
|
const errorDetail = errorData.detail || errorData.message || '';
|
||||||
|
const errorMessage = error.message || '';
|
||||||
|
|
||||||
|
// Check if it's a 503 error (service unavailable) which indicates disabled
|
||||||
|
// OR if the error message/detail explicitly mentions "disabled"
|
||||||
|
// For 503 errors from loyalty status endpoint, always treat as disabled
|
||||||
|
const isDisabledError =
|
||||||
|
statusCode === 503 ||
|
||||||
|
(typeof errorDetail === 'string' && errorDetail.toLowerCase().includes('disabled')) ||
|
||||||
|
(typeof errorMessage === 'string' && errorMessage.toLowerCase().includes('disabled'));
|
||||||
|
|
||||||
|
if (isDisabledError) {
|
||||||
|
setIsDisabled(true);
|
||||||
|
setLoyaltyStatus(null);
|
||||||
|
// Don't show toast for disabled state - it's not an error, just disabled
|
||||||
|
// Return early to prevent any toast from showing
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show toast for actual errors (not disabled state)
|
||||||
|
toast.error(error.message || 'Failed to load loyalty status');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRewards = async () => {
|
||||||
|
try {
|
||||||
|
const response = await loyaltyService.getAvailableRewards();
|
||||||
|
setRewards(response.data.rewards);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to load rewards');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRedemptions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await loyaltyService.getMyRedemptions();
|
||||||
|
setRedemptions(response.data.redemptions);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to load redemptions');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTransactions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await loyaltyService.getPointsHistory(1, 50);
|
||||||
|
setTransactions(response.data.transactions);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to load transaction history');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchReferrals = async () => {
|
||||||
|
try {
|
||||||
|
const response = await loyaltyService.getMyReferrals();
|
||||||
|
setReferrals(response.data.referrals);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to load referrals');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedeem = async (rewardId: number) => {
|
||||||
|
try {
|
||||||
|
setRedeeming(rewardId);
|
||||||
|
const response = await loyaltyService.redeemReward(rewardId);
|
||||||
|
|
||||||
|
// Find the reward name for display
|
||||||
|
const redeemedReward = rewards.find(r => r.id === rewardId);
|
||||||
|
const rewardName = redeemedReward?.name || 'Reward';
|
||||||
|
|
||||||
|
// Show redemption success modal with code
|
||||||
|
if (response.data?.code) {
|
||||||
|
setRedemptionData({
|
||||||
|
code: response.data.code,
|
||||||
|
rewardName: rewardName,
|
||||||
|
pointsUsed: response.data.points_used || redeemedReward?.points_cost || 0
|
||||||
|
});
|
||||||
|
setShowRedemptionModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(response.message || 'Reward redeemed successfully!');
|
||||||
|
await Promise.all([fetchLoyaltyStatus(), fetchRewards(), fetchRedemptions()]);
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.response?.data?.detail || error.message || 'Failed to redeem reward';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setRedeeming(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyReferralCode = () => {
|
||||||
|
if (loyaltyStatus?.referral_code) {
|
||||||
|
navigator.clipboard.writeText(loyaltyStatus.referral_code);
|
||||||
|
setCopiedCode(true);
|
||||||
|
toast.success('Referral code copied to clipboard!');
|
||||||
|
setTimeout(() => setCopiedCode(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateInfo = async () => {
|
||||||
|
try {
|
||||||
|
await loyaltyService.updateMyStatus({
|
||||||
|
birthday: birthday || undefined,
|
||||||
|
anniversary_date: anniversaryDate || undefined,
|
||||||
|
});
|
||||||
|
toast.success('Information updated successfully!');
|
||||||
|
await fetchLoyaltyStatus();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to update information');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTierColor = (level?: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'bronze':
|
||||||
|
return 'from-orange-500 to-amber-600';
|
||||||
|
case 'silver':
|
||||||
|
return 'from-gray-400 to-gray-500';
|
||||||
|
case 'gold':
|
||||||
|
return 'from-yellow-400 to-yellow-600';
|
||||||
|
case 'platinum':
|
||||||
|
return 'from-purple-400 to-indigo-600';
|
||||||
|
default:
|
||||||
|
return 'from-gray-400 to-gray-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTransactionIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'earned':
|
||||||
|
return <TrendingUp className="w-5 h-5 text-green-600" />;
|
||||||
|
case 'redeemed':
|
||||||
|
return <Gift className="w-5 h-5 text-blue-600" />;
|
||||||
|
case 'expired':
|
||||||
|
return <Clock className="w-5 h-5 text-red-600" />;
|
||||||
|
case 'bonus':
|
||||||
|
return <Award className="w-5 h-5 text-purple-600" />;
|
||||||
|
default:
|
||||||
|
return <History className="w-5 h-5 text-gray-600" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !loyaltyStatus && !isDisabled) {
|
||||||
|
return <Loading fullScreen text="Loading loyalty program..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDisabled) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
|
<EmptyState
|
||||||
|
icon={Star}
|
||||||
|
title="Loyalty Program Disabled"
|
||||||
|
description="The loyalty program is currently disabled by the administrator. Please check back later."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loyaltyStatus) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
|
<EmptyState
|
||||||
|
icon={Star}
|
||||||
|
title="Unable to load loyalty status"
|
||||||
|
description="Please try again later"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Loyalty Program</h1>
|
||||||
|
<p className="text-gray-600">Earn points, unlock rewards, and enjoy exclusive benefits</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Overview Card */}
|
||||||
|
<div className={`bg-gradient-to-br ${getTierColor(loyaltyStatus.tier?.level)} rounded-2xl shadow-xl p-8 mb-8 text-white`}>
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Award className="w-8 h-8" />
|
||||||
|
<h2 className="text-2xl font-bold">{loyaltyStatus.tier?.name} Member</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-white/90">{loyaltyStatus.tier?.description}</p>
|
||||||
|
</div>
|
||||||
|
{loyaltyStatus.tier?.icon && (
|
||||||
|
<div className="text-6xl opacity-50">{loyaltyStatus.tier.icon}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||||
|
<div className="text-sm opacity-90 mb-1">Available Points</div>
|
||||||
|
<div className="text-3xl font-bold">{loyaltyStatus.available_points.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||||
|
<div className="text-sm opacity-90 mb-1">Lifetime Points</div>
|
||||||
|
<div className="text-3xl font-bold">{loyaltyStatus.lifetime_points.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||||
|
<div className="text-sm opacity-90 mb-1">Earn Rate</div>
|
||||||
|
<div className="text-3xl font-bold">{loyaltyStatus.tier?.points_earn_rate}x</div>
|
||||||
|
<div className="text-sm opacity-90">points per dollar</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loyaltyStatus.next_tier && (
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm opacity-90">Progress to {loyaltyStatus.next_tier.name}</span>
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{loyaltyStatus.points_needed_for_next_tier?.toLocaleString()} points needed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-white/20 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className="bg-white h-3 rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(
|
||||||
|
100,
|
||||||
|
((loyaltyStatus.lifetime_points / loyaltyStatus.next_tier.min_points) * 100)
|
||||||
|
)}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm mb-6">
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="flex -mb-px">
|
||||||
|
{[
|
||||||
|
{ id: 'overview', label: 'Overview', icon: Star },
|
||||||
|
{ id: 'rewards', label: 'Rewards', icon: Gift },
|
||||||
|
{ id: 'history', label: 'History', icon: History },
|
||||||
|
{ id: 'referrals', label: 'Referrals', icon: Users },
|
||||||
|
].map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as Tab)}
|
||||||
|
className={`flex items-center gap-2 px-6 py-4 font-medium text-sm border-b-2 transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-indigo-600 text-indigo-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Overview Tab */}
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Benefits */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">Tier Benefits</h3>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<p className="text-gray-700 whitespace-pre-line">
|
||||||
|
{loyaltyStatus.tier?.benefits || 'No benefits listed'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Referral Code */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">Your Referral Code</h3>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 bg-gray-50 rounded-lg p-4 flex items-center justify-between">
|
||||||
|
<code className="text-lg font-mono font-bold text-gray-900">
|
||||||
|
{loyaltyStatus.referral_code}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyReferralCode}
|
||||||
|
className="ml-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{copiedCode ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
|
Share your code with friends! You both get bonus points when they make their first booking.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 mt-1">
|
||||||
|
Referrals: {loyaltyStatus.referral_count}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Personal Info */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">Personal Information</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Birthday (for birthday rewards)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={birthday}
|
||||||
|
onChange={(e) => setBirthday(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Anniversary Date (for anniversary bonuses)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={anniversaryDate}
|
||||||
|
onChange={(e) => setAnniversaryDate(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateInfo}
|
||||||
|
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
Update Information
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rewards Tab */}
|
||||||
|
{activeTab === 'rewards' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Available Rewards</h3>
|
||||||
|
{rewards.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Gift}
|
||||||
|
title="No rewards available"
|
||||||
|
description="Check back later for new rewards"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{rewards.map((reward) => (
|
||||||
|
<div
|
||||||
|
key={reward.id}
|
||||||
|
className="border border-gray-200 rounded-lg p-4 hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
{reward.image && (
|
||||||
|
<img
|
||||||
|
src={reward.image}
|
||||||
|
alt={reward.name}
|
||||||
|
className="w-full h-32 object-cover rounded-lg mb-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-1">{reward.name}</h4>
|
||||||
|
<p className="text-sm text-gray-600 mb-3">{reward.description}</p>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-lg font-bold text-indigo-600">
|
||||||
|
{reward.points_cost} points
|
||||||
|
</span>
|
||||||
|
{reward.stock_remaining !== null && (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{reward.stock_remaining} left
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRedeem(reward.id)}
|
||||||
|
disabled={!reward.is_available || !reward.can_afford || redeeming === reward.id}
|
||||||
|
className={`w-full py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
reward.is_available && reward.can_afford && redeeming !== reward.id
|
||||||
|
? 'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||||
|
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{redeeming === reward.id
|
||||||
|
? 'Redeeming...'
|
||||||
|
: !reward.can_afford
|
||||||
|
? 'Insufficient Points'
|
||||||
|
: !reward.is_available
|
||||||
|
? 'Not Available'
|
||||||
|
: 'Redeem Now'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">My Redemptions</h3>
|
||||||
|
{redemptions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={CreditCard}
|
||||||
|
title="No redemptions yet"
|
||||||
|
description="Redeem rewards to see them here"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{redemptions.map((redemption) => (
|
||||||
|
<div
|
||||||
|
key={redemption.id}
|
||||||
|
className="border border-gray-200 rounded-lg p-4 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-gray-900">{redemption.reward.name}</h4>
|
||||||
|
<p className="text-sm text-gray-600">{redemption.reward.description}</p>
|
||||||
|
{redemption.code && (
|
||||||
|
<code className="text-xs text-indigo-600 font-mono mt-1 block">
|
||||||
|
Code: {redemption.code}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
redemption.status === 'active'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: redemption.status === 'used'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: redemption.status === 'expired'
|
||||||
|
? 'bg-red-100 text-red-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{redemption.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History Tab */}
|
||||||
|
{activeTab === 'history' && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Points History</h3>
|
||||||
|
{transactions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={History}
|
||||||
|
title="No transactions yet"
|
||||||
|
description="Your points transactions will appear here"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{transactions.map((transaction) => (
|
||||||
|
<div
|
||||||
|
key={transaction.id}
|
||||||
|
className="border border-gray-200 rounded-lg p-4 flex items-center gap-4"
|
||||||
|
>
|
||||||
|
{getTransactionIcon(transaction.transaction_type)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{transaction.description}</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{formatDate(transaction.created_at)} • {transaction.source}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p
|
||||||
|
className={`font-bold ${
|
||||||
|
transaction.points > 0 ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{transaction.points > 0 ? '+' : ''}
|
||||||
|
{transaction.points.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{transaction.expires_at && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Expires: {formatDate(transaction.expires_at)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Referrals Tab */}
|
||||||
|
{activeTab === 'referrals' && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">My Referrals</h3>
|
||||||
|
{referrals.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="No referrals yet"
|
||||||
|
description="Share your referral code to earn bonus points!"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{referrals.map((referral) => (
|
||||||
|
<div
|
||||||
|
key={referral.id}
|
||||||
|
className="border border-gray-200 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{referral.referred_user?.name || 'Unknown User'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">{referral.referred_user?.email}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
referral.status === 'rewarded'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: referral.status === 'completed'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-yellow-100 text-yellow-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{referral.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{referral.status === 'rewarded' && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-gray-200 flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">You earned:</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
+{referral.referrer_points_earned} points
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{referral.completed_at && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Completed: {formatDate(referral.completed_at)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Redemption Success Modal */}
|
||||||
|
{showRedemptionModal && redemptionData && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-2">Reward Redeemed Successfully!</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
You've redeemed <strong>{redemptionData.rewardName}</strong> for {redemptionData.pointsUsed.toLocaleString()} points.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||||
|
<label className="text-sm font-medium text-gray-700 mb-2 block">Your Redemption Code:</label>
|
||||||
|
<div className="flex items-center justify-between bg-white border-2 border-indigo-500 rounded-lg p-3">
|
||||||
|
<code className="text-lg font-mono font-bold text-gray-900">{redemptionData.code}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(redemptionData.code);
|
||||||
|
toast.success('Code copied to clipboard!');
|
||||||
|
}}
|
||||||
|
className="ml-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Save this code! You can use it when booking or contact support to apply it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowRedemptionModal(false);
|
||||||
|
setRedemptionData(null);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('rewards');
|
||||||
|
setShowRedemptionModal(false);
|
||||||
|
setRedemptionData(null);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
View My Redemptions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoyaltyPage;
|
||||||
|
|
||||||
@@ -9,7 +9,9 @@ const API_BASE_URL = /\/api(\/?$)/i.test(normalized)
|
|||||||
|
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
const RETRY_DELAY = 1000;
|
const RETRY_DELAY = 1000;
|
||||||
const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504];
|
// Note: 503 is excluded because it's used for "service unavailable" (like disabled features)
|
||||||
|
// and should not be retried - it's an intentional state, not a transient error
|
||||||
|
const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 504];
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
@@ -187,6 +189,19 @@ apiClient.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Handle 503 (Service Unavailable) separately - often used for disabled features
|
||||||
|
// Don't retry these as they're intentional states, not transient errors
|
||||||
|
if (status === 503) {
|
||||||
|
const errorData = error.response.data as any;
|
||||||
|
const errorMessage = errorData?.detail || errorData?.message || 'Service temporarily unavailable';
|
||||||
|
return Promise.reject({
|
||||||
|
...error,
|
||||||
|
message: errorMessage,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (status >= 500 && status < 600) {
|
if (status >= 500 && status < 600) {
|
||||||
if (originalRequest && !originalRequest._retry) {
|
if (originalRequest && !originalRequest._retry) {
|
||||||
return retryRequest(error);
|
return retryRequest(error);
|
||||||
|
|||||||
242
Frontend/src/services/api/guestProfileService.ts
Normal file
242
Frontend/src/services/api/guestProfileService.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import apiClient from './apiClient';
|
||||||
|
|
||||||
|
export interface GuestPreference {
|
||||||
|
preferred_room_location?: string;
|
||||||
|
preferred_floor?: number;
|
||||||
|
preferred_room_type_id?: number;
|
||||||
|
preferred_amenities?: string[];
|
||||||
|
special_requests?: string;
|
||||||
|
preferred_services?: string[];
|
||||||
|
preferred_contact_method?: string;
|
||||||
|
preferred_language?: string;
|
||||||
|
dietary_restrictions?: string[];
|
||||||
|
additional_preferences?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestNote {
|
||||||
|
id: number;
|
||||||
|
note: string;
|
||||||
|
is_important: boolean;
|
||||||
|
is_private: boolean;
|
||||||
|
created_by?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestTag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestSegment {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
criteria?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestCommunication {
|
||||||
|
id: number;
|
||||||
|
communication_type: 'email' | 'phone' | 'sms' | 'chat' | 'in_person' | 'other';
|
||||||
|
direction: 'inbound' | 'outbound';
|
||||||
|
subject?: string;
|
||||||
|
content: string;
|
||||||
|
staff_name?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestBooking {
|
||||||
|
id: number;
|
||||||
|
booking_number: string;
|
||||||
|
check_in_date: string;
|
||||||
|
check_out_date: string;
|
||||||
|
status: string;
|
||||||
|
total_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestAnalytics {
|
||||||
|
lifetime_value: number;
|
||||||
|
satisfaction_score?: number;
|
||||||
|
booking_statistics: {
|
||||||
|
total_bookings: number;
|
||||||
|
completed_bookings: number;
|
||||||
|
cancelled_bookings: number;
|
||||||
|
last_visit_date?: string;
|
||||||
|
total_nights_stayed: number;
|
||||||
|
};
|
||||||
|
preferred_room_type?: string;
|
||||||
|
average_booking_value: number;
|
||||||
|
communication_count: number;
|
||||||
|
is_vip: boolean;
|
||||||
|
total_visits: number;
|
||||||
|
last_visit_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestProfile {
|
||||||
|
id: number;
|
||||||
|
full_name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
address?: string;
|
||||||
|
avatar?: string;
|
||||||
|
is_vip: boolean;
|
||||||
|
lifetime_value: number;
|
||||||
|
satisfaction_score?: number;
|
||||||
|
total_visits: number;
|
||||||
|
last_visit_date?: string;
|
||||||
|
created_at?: string;
|
||||||
|
analytics: GuestAnalytics;
|
||||||
|
preferences?: GuestPreference;
|
||||||
|
tags: GuestTag[];
|
||||||
|
segments: GuestSegment[];
|
||||||
|
notes: GuestNote[];
|
||||||
|
communications: GuestCommunication[];
|
||||||
|
recent_bookings: GuestBooking[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestListItem {
|
||||||
|
id: number;
|
||||||
|
full_name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
is_vip: boolean;
|
||||||
|
lifetime_value: number;
|
||||||
|
satisfaction_score?: number;
|
||||||
|
total_visits: number;
|
||||||
|
last_visit_date?: string;
|
||||||
|
tags: GuestTag[];
|
||||||
|
segments: GuestSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestSearchParams {
|
||||||
|
search?: string;
|
||||||
|
is_vip?: boolean;
|
||||||
|
segment_id?: number;
|
||||||
|
min_lifetime_value?: number;
|
||||||
|
min_satisfaction_score?: number;
|
||||||
|
tag_id?: number;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestSearchResponse {
|
||||||
|
status: string;
|
||||||
|
data: {
|
||||||
|
guests: GuestListItem[];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const guestProfileService = {
|
||||||
|
// Search and list guests
|
||||||
|
searchGuests: async (params: GuestSearchParams = {}): Promise<GuestSearchResponse> => {
|
||||||
|
const response = await apiClient.get('/guest-profiles/', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get guest profile details
|
||||||
|
getGuestProfile: async (userId: number): Promise<{ status: string; data: { profile: GuestProfile } }> => {
|
||||||
|
const response = await apiClient.get(`/guest-profiles/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update guest preferences
|
||||||
|
updatePreferences: async (userId: number, preferences: Partial<GuestPreference>): Promise<{ status: string; message: string }> => {
|
||||||
|
const response = await apiClient.put(`/guest-profiles/${userId}/preferences`, preferences);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create guest note
|
||||||
|
createNote: async (userId: number, note: { note: string; is_important?: boolean; is_private?: boolean }): Promise<{ status: string; message: string; data: { note: GuestNote } }> => {
|
||||||
|
const response = await apiClient.post(`/guest-profiles/${userId}/notes`, note);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete guest note
|
||||||
|
deleteNote: async (userId: number, noteId: number): Promise<{ status: string; message: string }> => {
|
||||||
|
const response = await apiClient.delete(`/guest-profiles/${userId}/notes/${noteId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Toggle VIP status
|
||||||
|
toggleVipStatus: async (userId: number, isVip: boolean): Promise<{ status: string; message: string }> => {
|
||||||
|
const response = await apiClient.put(`/guest-profiles/${userId}/vip-status`, { is_vip: isVip });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add tag to guest
|
||||||
|
addTag: async (userId: number, tagId: number): Promise<{ status: string; message: string }> => {
|
||||||
|
const response = await apiClient.post(`/guest-profiles/${userId}/tags`, { tag_id: tagId });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remove tag from guest
|
||||||
|
removeTag: async (userId: number, tagId: number): Promise<{ status: string; message: string }> => {
|
||||||
|
const response = await apiClient.delete(`/guest-profiles/${userId}/tags/${tagId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create communication record
|
||||||
|
createCommunication: async (userId: number, communication: {
|
||||||
|
communication_type: string;
|
||||||
|
direction: string;
|
||||||
|
subject?: string;
|
||||||
|
content: string;
|
||||||
|
booking_id?: number;
|
||||||
|
is_automated?: boolean;
|
||||||
|
}): Promise<{ status: string; message: string }> => {
|
||||||
|
const response = await apiClient.post(`/guest-profiles/${userId}/communications`, communication);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get guest analytics
|
||||||
|
getAnalytics: async (userId: number): Promise<{ status: string; data: { analytics: GuestAnalytics } }> => {
|
||||||
|
const response = await apiClient.get(`/guest-profiles/${userId}/analytics`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update guest metrics
|
||||||
|
updateMetrics: async (userId: number): Promise<{ status: string; message: string; data: { metrics: any } }> => {
|
||||||
|
const response = await apiClient.post(`/guest-profiles/${userId}/update-metrics`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get all tags
|
||||||
|
getAllTags: async (): Promise<{ status: string; data: { tags: GuestTag[] } }> => {
|
||||||
|
const response = await apiClient.get('/guest-profiles/tags/all');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create tag
|
||||||
|
createTag: async (tag: { name: string; color?: string; description?: string }): Promise<{ status: string; message: string; data: { tag: GuestTag } }> => {
|
||||||
|
const response = await apiClient.post('/guest-profiles/tags', tag);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get all segments
|
||||||
|
getAllSegments: async (): Promise<{ status: string; data: { segments: GuestSegment[] } }> => {
|
||||||
|
const response = await apiClient.get('/guest-profiles/segments/all');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Assign segment to guest
|
||||||
|
assignSegment: async (userId: number, segmentId: number): Promise<{ status: string; message: string }> => {
|
||||||
|
const response = await apiClient.post(`/guest-profiles/${userId}/segments`, { segment_id: segmentId });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remove segment from guest
|
||||||
|
removeSegment: async (userId: number, segmentId: number): Promise<{ status: string; message: string }> => {
|
||||||
|
const response = await apiClient.delete(`/guest-profiles/${userId}/segments/${segmentId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default guestProfileService;
|
||||||
|
|
||||||
@@ -38,9 +38,13 @@ export { default as auditService } from './auditService';
|
|||||||
export { default as pageContentService } from './pageContentService';
|
export { default as pageContentService } from './pageContentService';
|
||||||
export { default as chatService } from './chatService';
|
export { default as chatService } from './chatService';
|
||||||
export { default as contactService } from './contactService';
|
export { default as contactService } from './contactService';
|
||||||
|
export { default as loyaltyService } from './loyaltyService';
|
||||||
|
export { default as guestProfileService } from './guestProfileService';
|
||||||
export type { CustomerDashboardStats, CustomerDashboardResponse } from './dashboardService';
|
export type { CustomerDashboardStats, CustomerDashboardResponse } from './dashboardService';
|
||||||
export type * from './reportService';
|
export type * from './reportService';
|
||||||
export type * from './auditService';
|
export type * from './auditService';
|
||||||
export type * from './pageContentService';
|
export type * from './pageContentService';
|
||||||
export type * from './chatService';
|
export type * from './chatService';
|
||||||
export type * from './contactService';
|
export type * from './contactService';
|
||||||
|
export type * from './loyaltyService';
|
||||||
|
export type * from './guestProfileService';
|
||||||
|
|||||||
293
Frontend/src/services/api/loyaltyService.ts
Normal file
293
Frontend/src/services/api/loyaltyService.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import apiClient from './apiClient';
|
||||||
|
|
||||||
|
export interface LoyaltyTier {
|
||||||
|
id: number;
|
||||||
|
level: 'bronze' | 'silver' | 'gold' | 'platinum';
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
min_points: number;
|
||||||
|
points_earn_rate: number;
|
||||||
|
discount_percentage: number;
|
||||||
|
benefits: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserLoyaltyStatus {
|
||||||
|
total_points: number;
|
||||||
|
lifetime_points: number;
|
||||||
|
available_points: number;
|
||||||
|
expired_points: number;
|
||||||
|
referral_code: string;
|
||||||
|
referral_count: number;
|
||||||
|
birthday?: string;
|
||||||
|
anniversary_date?: string;
|
||||||
|
tier: LoyaltyTier;
|
||||||
|
next_tier?: {
|
||||||
|
id: number;
|
||||||
|
level: string;
|
||||||
|
name: string;
|
||||||
|
min_points: number;
|
||||||
|
points_earn_rate: number;
|
||||||
|
discount_percentage: number;
|
||||||
|
points_needed: number;
|
||||||
|
};
|
||||||
|
points_needed_for_next_tier?: number;
|
||||||
|
tier_started_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsTransaction {
|
||||||
|
id: number;
|
||||||
|
transaction_type: 'earned' | 'redeemed' | 'expired' | 'bonus' | 'adjustment';
|
||||||
|
source: 'booking' | 'referral' | 'birthday' | 'anniversary' | 'redemption' | 'promotion' | 'manual';
|
||||||
|
points: number;
|
||||||
|
description: string;
|
||||||
|
reference_number?: string;
|
||||||
|
expires_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
booking_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoyaltyReward {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
reward_type: 'discount' | 'room_upgrade' | 'amenity' | 'cashback' | 'voucher';
|
||||||
|
points_cost: number;
|
||||||
|
discount_percentage?: number;
|
||||||
|
discount_amount?: number;
|
||||||
|
max_discount_amount?: number;
|
||||||
|
min_booking_amount?: number;
|
||||||
|
icon?: string;
|
||||||
|
image?: string;
|
||||||
|
is_available: boolean;
|
||||||
|
can_afford: boolean;
|
||||||
|
stock_remaining?: number;
|
||||||
|
valid_from?: string;
|
||||||
|
valid_until?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RewardRedemption {
|
||||||
|
id: number;
|
||||||
|
reward: LoyaltyReward;
|
||||||
|
points_used: number;
|
||||||
|
status: 'pending' | 'active' | 'used' | 'expired' | 'cancelled';
|
||||||
|
code?: string;
|
||||||
|
booking_id?: number;
|
||||||
|
expires_at?: string;
|
||||||
|
used_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Referral {
|
||||||
|
id: number;
|
||||||
|
referred_user?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
referral_code: string;
|
||||||
|
status: 'pending' | 'completed' | 'rewarded';
|
||||||
|
referrer_points_earned: number;
|
||||||
|
referred_points_earned: number;
|
||||||
|
completed_at?: string;
|
||||||
|
rewarded_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoyaltyStatusResponse {
|
||||||
|
status: string;
|
||||||
|
data: UserLoyaltyStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsHistoryResponse {
|
||||||
|
status: string;
|
||||||
|
data: {
|
||||||
|
transactions: PointsTransaction[];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RewardsResponse {
|
||||||
|
status: string;
|
||||||
|
data: {
|
||||||
|
rewards: LoyaltyReward[];
|
||||||
|
available_points: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedemptionsResponse {
|
||||||
|
status: string;
|
||||||
|
data: {
|
||||||
|
redemptions: RewardRedemption[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferralsResponse {
|
||||||
|
status: string;
|
||||||
|
data: {
|
||||||
|
referrals: Referral[];
|
||||||
|
total_referrals: number;
|
||||||
|
total_points_earned: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const loyaltyService = {
|
||||||
|
getMyStatus: async (): Promise<LoyaltyStatusResponse> => {
|
||||||
|
const response = await apiClient.get('/api/loyalty/my-status');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPointsHistory: async (
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 20,
|
||||||
|
transactionType?: string
|
||||||
|
): Promise<PointsHistoryResponse> => {
|
||||||
|
const params: any = { page, limit };
|
||||||
|
if (transactionType) {
|
||||||
|
params.transaction_type = transactionType;
|
||||||
|
}
|
||||||
|
const response = await apiClient.get('/api/loyalty/points/history', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMyStatus: async (data: { birthday?: string; anniversary_date?: string }): Promise<{ status: string; message: string }> => {
|
||||||
|
const response = await apiClient.put('/api/loyalty/my-status', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAvailableRewards: async (): Promise<RewardsResponse> => {
|
||||||
|
const response = await apiClient.get('/api/loyalty/rewards');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
redeemReward: async (rewardId: number, bookingId?: number): Promise<{ status: string; message: string; data: any }> => {
|
||||||
|
const response = await apiClient.post('/api/loyalty/rewards/redeem', {
|
||||||
|
reward_id: rewardId,
|
||||||
|
booking_id: bookingId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getMyRedemptions: async (statusFilter?: string): Promise<RedemptionsResponse> => {
|
||||||
|
const params: any = {};
|
||||||
|
if (statusFilter) {
|
||||||
|
params.status_filter = statusFilter;
|
||||||
|
}
|
||||||
|
const response = await apiClient.get('/api/loyalty/rewards/my-redemptions', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
applyReferralCode: async (referralCode: string): Promise<{ status: string; message: string }> => {
|
||||||
|
const response = await apiClient.post('/api/loyalty/referral/apply', {
|
||||||
|
referral_code: referralCode,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getMyReferrals: async (): Promise<ReferralsResponse> => {
|
||||||
|
const response = await apiClient.get('/api/loyalty/referral/my-referrals');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAllTiers: async (): Promise<{ status: string; data: { tiers: LoyaltyTier[] } }> => {
|
||||||
|
const response = await apiClient.get('/api/loyalty/admin/tiers');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getUsersLoyaltyStatus: async (
|
||||||
|
search?: string,
|
||||||
|
tierId?: number,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<{
|
||||||
|
status: string;
|
||||||
|
data: {
|
||||||
|
users: Array<{
|
||||||
|
user_id: number;
|
||||||
|
user_name: string;
|
||||||
|
user_email: string;
|
||||||
|
tier: LoyaltyTier;
|
||||||
|
total_points: number;
|
||||||
|
lifetime_points: number;
|
||||||
|
available_points: number;
|
||||||
|
referral_count: number;
|
||||||
|
tier_started_date?: string;
|
||||||
|
}>;
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}> => {
|
||||||
|
const params: any = { page, limit };
|
||||||
|
if (search) params.search = search;
|
||||||
|
if (tierId) params.tier_id = tierId;
|
||||||
|
const response = await apiClient.get('/api/loyalty/admin/users', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin: Get loyalty program status
|
||||||
|
getProgramStatus: async (): Promise<{ status: string; data: { enabled: boolean; updated_at?: string; updated_by?: string } }> => {
|
||||||
|
const response = await apiClient.get('/api/loyalty/admin/status');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin: Update loyalty program status
|
||||||
|
updateProgramStatus: async (enabled: boolean): Promise<{ status: string; message: string; data: { enabled: boolean } }> => {
|
||||||
|
const response = await apiClient.put('/api/loyalty/admin/status', { enabled });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin: Create tier
|
||||||
|
createTier: async (tierData: Partial<LoyaltyTier>): Promise<{ status: string; message: string; data: any }> => {
|
||||||
|
const response = await apiClient.post('/api/loyalty/admin/tiers', tierData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin: Update tier
|
||||||
|
updateTier: async (tierId: number, tierData: Partial<LoyaltyTier>): Promise<{ status: string; message: string; data: any }> => {
|
||||||
|
const response = await apiClient.put(`/api/loyalty/admin/tiers/${tierId}`, tierData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin: Delete tier
|
||||||
|
deleteTier: async (tierId: number): Promise<{ status: string; message: string }> => {
|
||||||
|
const response = await apiClient.delete(`/api/loyalty/admin/tiers/${tierId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin: Get all rewards (admin view)
|
||||||
|
getAllRewardsAdmin: async (): Promise<RewardsResponse> => {
|
||||||
|
const response = await apiClient.get('/api/loyalty/admin/rewards');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin: Create reward
|
||||||
|
createReward: async (rewardData: Partial<LoyaltyReward>): Promise<{ status: string; message: string; data: any }> => {
|
||||||
|
const response = await apiClient.post('/api/loyalty/admin/rewards', rewardData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin: Update reward
|
||||||
|
updateReward: async (rewardId: number, rewardData: Partial<LoyaltyReward>): Promise<{ status: string; message: string; data: any }> => {
|
||||||
|
const response = await apiClient.put(`/api/loyalty/admin/rewards/${rewardId}`, rewardData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin: Delete reward
|
||||||
|
deleteReward: async (rewardId: number): Promise<{ status: string; message: string }> => {
|
||||||
|
const response = await apiClient.delete(`/api/loyalty/admin/rewards/${rewardId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default loyaltyService;
|
||||||
|
|
||||||
Reference in New Issue
Block a user