This commit is contained in:
Iliyan Angelov
2025-11-21 22:40:44 +02:00
parent 9842cc3a4a
commit be07802066
60 changed files with 8189 additions and 9 deletions

View 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')

View 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')

View 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()

View File

@@ -95,7 +95,7 @@ async def metrics():
return {'status': 'success', 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'timestamp': datetime.utcnow().isoformat()}
app.include_router(auth_routes.router, prefix='/api')
app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX)
from .routes import room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, system_settings_routes, contact_routes, page_content_routes, home_routes, about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, terms_routes, refunds_routes, cancellation_routes, accessibility_routes, faq_routes
from .routes import room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, system_settings_routes, contact_routes, page_content_routes, home_routes, about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, terms_routes, refunds_routes, cancellation_routes, accessibility_routes, faq_routes, loyalty_routes, guest_profile_routes
app.include_router(room_routes.router, prefix='/api')
app.include_router(booking_routes.router, prefix='/api')
app.include_router(payment_routes.router, prefix='/api')
@@ -123,6 +123,8 @@ app.include_router(cancellation_routes.router, prefix='/api')
app.include_router(accessibility_routes.router, prefix='/api')
app.include_router(faq_routes.router, prefix='/api')
app.include_router(chat_routes.router, prefix='/api')
app.include_router(loyalty_routes.router, prefix='/api')
app.include_router(guest_profile_routes.router, prefix='/api')
app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(booking_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(payment_routes.router, prefix=settings.API_V1_PREFIX)
@@ -150,6 +152,8 @@ app.include_router(cancellation_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(accessibility_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(faq_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(chat_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(loyalty_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(guest_profile_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(page_content_routes.router, prefix='/api')
app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX)
logger.info('All routes registered successfully')

View File

@@ -21,4 +21,15 @@ from .system_settings import SystemSettings
from .invoice import Invoice, InvoiceItem
from .page_content import PageContent, PageType
from .chat import Chat, ChatMessage, ChatStatus
__all__ = ['Role', 'User', 'RefreshToken', 'PasswordResetToken', 'RoomType', 'Room', 'Booking', 'Payment', 'Service', 'ServiceUsage', 'ServiceBooking', 'ServiceBookingItem', 'ServicePayment', 'ServiceBookingStatus', 'ServicePaymentStatus', 'ServicePaymentMethod', 'Promotion', 'CheckInCheckOut', 'Banner', 'Review', 'Favorite', 'AuditLog', 'CookiePolicy', 'CookieIntegrationConfig', 'SystemSettings', 'Invoice', 'InvoiceItem', 'PageContent', 'PageType', 'Chat', 'ChatMessage', 'ChatStatus']
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']

View 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')

View 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])

View 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')

View 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')

View 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')

View 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])

View 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

View 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')

View 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])

View 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])

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Boolean, Text, ForeignKey, DateTime
from sqlalchemy import Column, Integer, String, Boolean, Text, ForeignKey, DateTime, Numeric
from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
@@ -18,6 +18,14 @@ class User(Base):
mfa_enabled = Column(Boolean, nullable=False, default=False)
mfa_secret = Column(String(255), nullable=True)
mfa_backup_codes = Column(Text, nullable=True)
# Guest Profile & CRM fields
is_vip = Column(Boolean, nullable=False, default=False)
lifetime_value = Column(Numeric(10, 2), nullable=True, default=0) # Total revenue from guest
satisfaction_score = Column(Numeric(3, 2), nullable=True) # Average satisfaction score (0-5)
last_visit_date = Column(DateTime, nullable=True) # Last booking check-in date
total_visits = Column(Integer, nullable=False, default=0) # Total number of bookings
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
role = relationship('Role', back_populates='users')
@@ -30,3 +38,12 @@ class User(Base):
service_bookings = relationship('ServiceBooking', back_populates='user')
visitor_chats = relationship('Chat', foreign_keys='Chat.visitor_id', back_populates='visitor')
staff_chats = relationship('Chat', foreign_keys='Chat.staff_id', back_populates='staff')
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')

View 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')

View File

@@ -15,10 +15,13 @@ from ..models.room import Room, RoomStatus
from ..models.room_type import RoomType
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..models.service_usage import ServiceUsage
from ..models.user_loyalty import UserLoyalty
from ..models.referral import Referral, ReferralStatus
from ..services.room_service import normalize_images, get_base_url
from fastapi import Request
from ..utils.mailer import send_email
from ..utils.email_templates import booking_confirmation_email_template, booking_status_changed_email_template
from ..services.loyalty_service import LoyaltyService
router = APIRouter(prefix='/bookings', tags=['bookings'])
def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str:
@@ -161,6 +164,7 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr
notes = booking_data.get('notes')
payment_method = booking_data.get('payment_method', 'cash')
promotion_code = booking_data.get('promotion_code')
referral_code = booking_data.get('referral_code')
invoice_info = booking_data.get('invoice_info', {})
missing_fields = []
if not room_id:
@@ -262,6 +266,33 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr
)
db.add(booking)
db.flush()
# Process referral code if provided
if referral_code:
try:
from ..services.loyalty_service import LoyaltyService
from ..models.system_settings import SystemSettings
# Check if loyalty program is enabled
setting = db.query(SystemSettings).filter(
SystemSettings.key == 'loyalty_program_enabled'
).first()
is_enabled = True # Default to enabled
if setting:
is_enabled = setting.value.lower() == 'true'
if is_enabled:
# Process referral code - this will create referral record and award points when booking is confirmed
LoyaltyService.process_referral(
db,
current_user.id,
referral_code.upper().strip(),
booking.id
)
logger.info(f"Referral code {referral_code} processed for booking {booking.id}")
except Exception as referral_error:
logger.warning(f"Failed to process referral code {referral_code}: {referral_error}")
# Don't fail the booking if referral processing fails
if payment_method in ['stripe', 'paypal']:
from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType
if payment_method == 'stripe':
@@ -586,6 +617,42 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends
email_html = booking_confirmation_email_template(booking_number=booking.booking_number, guest_name=guest_name, room_number=room.room_number if room else 'N/A', room_type=room_type_name, check_in=booking.check_in_date.strftime('%B %d, %Y') if booking.check_in_date else 'N/A', check_out=booking.check_out_date.strftime('%B %d, %Y') if booking.check_out_date else 'N/A', num_guests=booking.num_guests, total_price=float(booking.total_price), requires_deposit=booking.requires_deposit, deposit_amount=float(booking.total_price) * 0.2 if booking.requires_deposit else None, original_price=float(booking.original_price) if booking.original_price else None, discount_amount=float(booking.discount_amount) if booking.discount_amount else None, promotion_code=booking.promotion_code, client_url=client_url, currency_symbol=currency_symbol)
if guest_email:
await send_email(to=guest_email, subject=f'Booking Confirmed - {booking.booking_number}', html=email_html)
# Award loyalty points for confirmed booking
if booking.user:
try:
# Check if booking already earned points
from ..models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionSource
existing_points = db.query(LoyaltyPointTransaction).filter(
LoyaltyPointTransaction.booking_id == booking.id,
LoyaltyPointTransaction.source == TransactionSource.booking
).first()
if not existing_points:
# Award points based on total price paid
total_paid = sum(float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed)
if total_paid > 0:
LoyaltyService.earn_points_from_booking(db, booking.user_id, booking, total_paid)
# Process referral if applicable - referral is already processed when booking is created
# This section is for backward compatibility with existing referrals
if booking.user:
user_loyalty = db.query(UserLoyalty).filter(UserLoyalty.user_id == booking.user_id).first()
if user_loyalty and user_loyalty.referral_code:
# Check if there's a referral for this user that hasn't been rewarded yet
from ..models.referral import Referral
referral = db.query(Referral).filter(
Referral.referred_user_id == booking.user_id,
Referral.booking_id == booking.id,
Referral.status.in_([ReferralStatus.pending, ReferralStatus.completed])
).first()
if referral and referral.status == ReferralStatus.pending:
# Award points now that booking is confirmed
LoyaltyService.process_referral(db, booking.user_id, referral.referral_code, booking.id)
except Exception as loyalty_error:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Failed to award loyalty points: {loyalty_error}')
elif booking.status == BookingStatus.cancelled:
guest_name = booking.user.full_name if booking.user else 'Guest'
guest_email = booking.user.email if booking.user else None

View 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))

View 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))

View File

@@ -13,6 +13,7 @@ from ..utils.mailer import send_email
from ..utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template
from ..services.stripe_service import StripeService
from ..services.paypal_service import PayPalService
from ..services.loyalty_service import LoyaltyService
router = APIRouter(prefix='/payments', tags=['payments'])
async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str='Payment failed or canceled'):
@@ -137,6 +138,29 @@ async def create_payment(payment_data: dict, current_user: User=Depends(get_curr
db.add(payment)
db.commit()
db.refresh(payment)
# Award loyalty points if payment completed and booking is confirmed
if payment.payment_status == PaymentStatus.completed and booking:
try:
db.refresh(booking)
if booking.status == BookingStatus.confirmed and booking.user:
# Check if booking already earned points
from ..models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionSource
existing_points = db.query(LoyaltyPointTransaction).filter(
LoyaltyPointTransaction.booking_id == booking.id,
LoyaltyPointTransaction.source == TransactionSource.booking
).first()
if not existing_points:
# Award points based on payment amount
total_paid = sum(float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed)
if total_paid > 0:
LoyaltyService.earn_points_from_booking(db, booking.user_id, booking, total_paid)
except Exception as loyalty_error:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Failed to award loyalty points: {loyalty_error}')
if payment.payment_status == PaymentStatus.completed and booking.user:
try:
from ..models.system_settings import SystemSettings

View 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
}

View 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()

View 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)

View File

@@ -51,6 +51,7 @@ const PayPalReturnPage = lazy(() => import('./pages/customer/PayPalReturnPage'))
const PayPalCancelPage = lazy(() => import('./pages/customer/PayPalCancelPage'));
const InvoicePage = lazy(() => import('./pages/customer/InvoicePage'));
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const ContactPage = lazy(() => import('./pages/ContactPage'));
const PrivacyPolicyPage = lazy(() => import('./pages/PrivacyPolicyPage'));
@@ -64,12 +65,14 @@ const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage'));
const InvoiceManagementPage = lazy(() => import('./pages/admin/InvoiceManagementPage'));
const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage'));
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
const GuestProfilePage = lazy(() => import('./pages/admin/GuestProfilePage'));
const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage'));
const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard'));
const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage'));
const BusinessDashboardPage = lazy(() => import('./pages/admin/BusinessDashboardPage'));
const SettingsPage = lazy(() => import('./pages/admin/SettingsPage'));
const ReceptionDashboardPage = lazy(() => import('./pages/admin/ReceptionDashboardPage'));
const LoyaltyManagementPage = lazy(() => import('./pages/admin/LoyaltyManagementPage'));
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage'));
@@ -280,6 +283,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="loyalty"
element={
<CustomerRoute>
<LoyaltyPage />
</CustomerRoute>
}
/>
</Route>
{}
@@ -338,6 +349,14 @@ function App() {
path="payments"
element={<PaymentManagementPage />}
/>
<Route
path="loyalty"
element={<LoyaltyManagementPage />}
/>
<Route
path="guest-profiles"
element={<GuestProfilePage />}
/>
</Route>
{}
@@ -374,6 +393,14 @@ function App() {
path="chats"
element={<ChatManagementPage />}
/>
<Route
path="loyalty"
element={<LoyaltyManagementPage />}
/>
<Route
path="guest-profiles"
element={<GuestProfilePage />}
/>
</Route>
{/* Accountant Routes */}

View File

@@ -71,6 +71,8 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
const [promotionDiscount, setPromotionDiscount] = useState(0);
const [validatingPromotion, setValidatingPromotion] = useState(false);
const [promotionError, setPromotionError] = useState<string | null>(null);
const [referralCode, setReferralCode] = useState('');
const [referralError, setReferralError] = useState<string | null>(null);
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
const [showPaymentModal, setShowPaymentModal] = useState(false);
const [paymentMethod, setPaymentMethod] = useState<'stripe' | 'paypal' | 'cash' | null>(null);
@@ -123,6 +125,8 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
setPromotionCode('');
setSelectedPromotion(null);
setPromotionDiscount(0);
setReferralCode('');
setReferralError(null);
setShowPaymentModal(false);
setPaymentMethod(null);
setCreatedBookingId(null);
@@ -376,6 +380,7 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
quantity: item.quantity,
})),
promotion_code: selectedPromotion?.code || undefined,
referral_code: referralCode.trim() || undefined,
invoice_info: (data as any).invoiceInfo ? {
company_name: (data as any).invoiceInfo.company_name || undefined,
company_address: (data as any).invoiceInfo.company_address || undefined,
@@ -823,6 +828,32 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
)}
{promotionError && <p className="text-xs text-red-400 mt-2">{promotionError}</p>}
</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>
)}

View File

@@ -12,6 +12,7 @@ import {
Phone,
Mail,
Calendar,
Star,
} from 'lucide-react';
import { useClickOutside } from '../../hooks/useClickOutside';
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
@@ -280,6 +281,18 @@ const Header: React.FC<HeaderProps> = ({
<Calendar className="w-4 h-4" />
<span className="font-light tracking-wide">My Bookings</span>
</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' && (

View File

@@ -12,7 +12,9 @@ import {
LogIn,
LogOut,
Menu,
X
X,
Award,
User
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
@@ -92,6 +94,16 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
icon: Users,
label: 'Users'
},
{
path: '/admin/guest-profiles',
icon: User,
label: 'Guest Profiles'
},
{
path: '/admin/loyalty',
icon: Award,
label: 'Loyalty Program'
},
{
path: '/admin/business',
icon: FileText,
@@ -123,7 +135,7 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
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;
}

View File

@@ -11,7 +11,9 @@ import {
Menu,
X,
CreditCard,
MessageCircle
MessageCircle,
Award,
Users
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useChatNotifications } from '../../contexts/ChatNotificationContext';
@@ -104,6 +106,16 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
icon: CreditCard,
label: 'Payments'
},
{
path: '/staff/loyalty',
icon: Award,
label: 'Loyalty Program'
},
{
path: '/staff/guest-profiles',
icon: Users,
label: 'Guest Profiles'
},
{
path: '/staff/chats',
icon: MessageCircle,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
export { default as DashboardPage } from './DashboardPage';
export { default as RoomManagementPage } from './RoomManagementPage';
export { default as UserManagementPage } from './UserManagementPage';
export { default as GuestProfilePage } from './GuestProfilePage';
export { default as BookingManagementPage } from './BookingManagementPage';
export { default as PaymentManagementPage } from './PaymentManagementPage';
export { default as ServiceManagementPage } from './ServiceManagementPage';

View 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;

View File

@@ -9,7 +9,9 @@ const API_BASE_URL = /\/api(\/?$)/i.test(normalized)
const MAX_RETRIES = 3;
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({
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 (originalRequest && !originalRequest._retry) {
return retryRequest(error);

View 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;

View File

@@ -38,9 +38,13 @@ export { default as auditService } from './auditService';
export { default as pageContentService } from './pageContentService';
export { default as chatService } from './chatService';
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 * from './reportService';
export type * from './auditService';
export type * from './pageContentService';
export type * from './chatService';
export type * from './contactService';
export type * from './loyaltyService';
export type * from './guestProfileService';

View 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;