From 48353cde9c5697fa27f4aa0e08e8d0d52e077949 Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Sun, 16 Nov 2025 20:05:08 +0200 Subject: [PATCH] update --- Backend/.env.example | 61 +- .../alembic/__pycache__/env.cpython-312.pyc | Bin 0 -> 2790 bytes Backend/alembic/env.py | 17 +- ...8a_initial_migration_create_all_tables_.py | 285 ++++++++ ...gration_create_all_tables_.cpython-312.pyc | Bin 0 -> 24262 bytes Backend/requirements.txt | 5 + Backend/reset_user_passwords.py | 91 +++ Backend/run.py | 45 +- Backend/src/__pycache__/main.cpython-312.pyc | Bin 4863 -> 12849 bytes .../__pycache__/database.cpython-312.pyc | Bin 1523 -> 2506 bytes .../logging_config.cpython-312.pyc | Bin 0 -> 4004 bytes .../__pycache__/settings.cpython-312.pyc | Bin 0 -> 6681 bytes Backend/src/config/database.py | 59 +- Backend/src/config/logging_config.py | 96 +++ Backend/src/config/settings.py | 119 +++ Backend/src/main.py | 233 ++++-- .../__pycache__/auth.cpython-312.pyc | Bin 2622 -> 2819 bytes .../cookie_consent.cpython-312.pyc | Bin 0 -> 3779 bytes .../__pycache__/error_handler.cpython-312.pyc | Bin 4537 -> 4976 bytes .../__pycache__/request_id.cpython-312.pyc | Bin 0 -> 2747 bytes .../__pycache__/security.cpython-312.pyc | Bin 0 -> 2020 bytes .../__pycache__/timeout.cpython-312.pyc | Bin 0 -> 1967 bytes Backend/src/middleware/auth.py | 4 +- Backend/src/middleware/cookie_consent.py | 89 +++ Backend/src/middleware/error_handler.py | 37 +- Backend/src/middleware/request_id.py | 65 ++ Backend/src/middleware/security.py | 57 ++ Backend/src/middleware/timeout.py | 41 ++ Backend/src/models/__init__.py | 6 + .../__pycache__/__init__.cpython-312.pyc | Bin 911 -> 1095 bytes .../__pycache__/audit_log.cpython-312.pyc | Bin 0 -> 1689 bytes .../cookie_integration_config.cpython-312.pyc | Bin 0 -> 1478 bytes .../__pycache__/cookie_policy.cpython-312.pyc | Bin 0 -> 1537 bytes Backend/src/models/audit_log.py | 28 + .../src/models/cookie_integration_config.py | 30 + Backend/src/models/cookie_policy.py | 31 + .../admin_privacy_routes.cpython-312.pyc | Bin 0 -> 4225 bytes .../__pycache__/audit_routes.cpython-312.pyc | Bin 0 -> 10232 bytes .../__pycache__/auth_routes.cpython-312.pyc | Bin 7265 -> 8849 bytes .../__pycache__/banner_routes.cpython-312.pyc | Bin 10474 -> 13226 bytes .../booking_routes.cpython-312.pyc | Bin 19250 -> 22637 bytes .../payment_routes.cpython-312.pyc | Bin 10778 -> 17122 bytes .../privacy_routes.cpython-312.pyc | Bin 0 -> 3988 bytes .../__pycache__/report_routes.cpython-312.pyc | Bin 15447 -> 25118 bytes Backend/src/routes/admin_privacy_routes.py | 120 ++++ Backend/src/routes/audit_routes.py | 239 +++++++ Backend/src/routes/auth_routes.py | 47 +- Backend/src/routes/banner_routes.py | 59 +- Backend/src/routes/booking_routes.py | 70 ++ Backend/src/routes/payment_routes.py | 116 ++- Backend/src/routes/privacy_routes.py | 111 +++ Backend/src/routes/report_routes.py | 195 +++++ .../__pycache__/admin_privacy.cpython-312.pyc | Bin 0 -> 3077 bytes .../__pycache__/privacy.cpython-312.pyc | Bin 0 -> 2973 bytes Backend/src/schemas/admin_privacy.py | 68 ++ Backend/src/schemas/privacy.py | 70 ++ .../__pycache__/auth_service.cpython-312.pyc | Bin 16448 -> 20742 bytes .../privacy_admin_service.cpython-312.pyc | Bin 0 -> 4664 bytes Backend/src/services/audit_service.py | 82 +++ Backend/src/services/auth_service.py | 173 +++-- Backend/src/services/privacy_admin_service.py | 98 +++ .../email_templates.cpython-312.pyc | Bin 0 -> 13015 bytes .../utils/__pycache__/mailer.cpython-312.pyc | Bin 2211 -> 4311 bytes Backend/src/utils/email_templates.py | 261 +++++++ Backend/src/utils/mailer.py | 119 ++- Frontend/index.html | 5 +- Frontend/src/App.tsx | 151 ++-- .../src/components/common/AnalyticsLoader.tsx | 117 +++ .../components/common/ConfirmationDialog.tsx | 164 +++++ .../components/common/CookieConsentBanner.tsx | 200 ++++++ .../common/CookiePreferencesLink.tsx | 29 + .../src/components/common/GlobalLoading.tsx | 30 + .../components/common/OfflineIndicator.tsx | 25 + Frontend/src/components/common/Skeleton.tsx | 47 ++ Frontend/src/components/common/index.ts | 12 + Frontend/src/components/layout/Footer.tsx | 264 ++++--- Frontend/src/components/layout/Header.tsx | 280 +++++--- Frontend/src/components/layout/LayoutMain.tsx | 2 +- .../src/components/layout/SidebarAdmin.tsx | 8 +- .../src/components/rooms/BannerCarousel.tsx | 349 +++++++-- .../src/components/rooms/BannerSkeleton.tsx | 9 +- Frontend/src/components/rooms/RoomCard.tsx | 95 +-- Frontend/src/components/rooms/RoomFilter.tsx | 67 +- .../src/components/rooms/SearchRoomForm.tsx | 87 ++- .../src/contexts/CookieConsentContext.tsx | 112 +++ .../src/contexts/GlobalLoadingContext.tsx | 40 ++ Frontend/src/hooks/index.ts | 7 + Frontend/src/hooks/useAsync.ts | 86 +++ Frontend/src/hooks/useClickOutside.ts | 28 + Frontend/src/hooks/useLocalStorage.ts | 68 ++ Frontend/src/hooks/useOffline.ts | 29 + Frontend/src/pages/AboutPage.tsx | 244 +++++++ Frontend/src/pages/HomePage.tsx | 237 +++--- Frontend/src/pages/admin/AuditLogsPage.tsx | 446 ++++++++++++ .../src/pages/admin/BannerManagementPage.tsx | 677 ++++++++++++++++++ .../src/pages/admin/BookingManagementPage.tsx | 67 +- .../src/pages/admin/CookieSettingsPage.tsx | 357 +++++++++ Frontend/src/pages/admin/DashboardPage.tsx | 301 ++++---- Frontend/src/pages/admin/ReportsPage.tsx | 376 ++++++++++ Frontend/src/pages/admin/index.ts | 1 + Frontend/src/pages/auth/LoginPage.tsx | 117 ++- Frontend/src/pages/auth/RegisterPage.tsx | 186 +++-- Frontend/src/pages/customer/DashboardPage.tsx | 363 +++++----- Frontend/src/pages/customer/ProfilePage.tsx | 543 ++++++++++++++ Frontend/src/pages/customer/RoomListPage.tsx | 101 +-- .../src/services/api/adminPrivacyService.ts | 63 ++ Frontend/src/services/api/apiClient.ts | 195 ++++- Frontend/src/services/api/auditService.ts | 116 +++ Frontend/src/services/api/authService.ts | 19 + Frontend/src/services/api/bannerService.ts | 198 ++++- Frontend/src/services/api/dashboardService.ts | 57 ++ Frontend/src/services/api/index.ts | 4 + Frontend/src/services/api/privacyService.ts | 84 +++ Frontend/src/styles/index.css | 304 +++++++- Frontend/src/utils/constants.ts | 53 ++ Frontend/src/utils/format.ts | 162 +++++ Frontend/src/utils/index.ts | 4 + Frontend/tailwind.config.js | 11 + 118 files changed, 9488 insertions(+), 1336 deletions(-) create mode 100644 Backend/alembic/__pycache__/env.cpython-312.pyc create mode 100644 Backend/alembic/versions/59baf2338f8a_initial_migration_create_all_tables_.py create mode 100644 Backend/alembic/versions/__pycache__/59baf2338f8a_initial_migration_create_all_tables_.cpython-312.pyc create mode 100644 Backend/reset_user_passwords.py create mode 100644 Backend/src/config/__pycache__/logging_config.cpython-312.pyc create mode 100644 Backend/src/config/__pycache__/settings.cpython-312.pyc create mode 100644 Backend/src/config/logging_config.py create mode 100644 Backend/src/config/settings.py create mode 100644 Backend/src/middleware/__pycache__/cookie_consent.cpython-312.pyc create mode 100644 Backend/src/middleware/__pycache__/request_id.cpython-312.pyc create mode 100644 Backend/src/middleware/__pycache__/security.cpython-312.pyc create mode 100644 Backend/src/middleware/__pycache__/timeout.cpython-312.pyc create mode 100644 Backend/src/middleware/cookie_consent.py create mode 100644 Backend/src/middleware/request_id.py create mode 100644 Backend/src/middleware/security.py create mode 100644 Backend/src/middleware/timeout.py create mode 100644 Backend/src/models/__pycache__/audit_log.cpython-312.pyc create mode 100644 Backend/src/models/__pycache__/cookie_integration_config.cpython-312.pyc create mode 100644 Backend/src/models/__pycache__/cookie_policy.cpython-312.pyc create mode 100644 Backend/src/models/audit_log.py create mode 100644 Backend/src/models/cookie_integration_config.py create mode 100644 Backend/src/models/cookie_policy.py create mode 100644 Backend/src/routes/__pycache__/admin_privacy_routes.cpython-312.pyc create mode 100644 Backend/src/routes/__pycache__/audit_routes.cpython-312.pyc create mode 100644 Backend/src/routes/__pycache__/privacy_routes.cpython-312.pyc create mode 100644 Backend/src/routes/admin_privacy_routes.py create mode 100644 Backend/src/routes/audit_routes.py create mode 100644 Backend/src/routes/privacy_routes.py create mode 100644 Backend/src/schemas/__pycache__/admin_privacy.cpython-312.pyc create mode 100644 Backend/src/schemas/__pycache__/privacy.cpython-312.pyc create mode 100644 Backend/src/schemas/admin_privacy.py create mode 100644 Backend/src/schemas/privacy.py create mode 100644 Backend/src/services/__pycache__/privacy_admin_service.cpython-312.pyc create mode 100644 Backend/src/services/audit_service.py create mode 100644 Backend/src/services/privacy_admin_service.py create mode 100644 Backend/src/utils/__pycache__/email_templates.cpython-312.pyc create mode 100644 Backend/src/utils/email_templates.py create mode 100644 Frontend/src/components/common/AnalyticsLoader.tsx create mode 100644 Frontend/src/components/common/ConfirmationDialog.tsx create mode 100644 Frontend/src/components/common/CookieConsentBanner.tsx create mode 100644 Frontend/src/components/common/CookiePreferencesLink.tsx create mode 100644 Frontend/src/components/common/GlobalLoading.tsx create mode 100644 Frontend/src/components/common/OfflineIndicator.tsx create mode 100644 Frontend/src/components/common/Skeleton.tsx create mode 100644 Frontend/src/components/common/index.ts create mode 100644 Frontend/src/contexts/CookieConsentContext.tsx create mode 100644 Frontend/src/contexts/GlobalLoadingContext.tsx create mode 100644 Frontend/src/hooks/index.ts create mode 100644 Frontend/src/hooks/useAsync.ts create mode 100644 Frontend/src/hooks/useClickOutside.ts create mode 100644 Frontend/src/hooks/useLocalStorage.ts create mode 100644 Frontend/src/hooks/useOffline.ts create mode 100644 Frontend/src/pages/AboutPage.tsx create mode 100644 Frontend/src/pages/admin/AuditLogsPage.tsx create mode 100644 Frontend/src/pages/admin/BannerManagementPage.tsx create mode 100644 Frontend/src/pages/admin/CookieSettingsPage.tsx create mode 100644 Frontend/src/pages/admin/ReportsPage.tsx create mode 100644 Frontend/src/pages/customer/ProfilePage.tsx create mode 100644 Frontend/src/services/api/adminPrivacyService.ts create mode 100644 Frontend/src/services/api/auditService.ts create mode 100644 Frontend/src/services/api/dashboardService.ts create mode 100644 Frontend/src/services/api/privacyService.ts create mode 100644 Frontend/src/utils/constants.ts create mode 100644 Frontend/src/utils/format.ts create mode 100644 Frontend/src/utils/index.ts diff --git a/Backend/.env.example b/Backend/.env.example index 688eb386..086a03ef 100644 --- a/Backend/.env.example +++ b/Backend/.env.example @@ -1,35 +1,34 @@ -# Environment -NODE_ENV=development +# Hotel Booking API - Environment Variables +# Copy this file to .env and fill in your actual values -# Server -PORT=3000 -HOST=localhost +# ============================================ +# Email/SMTP Configuration +# ============================================ +# SMTP Server Settings +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASSWORD=your-app-specific-password -# Database +# Email Sender Information +SMTP_FROM_EMAIL=noreply@yourdomain.com +SMTP_FROM_NAME=Hotel Booking + +# Alternative: Legacy environment variable names (for backward compatibility) +# MAIL_HOST=smtp.gmail.com +# MAIL_PORT=587 +# MAIL_USER=your-email@gmail.com +# MAIL_PASS=your-app-specific-password +# MAIL_FROM=noreply@yourdomain.com +# MAIL_SECURE=false + +# ============================================ +# Other Required Variables +# ============================================ +CLIENT_URL=http://localhost:5173 +DB_USER=root +DB_PASS=your_database_password +DB_NAME=hotel_db DB_HOST=localhost DB_PORT=3306 -DB_USER=root -DB_PASS= -DB_NAME=hotel_booking_dev - -# JWT -JWT_SECRET=your_super_secret_jwt_key_change_this_in_production -JWT_EXPIRES_IN=1h -JWT_REFRESH_SECRET=your_super_secret_refresh_key_change_this_in_production -JWT_REFRESH_EXPIRES_IN=7d - -# Client URL -CLIENT_URL=http://localhost:5173 - -# Upload -MAX_FILE_SIZE=5242880 -ALLOWED_FILE_TYPES=image/jpeg,image/png,image/jpg,image/webp - -# Pagination -DEFAULT_PAGE_SIZE=10 -MAX_PAGE_SIZE=100 - -# Rate Limiting -RATE_LIMIT_WINDOW_MS=900000 -RATE_LIMIT_MAX_REQUESTS=100 - +JWT_SECRET=your-super-secret-jwt-key-change-in-production diff --git a/Backend/alembic/__pycache__/env.cpython-312.pyc b/Backend/alembic/__pycache__/env.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d39f425a09b771e364b47aa5368fb98cfe1d2eef GIT binary patch literal 2790 zcmZuyO>ERg6dtd=UfcWQkYsn0O$eKi9~LzUp_H_g(h@2asZxZhUf7G}tS7;Xf6}o7 zB!VhPNVHO=7WDv!9yqoNm3ru**Kq9xE1?@qa;REW;uaJTQcitiukjKx$s;-^UG?LwoRGfR4skO3%c9f4XQ6)LZQE)I>?YslYZ+XMBgUQe_utm39@Jzl zdyA{~!J32h5X8aPKY3d-x+NljMqjjJ&|(tyhg$cQwrAA5!uZmxZs2paHLK6Tt{q!* zx`mZlVw*}W;&Su0ZGbcd92Z}6r-9}!sO}|L(}t~Tie|gmy7G)>kjkc12g52j*mZSl z&H37d;!3&g0kegNPWvGCjZ52m}weJ!g~?w+AB(S6@QZZZ&T5N0MM?n8mBW4k}F)O!L(WjH56M?D!Q@v08K6DWZu+j)dY0$^e@ioB7Yc7AcwgM zc)JLGARGKO_9D8H0}1@~Y2(!5Gk z({UFK>?bYN#9D>(S-3=Bc2zQmUB$$%s;RE(XARxOL^YHd-O?OCqv@)FYp!C?yUuha zibV9Gs7ena+!K9t;56oZygyIs_a?~2t^K)duQsSU13^@Rr4$Xae z_vH8`+r;B@*0u3Zv2)qA=f}?jHjL3J+rA7?7@tyWm$9XdLr~_7UKN38rdk6HVOmxKYNsa z=cSlT@-K2oI?xaYJaJ%69DI(LWdEihH-tV<=vx=cuUKHbqTE2V>(JD$iuto6?CMDJ z?6K7T7+r#I1&q=!4u;$8ISAEXqcM^p)FRynp*o7NqA@X6>-(VbL1Lv@3pdbpRHwk$ zGh)2nU|cy8*4rW~UavDQO>ZzBuu%kn^4O{~lE-W8{|(JTtPxR>Ce#B*|Kbz&?iabe z_3>SMSRZ@&i(IpAl1t_N>c(m*o27J1uc;(_exBf2{hBY*PF6Ei#{s4USht`wDER4Z zOjk05b_SA%$xqYza6+-)9+rioTYAJJG}XQ^y-Wh$7;yU={&$ly3$~VMbPwZV2`?e9E9J+rrfPc3qbZ->`A9e~rKO9O9j*5I!Fa_}YaJi1qth?J06ZSqL|tGj9r(WQ+rDKUNKR_7 zC66sVVuzn_xo@sC None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('audit_logs', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('action', sa.String(length=100), nullable=False), + sa.Column('resource_type', sa.String(length=50), nullable=False), + sa.Column('resource_id', sa.Integer(), nullable=True), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('user_agent', sa.String(length=255), nullable=True), + sa.Column('request_id', sa.String(length=36), nullable=True), + sa.Column('details', sa.JSON(), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False) + op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False) + op.create_index(op.f('ix_audit_logs_id'), 'audit_logs', ['id'], unique=False) + op.create_index(op.f('ix_audit_logs_request_id'), 'audit_logs', ['request_id'], unique=False) + op.create_index(op.f('ix_audit_logs_resource_id'), 'audit_logs', ['resource_id'], unique=False) + op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False) + op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False) + op.drop_index('name', table_name='SequelizeMeta') + op.drop_table('SequelizeMeta') + op.drop_index('banners_is_active', table_name='banners') + op.drop_index('banners_position', table_name='banners') + op.create_index(op.f('ix_banners_id'), 'banners', ['id'], unique=False) + # Drop foreign keys first, then indexes + op.drop_constraint('bookings_ibfk_2', 'bookings', type_='foreignkey') + op.drop_constraint('bookings_ibfk_1', 'bookings', type_='foreignkey') + op.drop_index('booking_number', table_name='bookings') + op.drop_index('bookings_booking_number', table_name='bookings') + op.drop_index('bookings_check_in_date', table_name='bookings') + op.drop_index('bookings_check_out_date', table_name='bookings') + op.drop_index('bookings_room_id', table_name='bookings') + op.drop_index('bookings_status', table_name='bookings') + op.drop_index('bookings_user_id', table_name='bookings') + op.create_index(op.f('ix_bookings_booking_number'), 'bookings', ['booking_number'], unique=True) + op.create_index(op.f('ix_bookings_id'), 'bookings', ['id'], unique=False) + op.create_foreign_key(None, 'bookings', 'users', ['user_id'], ['id']) + op.create_foreign_key(None, 'bookings', 'rooms', ['room_id'], ['id']) + # Drop foreign keys first, then indexes + op.drop_constraint('checkin_checkout_ibfk_1', 'checkin_checkout', type_='foreignkey') + op.drop_constraint('checkin_checkout_ibfk_2', 'checkin_checkout', type_='foreignkey') + op.drop_constraint('checkin_checkout_ibfk_3', 'checkin_checkout', type_='foreignkey') + op.drop_index('checkin_checkout_booking_id', table_name='checkin_checkout') + op.create_index(op.f('ix_checkin_checkout_id'), 'checkin_checkout', ['id'], unique=False) + op.create_unique_constraint(None, 'checkin_checkout', ['booking_id']) + op.create_foreign_key(None, 'checkin_checkout', 'bookings', ['booking_id'], ['id']) + op.create_foreign_key(None, 'checkin_checkout', 'users', ['checkout_by'], ['id']) + op.create_foreign_key(None, 'checkin_checkout', 'users', ['checkin_by'], ['id']) + # Drop foreign keys first, then indexes + op.drop_constraint('favorites_ibfk_2', 'favorites', type_='foreignkey') + op.drop_constraint('favorites_ibfk_1', 'favorites', type_='foreignkey') + op.drop_index('favorites_room_id', table_name='favorites') + op.drop_index('favorites_user_id', table_name='favorites') + op.drop_index('unique_user_room_favorite', table_name='favorites') + op.create_index(op.f('ix_favorites_id'), 'favorites', ['id'], unique=False) + op.create_foreign_key(None, 'favorites', 'users', ['user_id'], ['id']) + op.create_foreign_key(None, 'favorites', 'rooms', ['room_id'], ['id']) + op.alter_column('password_reset_tokens', 'used', + existing_type=mysql.TINYINT(display_width=1), + nullable=False, + existing_server_default=sa.text("'0'")) + # Drop foreign key first, then indexes + op.drop_constraint('password_reset_tokens_ibfk_1', 'password_reset_tokens', type_='foreignkey') + op.drop_index('password_reset_tokens_token', table_name='password_reset_tokens') + op.drop_index('password_reset_tokens_user_id', table_name='password_reset_tokens') + op.drop_index('token', table_name='password_reset_tokens') + op.create_index(op.f('ix_password_reset_tokens_id'), 'password_reset_tokens', ['id'], unique=False) + op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True) + op.create_foreign_key(None, 'password_reset_tokens', 'users', ['user_id'], ['id']) + op.alter_column('payments', 'deposit_percentage', + existing_type=mysql.INTEGER(), + comment=None, + existing_comment='Percentage of deposit (e.g., 20, 30, 50)', + existing_nullable=True) + # Drop foreign keys first, then indexes + op.drop_constraint('payments_related_payment_id_foreign_idx', 'payments', type_='foreignkey') + op.drop_constraint('payments_ibfk_1', 'payments', type_='foreignkey') + op.drop_index('payments_booking_id', table_name='payments') + op.drop_index('payments_payment_status', table_name='payments') + op.create_index(op.f('ix_payments_id'), 'payments', ['id'], unique=False) + op.create_foreign_key(None, 'payments', 'bookings', ['booking_id'], ['id']) + op.create_foreign_key(None, 'payments', 'payments', ['related_payment_id'], ['id']) + op.drop_index('code', table_name='promotions') + op.drop_index('promotions_code', table_name='promotions') + op.drop_index('promotions_is_active', table_name='promotions') + op.create_index(op.f('ix_promotions_code'), 'promotions', ['code'], unique=True) + op.create_index(op.f('ix_promotions_id'), 'promotions', ['id'], unique=False) + # Drop foreign key first, then indexes + op.drop_constraint('refresh_tokens_ibfk_1', 'refresh_tokens', type_='foreignkey') + op.drop_index('refresh_tokens_token', table_name='refresh_tokens') + op.drop_index('refresh_tokens_user_id', table_name='refresh_tokens') + op.drop_index('token', table_name='refresh_tokens') + op.create_index(op.f('ix_refresh_tokens_id'), 'refresh_tokens', ['id'], unique=False) + op.create_index(op.f('ix_refresh_tokens_token'), 'refresh_tokens', ['token'], unique=True) + op.create_foreign_key(None, 'refresh_tokens', 'users', ['user_id'], ['id']) + # Drop foreign keys first, then indexes + op.drop_constraint('reviews_ibfk_2', 'reviews', type_='foreignkey') + op.drop_constraint('reviews_ibfk_1', 'reviews', type_='foreignkey') + op.drop_index('reviews_room_id', table_name='reviews') + op.drop_index('reviews_status', table_name='reviews') + op.drop_index('reviews_user_id', table_name='reviews') + op.create_index(op.f('ix_reviews_id'), 'reviews', ['id'], unique=False) + op.create_foreign_key(None, 'reviews', 'rooms', ['room_id'], ['id']) + op.create_foreign_key(None, 'reviews', 'users', ['user_id'], ['id']) + op.drop_index('name', table_name='roles') + op.create_index(op.f('ix_roles_id'), 'roles', ['id'], unique=False) + op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True) + op.create_index(op.f('ix_room_types_id'), 'room_types', ['id'], unique=False) + # Drop foreign key first, then indexes + op.drop_constraint('rooms_ibfk_1', 'rooms', type_='foreignkey') + op.drop_index('room_number', table_name='rooms') + op.drop_index('rooms_featured', table_name='rooms') + op.drop_index('rooms_room_type_id', table_name='rooms') + op.drop_index('rooms_status', table_name='rooms') + op.create_index(op.f('ix_rooms_id'), 'rooms', ['id'], unique=False) + op.create_index(op.f('ix_rooms_room_number'), 'rooms', ['room_number'], unique=True) + op.create_foreign_key(None, 'rooms', 'room_types', ['room_type_id'], ['id']) + # Drop foreign keys first, then indexes + op.drop_constraint('service_usages_ibfk_1', 'service_usages', type_='foreignkey') + op.drop_constraint('service_usages_ibfk_2', 'service_usages', type_='foreignkey') + op.drop_index('service_usages_booking_id', table_name='service_usages') + op.drop_index('service_usages_service_id', table_name='service_usages') + op.create_index(op.f('ix_service_usages_id'), 'service_usages', ['id'], unique=False) + op.create_foreign_key(None, 'service_usages', 'bookings', ['booking_id'], ['id']) + op.create_foreign_key(None, 'service_usages', 'services', ['service_id'], ['id']) + op.drop_index('services_category', table_name='services') + op.create_index(op.f('ix_services_id'), 'services', ['id'], unique=False) + # Drop foreign key first, then indexes + op.drop_constraint('users_ibfk_1', 'users', type_='foreignkey') + op.drop_index('email', table_name='users') + op.drop_index('users_email', table_name='users') + op.drop_index('users_role_id', table_name='users') + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_foreign_key(None, 'users', 'roles', ['role_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'users', type_='foreignkey') + op.create_foreign_key('users_ibfk_1', 'users', 'roles', ['role_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.create_index('users_role_id', 'users', ['role_id'], unique=False) + op.create_index('users_email', 'users', ['email'], unique=False) + op.create_index('email', 'users', ['email'], unique=False) + op.drop_index(op.f('ix_services_id'), table_name='services') + op.create_index('services_category', 'services', ['category'], unique=False) + op.drop_constraint(None, 'service_usages', type_='foreignkey') + op.drop_constraint(None, 'service_usages', type_='foreignkey') + op.create_foreign_key('service_usages_ibfk_2', 'service_usages', 'services', ['service_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT') + op.create_foreign_key('service_usages_ibfk_1', 'service_usages', 'bookings', ['booking_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT') + op.drop_index(op.f('ix_service_usages_id'), table_name='service_usages') + op.create_index('service_usages_service_id', 'service_usages', ['service_id'], unique=False) + op.create_index('service_usages_booking_id', 'service_usages', ['booking_id'], unique=False) + op.drop_constraint(None, 'rooms', type_='foreignkey') + op.create_foreign_key('rooms_ibfk_1', 'rooms', 'room_types', ['room_type_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT') + op.drop_index(op.f('ix_rooms_room_number'), table_name='rooms') + op.drop_index(op.f('ix_rooms_id'), table_name='rooms') + op.create_index('rooms_status', 'rooms', ['status'], unique=False) + op.create_index('rooms_room_type_id', 'rooms', ['room_type_id'], unique=False) + op.create_index('rooms_featured', 'rooms', ['featured'], unique=False) + op.create_index('room_number', 'rooms', ['room_number'], unique=False) + op.drop_index(op.f('ix_room_types_id'), table_name='room_types') + op.drop_index(op.f('ix_roles_name'), table_name='roles') + op.drop_index(op.f('ix_roles_id'), table_name='roles') + op.create_index('name', 'roles', ['name'], unique=False) + op.drop_constraint(None, 'reviews', type_='foreignkey') + op.drop_constraint(None, 'reviews', type_='foreignkey') + op.create_foreign_key('reviews_ibfk_1', 'reviews', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') + op.create_foreign_key('reviews_ibfk_2', 'reviews', 'rooms', ['room_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') + op.drop_index(op.f('ix_reviews_id'), table_name='reviews') + op.create_index('reviews_user_id', 'reviews', ['user_id'], unique=False) + op.create_index('reviews_status', 'reviews', ['status'], unique=False) + op.create_index('reviews_room_id', 'reviews', ['room_id'], unique=False) + op.drop_constraint(None, 'refresh_tokens', type_='foreignkey') + op.create_foreign_key('refresh_tokens_ibfk_1', 'refresh_tokens', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') + op.drop_index(op.f('ix_refresh_tokens_token'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_id'), table_name='refresh_tokens') + op.create_index('token', 'refresh_tokens', ['token'], unique=False) + op.create_index('refresh_tokens_user_id', 'refresh_tokens', ['user_id'], unique=False) + op.create_index('refresh_tokens_token', 'refresh_tokens', ['token'], unique=False) + op.drop_index(op.f('ix_promotions_id'), table_name='promotions') + op.drop_index(op.f('ix_promotions_code'), table_name='promotions') + op.create_index('promotions_is_active', 'promotions', ['is_active'], unique=False) + op.create_index('promotions_code', 'promotions', ['code'], unique=False) + op.create_index('code', 'promotions', ['code'], unique=False) + op.drop_constraint(None, 'payments', type_='foreignkey') + op.drop_constraint(None, 'payments', type_='foreignkey') + op.create_foreign_key('payments_ibfk_1', 'payments', 'bookings', ['booking_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT') + op.create_foreign_key('payments_related_payment_id_foreign_idx', 'payments', 'payments', ['related_payment_id'], ['id'], onupdate='CASCADE', ondelete='SET NULL') + op.drop_index(op.f('ix_payments_id'), table_name='payments') + op.create_index('payments_payment_status', 'payments', ['payment_status'], unique=False) + op.create_index('payments_booking_id', 'payments', ['booking_id'], unique=False) + op.alter_column('payments', 'deposit_percentage', + existing_type=mysql.INTEGER(), + comment='Percentage of deposit (e.g., 20, 30, 50)', + existing_nullable=True) + op.drop_constraint(None, 'password_reset_tokens', type_='foreignkey') + op.create_foreign_key('password_reset_tokens_ibfk_1', 'password_reset_tokens', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') + op.drop_index(op.f('ix_password_reset_tokens_token'), table_name='password_reset_tokens') + op.drop_index(op.f('ix_password_reset_tokens_id'), table_name='password_reset_tokens') + op.create_index('token', 'password_reset_tokens', ['token'], unique=False) + op.create_index('password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'], unique=False) + op.create_index('password_reset_tokens_token', 'password_reset_tokens', ['token'], unique=False) + op.alter_column('password_reset_tokens', 'used', + existing_type=mysql.TINYINT(display_width=1), + nullable=True, + existing_server_default=sa.text("'0'")) + op.drop_constraint(None, 'favorites', type_='foreignkey') + op.drop_constraint(None, 'favorites', type_='foreignkey') + op.create_foreign_key('favorites_ibfk_1', 'favorites', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') + op.create_foreign_key('favorites_ibfk_2', 'favorites', 'rooms', ['room_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') + op.drop_index(op.f('ix_favorites_id'), table_name='favorites') + op.create_index('unique_user_room_favorite', 'favorites', ['user_id', 'room_id'], unique=False) + op.create_index('favorites_user_id', 'favorites', ['user_id'], unique=False) + op.create_index('favorites_room_id', 'favorites', ['room_id'], unique=False) + op.drop_constraint(None, 'checkin_checkout', type_='foreignkey') + op.drop_constraint(None, 'checkin_checkout', type_='foreignkey') + op.drop_constraint(None, 'checkin_checkout', type_='foreignkey') + op.create_foreign_key('checkin_checkout_ibfk_3', 'checkin_checkout', 'users', ['checkout_by'], ['id'], onupdate='CASCADE', ondelete='SET NULL') + op.create_foreign_key('checkin_checkout_ibfk_2', 'checkin_checkout', 'users', ['checkin_by'], ['id'], onupdate='CASCADE', ondelete='SET NULL') + op.create_foreign_key('checkin_checkout_ibfk_1', 'checkin_checkout', 'bookings', ['booking_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT') + op.drop_constraint(None, 'checkin_checkout', type_='unique') + op.drop_index(op.f('ix_checkin_checkout_id'), table_name='checkin_checkout') + op.create_index('checkin_checkout_booking_id', 'checkin_checkout', ['booking_id'], unique=False) + op.drop_constraint(None, 'bookings', type_='foreignkey') + op.drop_constraint(None, 'bookings', type_='foreignkey') + op.create_foreign_key('bookings_ibfk_1', 'bookings', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT') + op.create_foreign_key('bookings_ibfk_2', 'bookings', 'rooms', ['room_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT') + op.drop_index(op.f('ix_bookings_id'), table_name='bookings') + op.drop_index(op.f('ix_bookings_booking_number'), table_name='bookings') + op.create_index('bookings_user_id', 'bookings', ['user_id'], unique=False) + op.create_index('bookings_status', 'bookings', ['status'], unique=False) + op.create_index('bookings_room_id', 'bookings', ['room_id'], unique=False) + op.create_index('bookings_check_out_date', 'bookings', ['check_out_date'], unique=False) + op.create_index('bookings_check_in_date', 'bookings', ['check_in_date'], unique=False) + op.create_index('bookings_booking_number', 'bookings', ['booking_number'], unique=False) + op.create_index('booking_number', 'bookings', ['booking_number'], unique=False) + op.drop_index(op.f('ix_banners_id'), table_name='banners') + op.create_index('banners_position', 'banners', ['position'], unique=False) + op.create_index('banners_is_active', 'banners', ['is_active'], unique=False) + op.create_table('SequelizeMeta', + sa.Column('name', mysql.VARCHAR(collation='utf8mb3_unicode_ci', length=255), nullable=False), + sa.PrimaryKeyConstraint('name'), + mysql_collate='utf8mb3_unicode_ci', + mysql_default_charset='utf8mb3', + mysql_engine='InnoDB' + ) + op.create_index('name', 'SequelizeMeta', ['name'], unique=False) + op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_resource_id'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_request_id'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_id'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs') + op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs') + op.drop_table('audit_logs') + # ### end Alembic commands ### + diff --git a/Backend/alembic/versions/__pycache__/59baf2338f8a_initial_migration_create_all_tables_.cpython-312.pyc b/Backend/alembic/versions/__pycache__/59baf2338f8a_initial_migration_create_all_tables_.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1cf866f581d46155e46cd0d34f8810bad713d0d3 GIT binary patch literal 24262 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 str: + """Hash password using bcrypt""" + password_bytes = password.encode('utf-8') + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password_bytes, salt) + return hashed.decode('utf-8') + + +def reset_password(db: Session, email: str, new_password: str) -> bool: + """Reset password for a user""" + user = db.query(User).filter(User.email == email).first() + + if not user: + print(f"❌ User with email '{email}' not found") + return False + + # Hash new password + hashed_password = hash_password(new_password) + + # Update password + user.password = hashed_password + db.commit() + db.refresh(user) + + print(f"✅ Password reset for {email}") + print(f" New password: {new_password}") + print(f" Hash length: {len(user.password)} characters") + print() + + return True + + +def main(): + """Reset passwords for all test users""" + db = SessionLocal() + + try: + print("="*80) + print("RESETTING TEST USER PASSWORDS") + print("="*80) + print() + + test_users = [ + {"email": "admin@hotel.com", "password": "admin123"}, + {"email": "staff@hotel.com", "password": "staff123"}, + {"email": "customer@hotel.com", "password": "customer123"}, + ] + + for user_data in test_users: + reset_password(db, user_data["email"], user_data["password"]) + + print("="*80) + print("SUMMARY") + print("="*80) + print("All test user passwords have been reset.") + print("\nYou can now login with:") + for user_data in test_users: + print(f" {user_data['email']:<25} Password: {user_data['password']}") + print() + + except Exception as e: + logger.error(f"Error: {e}", exc_info=True) + print(f"\n❌ Error: {e}") + db.rollback() + finally: + db.close() + + +if __name__ == "__main__": + main() + diff --git a/Backend/run.py b/Backend/run.py index 22854924..77a5dd1e 100644 --- a/Backend/run.py +++ b/Backend/run.py @@ -3,21 +3,46 @@ Main entry point for the FastAPI server """ import uvicorn -import os -from dotenv import load_dotenv +from src.config.settings import settings +from src.config.logging_config import setup_logging, get_logger -load_dotenv() +# Setup logging +setup_logging() +logger = get_logger(__name__) if __name__ == "__main__": - port = int(os.getenv("PORT", 8000)) - host = os.getenv("HOST", "0.0.0.0") - reload = os.getenv("NODE_ENV") == "development" + logger.info(f"Starting {settings.APP_NAME} on {settings.HOST}:{settings.PORT}") + + import os + from pathlib import Path + + # Only watch the src directory to avoid watching logs, uploads, etc. + base_dir = Path(__file__).parent + src_dir = str(base_dir / "src") + + # Temporarily disable reload to stop constant "1 change detected" messages + # The file watcher is detecting changes that cause a loop + # TODO: Investigate what's causing constant file changes + use_reload = False # Disabled until we identify the source of constant changes uvicorn.run( "src.main:app", - host=host, - port=port, - reload=reload, - log_level="info" + host=settings.HOST, + port=8000, + reload=use_reload, + log_level=settings.LOG_LEVEL.lower(), + reload_dirs=[src_dir] if use_reload else None, + reload_excludes=[ + "*.log", + "*.pyc", + "*.pyo", + "*.pyd", + "__pycache__", + "**/__pycache__/**", + "*.db", + "*.sqlite", + "*.sqlite3" + ], + reload_delay=1.0 # Increase delay to reduce false positives ) diff --git a/Backend/src/__pycache__/main.cpython-312.pyc b/Backend/src/__pycache__/main.cpython-312.pyc index 719362bc95d8ac9b837b6c76c928c0248ebd28ca..8a1eb58b9e6d4f271ec93c17c57950dd4b9a13bb 100644 GIT binary patch literal 12849 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 literal 1523 zcmah|O>7%Q6rR~#+q>(3J56z0BGMLuDa{sDK!^b0C@DoP4vJHx?#0^dOl&v19<#go zaVV07dPqPt6^9n7suYP!P>PfbQjR@F;sD4Ttbs@^T*xgb9I6N*X4Y}qNJNaZZ{GXn zo3}IHd-HuXssc9t`5{xeDFX024?4qNVZqLA2*4eH0D=~Or<1utK)1%Mb_v%d2}i{U~@X)%nGf83j>jRcOb$%J%#>OGe5*D|{$RMd5u` zBt-)F^80;_icO5!lHDIE3v7_(^{xeM;8dYt8fIF7o|5!8*29-8%; zRi-H)C^PjU0QZlr2HUZoV0!}SI696K_fM>{n((0~87g*E)Klo$8$eIjrEJd@U(P}r zH63htxQMHzN)sCnBHm0zXXF%8@1}Y-r;pz+x4G$Hs z6h_9VOr?oR6`5SQ&XAaMihVmCGnW8l{qF5m2!M|6p8y412e*Yg>;+s0*Y;}D zNx|n16M{NR_kWyOFehFGv+!GS7G_~SE6_-xU7*WcQyMTW;@3?;6D~i^bGvV8WnyEk zRGrg?u{-6Fns$+wQIt`Yv$ExQ%&y%sw z%`)=`c-#QLDn~x=X$)-(Vj$g$B|eiM%IcE5D1V_oUJ%;O-)FeH%j5w3 zI`yjfoj3rqT~uYIHY~G@?YhH-p~s#8{dmsnf2tiFt~QN5MIest@NS?2acQvKj}Ctk z{)t(0Iw2M~3O8YHqTIxpp^@R-cy1_H7%5&EyG(=a{&#x$k~GAw?cenqu)_f0oMvWM ze$yss+QM%;udtBo>i)*ewg@5I1gAH_>zknO7oco{o<|^jBf1>~;jV>~OWDQjt@pl~ zX{s+YLM=7Dk2p>Bu?t3x- diff --git a/Backend/src/config/__pycache__/logging_config.cpython-312.pyc b/Backend/src/config/__pycache__/logging_config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..286c8eb7a13b8c28dc63227e95675cc02e4ecc63 GIT binary patch literal 4004 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 logging.Logger: + """ + Setup structured logging with file and console handlers + + Args: + log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + log_file: Path to log file + enable_file_logging: Whether to enable file logging + + Returns: + Configured root logger + """ + # Get configuration from settings + level = log_level or settings.LOG_LEVEL + log_file_path = log_file or settings.LOG_FILE + + # Convert string level to logging constant + numeric_level = getattr(logging, level.upper(), logging.INFO) + + # Create logs directory if it doesn't exist + if enable_file_logging and log_file_path: + log_path = Path(log_file_path) + log_path.parent.mkdir(parents=True, exist_ok=True) + + # Create formatter with structured format + detailed_formatter = logging.Formatter( + fmt='%(asctime)s | %(levelname)-8s | %(name)s | %(funcName)s:%(lineno)d | %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + simple_formatter = logging.Formatter( + fmt='%(asctime)s | %(levelname)-8s | %(message)s', + datefmt='%H:%M:%S' + ) + + # Configure root logger + root_logger = logging.getLogger() + root_logger.setLevel(numeric_level) + + # Remove existing handlers + root_logger.handlers.clear() + + # Console handler (always enabled) + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(numeric_level) + console_handler.setFormatter(simple_formatter if settings.is_development else detailed_formatter) + root_logger.addHandler(console_handler) + + # File handler (rotating) - Disabled in development to avoid file watcher issues + if enable_file_logging and log_file_path and not settings.is_development: + file_handler = RotatingFileHandler( + log_file_path, + maxBytes=settings.LOG_MAX_BYTES, + backupCount=settings.LOG_BACKUP_COUNT, + encoding='utf-8' + ) + file_handler.setLevel(numeric_level) + file_handler.setFormatter(detailed_formatter) + root_logger.addHandler(file_handler) + + # Set levels for third-party loggers + logging.getLogger("uvicorn").setLevel(logging.INFO) + logging.getLogger("uvicorn.access").setLevel(logging.INFO if settings.is_development else logging.WARNING) + logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) + logging.getLogger("slowapi").setLevel(logging.WARNING) + + return root_logger + + +def get_logger(name: str) -> logging.Logger: + """ + Get a logger instance with the given name + + Args: + name: Logger name (typically __name__) + + Returns: + Logger instance + """ + return logging.getLogger(name) + diff --git a/Backend/src/config/settings.py b/Backend/src/config/settings.py new file mode 100644 index 00000000..2881b6ab --- /dev/null +++ b/Backend/src/config/settings.py @@ -0,0 +1,119 @@ +""" +Enterprise-grade configuration management using Pydantic Settings +""" +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import Field +from typing import List +import os + + +class Settings(BaseSettings): + """Application settings with environment variable support""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore" + ) + + # Application + APP_NAME: str = Field(default="Hotel Booking API", description="Application name") + APP_VERSION: str = Field(default="1.0.0", description="Application version") + ENVIRONMENT: str = Field(default="development", description="Environment: development, staging, production") + DEBUG: bool = Field(default=False, description="Debug mode") + API_V1_PREFIX: str = Field(default="/api/v1", description="API v1 prefix") + + # Server + HOST: str = Field(default="0.0.0.0", description="Server host") + PORT: int = Field(default=8000, description="Server port") + + # Database + DB_USER: str = Field(default="root", description="Database user") + DB_PASS: str = Field(default="", description="Database password") + DB_NAME: str = Field(default="hotel_db", description="Database name") + DB_HOST: str = Field(default="localhost", description="Database host") + DB_PORT: str = Field(default="3306", description="Database port") + + # Security + JWT_SECRET: str = Field(default="dev-secret-key-change-in-production-12345", description="JWT secret key") + JWT_ALGORITHM: str = Field(default="HS256", description="JWT algorithm") + JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description="JWT access token expiration in minutes") + JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, description="JWT refresh token expiration in days") + + # CORS + CLIENT_URL: str = Field(default="http://localhost:5173", description="Frontend client URL") + CORS_ORIGINS: List[str] = Field( + default_factory=lambda: [ + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173" + ], + description="Allowed CORS origins" + ) + + # Rate Limiting + RATE_LIMIT_ENABLED: bool = Field(default=True, description="Enable rate limiting") + RATE_LIMIT_PER_MINUTE: int = Field(default=60, description="Requests per minute per IP") + + # Logging + LOG_LEVEL: str = Field(default="INFO", description="Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL") + LOG_FILE: str = Field(default="logs/app.log", description="Log file path") + LOG_MAX_BYTES: int = Field(default=10485760, description="Max log file size (10MB)") + LOG_BACKUP_COUNT: int = Field(default=5, description="Number of backup log files") + + # Email + SMTP_HOST: str = Field(default="smtp.gmail.com", description="SMTP host") + SMTP_PORT: int = Field(default=587, description="SMTP port") + SMTP_USER: str = Field(default="", description="SMTP username") + SMTP_PASSWORD: str = Field(default="", description="SMTP password") + SMTP_FROM_EMAIL: str = Field(default="", description="From email address") + SMTP_FROM_NAME: str = Field(default="Hotel Booking", description="From name") + + # File Upload + UPLOAD_DIR: str = Field(default="uploads", description="Upload directory") + MAX_UPLOAD_SIZE: int = Field(default=5242880, description="Max upload size in bytes (5MB)") + ALLOWED_EXTENSIONS: List[str] = Field( + default_factory=lambda: ["jpg", "jpeg", "png", "gif", "webp"], + description="Allowed file extensions" + ) + + # Redis (for caching) + REDIS_ENABLED: bool = Field(default=False, description="Enable Redis caching") + REDIS_HOST: str = Field(default="localhost", description="Redis host") + REDIS_PORT: int = Field(default=6379, description="Redis port") + REDIS_DB: int = Field(default=0, description="Redis database number") + REDIS_PASSWORD: str = Field(default="", description="Redis password") + + # Request Timeout + REQUEST_TIMEOUT: int = Field(default=30, description="Request timeout in seconds") + + # Health Check + HEALTH_CHECK_INTERVAL: int = Field(default=30, description="Health check interval in seconds") + + @property + def database_url(self) -> str: + """Construct database URL""" + return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + @property + def is_production(self) -> bool: + """Check if running in production""" + return self.ENVIRONMENT.lower() == "production" + + @property + def is_development(self) -> bool: + """Check if running in development""" + return self.ENVIRONMENT.lower() == "development" + + @property + def redis_url(self) -> str: + """Construct Redis URL""" + if self.REDIS_PASSWORD: + return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + + +# Global settings instance +settings = Settings() + diff --git a/Backend/src/main.py b/Backend/src/main.py index 6e3d2cc2..ce48c970 100644 --- a/Backend/src/main.py +++ b/Backend/src/main.py @@ -1,17 +1,30 @@ -from fastapi import FastAPI, Request, HTTPException +from fastapi import FastAPI, Request, HTTPException, Depends, status from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import JSONResponse from fastapi.exceptions import RequestValidationError -from sqlalchemy.exc import IntegrityError +from sqlalchemy.exc import IntegrityError, OperationalError from jose.exceptions import JWTError from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded -import os from pathlib import Path +from datetime import datetime +import sys -from .config.database import engine, Base +# Import configuration and logging FIRST +from .config.settings import settings +from .config.logging_config import setup_logging, get_logger +from .config.database import engine, Base, get_db +from . import models # noqa: F401 - ensure models are imported so tables are created +from sqlalchemy.orm import Session + +# Setup logging before anything else +logger = setup_logging() + +logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION} in {settings.ENVIRONMENT} mode") + +# Import middleware from .middleware.error_handler import ( validation_exception_handler, integrity_error_handler, @@ -19,38 +32,65 @@ from .middleware.error_handler import ( http_exception_handler, general_exception_handler ) -# Create database tables -Base.metadata.create_all(bind=engine) +from .middleware.request_id import RequestIDMiddleware +from .middleware.security import SecurityHeadersMiddleware +from .middleware.timeout import TimeoutMiddleware +from .middleware.cookie_consent import CookieConsentMiddleware + +# Create database tables (for development, migrations should be used in production) +if settings.is_development: + logger.info("Creating database tables (development mode)") + Base.metadata.create_all(bind=engine) +else: + # Ensure new cookie-related tables exist even if full migrations haven't been run yet. + try: + from .models.cookie_policy import CookiePolicy + from .models.cookie_integration_config import CookieIntegrationConfig + logger.info("Ensuring cookie-related tables exist") + CookiePolicy.__table__.create(bind=engine, checkfirst=True) + CookieIntegrationConfig.__table__.create(bind=engine, checkfirst=True) + except Exception as e: + logger.error(f"Failed to ensure cookie tables exist: {e}") from .routes import auth_routes +from .routes import privacy_routes # Initialize FastAPI app app = FastAPI( - title="Hotel Booking API", - description="Hotel booking backend API", - version="1.0.0" + title=settings.APP_NAME, + description="Enterprise-grade Hotel Booking API", + version=settings.APP_VERSION, + docs_url="/api/docs" if not settings.is_production else None, + redoc_url="/api/redoc" if not settings.is_production else None, + openapi_url="/api/openapi.json" if not settings.is_production else None ) +# Add middleware in order (order matters!) +# 1. Request ID middleware (first to add request ID) +app.add_middleware(RequestIDMiddleware) + +# 2. Cookie consent middleware (makes consent available on request.state) +app.add_middleware(CookieConsentMiddleware) + +# 3. Timeout middleware +if settings.REQUEST_TIMEOUT > 0: + app.add_middleware(TimeoutMiddleware) + +# 4. Security headers middleware +app.add_middleware(SecurityHeadersMiddleware) + # Rate limiting -limiter = Limiter(key_func=get_remote_address) -app.state.limiter = limiter -app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +if settings.RATE_LIMIT_ENABLED: + limiter = Limiter( + key_func=get_remote_address, + default_limits=[f"{settings.RATE_LIMIT_PER_MINUTE}/minute"] + ) + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + logger.info(f"Rate limiting enabled: {settings.RATE_LIMIT_PER_MINUTE} requests/minute") # CORS configuration -# Allow multiple origins for development -client_url = os.getenv("CLIENT_URL", "http://localhost:5173") -allowed_origins = [ - client_url, - "http://localhost:5173", # Vite default - "http://localhost:3000", # Alternative port - "http://localhost:5174", # Vite alternative - "http://127.0.0.1:5173", - "http://127.0.0.1:3000", - "http://127.0.0.1:5174", -] - -# In development, allow all localhost origins using regex -if os.getenv("ENVIRONMENT", "development") == "development": +if settings.is_development: # For development, use regex to allow any localhost port app.add_middleware( CORSMiddleware, @@ -59,18 +99,20 @@ if os.getenv("ENVIRONMENT", "development") == "development": allow_methods=["*"], allow_headers=["*"], ) + logger.info("CORS configured for development (allowing localhost)") else: # Production: use specific origins app.add_middleware( CORSMiddleware, - allow_origins=allowed_origins, + allow_origins=settings.CORS_ORIGINS, allow_credentials=True, - allow_methods=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], allow_headers=["*"], ) + logger.info(f"CORS configured for production with {len(settings.CORS_ORIGINS)} allowed origins") # Serve static files (uploads) -uploads_dir = Path(__file__).parent.parent / "uploads" +uploads_dir = Path(__file__).parent.parent / settings.UPLOAD_DIR uploads_dir.mkdir(exist_ok=True) app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads") @@ -81,25 +123,82 @@ app.add_exception_handler(IntegrityError, integrity_error_handler) app.add_exception_handler(JWTError, jwt_error_handler) app.add_exception_handler(Exception, general_exception_handler) -# Health check -@app.get("/health") -async def health_check(): +# Enhanced Health check with database connectivity +@app.get("/health", tags=["health"]) +async def health_check(db: Session = Depends(get_db)): + """ + Enhanced health check endpoint with database connectivity test + """ + health_status = { + "status": "healthy", + "timestamp": datetime.utcnow().isoformat(), + "service": settings.APP_NAME, + "version": settings.APP_VERSION, + "environment": settings.ENVIRONMENT, + "checks": { + "api": "ok", + "database": "unknown" + } + } + + # Check database connectivity + try: + from sqlalchemy import text + db.execute(text("SELECT 1")) + health_status["checks"]["database"] = "ok" + except OperationalError as e: + health_status["status"] = "unhealthy" + health_status["checks"]["database"] = "error" + health_status["error"] = str(e) + logger.error(f"Database health check failed: {str(e)}") + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content=health_status + ) + except Exception as e: + health_status["status"] = "unhealthy" + health_status["checks"]["database"] = "error" + health_status["error"] = str(e) + logger.error(f"Health check failed: {str(e)}") + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content=health_status + ) + + return health_status + + +# Metrics endpoint (basic) +@app.get("/metrics", tags=["monitoring"]) +async def metrics(): + """ + Basic metrics endpoint (can be extended with Prometheus or similar) + """ return { "status": "success", - "message": "Server is running", - "timestamp": __import__("datetime").datetime.utcnow().isoformat() + "service": settings.APP_NAME, + "version": settings.APP_VERSION, + "environment": settings.ENVIRONMENT, + "timestamp": datetime.utcnow().isoformat() } -# API Routes +# API Routes with versioning +# Legacy routes (maintain backward compatibility) app.include_router(auth_routes.router, prefix="/api") +app.include_router(privacy_routes.router, prefix="/api") + +# Versioned API routes (v1) +app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(privacy_routes.router, prefix=settings.API_V1_PREFIX) # Import and include other routes from .routes import ( room_routes, booking_routes, payment_routes, banner_routes, favorite_routes, service_routes, promotion_routes, report_routes, - review_routes, user_routes + review_routes, user_routes, audit_routes, admin_privacy_routes ) +# Legacy routes (maintain backward compatibility) app.include_router(room_routes.router, prefix="/api") app.include_router(booking_routes.router, prefix="/api") app.include_router(payment_routes.router, prefix="/api") @@ -110,12 +209,66 @@ app.include_router(promotion_routes.router, prefix="/api") app.include_router(report_routes.router, prefix="/api") app.include_router(review_routes.router, prefix="/api") app.include_router(user_routes.router, prefix="/api") +app.include_router(audit_routes.router, prefix="/api") +app.include_router(admin_privacy_routes.router, prefix="/api") -# Note: FastAPI automatically handles 404s for unmatched routes -# This handler is kept for custom 404 responses but may not be needed +# Versioned routes (v1) +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) +app.include_router(banner_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(favorite_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(service_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(promotion_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(report_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(review_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(user_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX) + +logger.info("All routes registered successfully") + +# Startup event +@app.on_event("startup") +async def startup_event(): + """Run on application startup""" + logger.info(f"{settings.APP_NAME} started successfully") + logger.info(f"Environment: {settings.ENVIRONMENT}") + logger.info(f"Debug mode: {settings.DEBUG}") + logger.info(f"API version: {settings.API_V1_PREFIX}") + +# Shutdown event +@app.on_event("shutdown") +async def shutdown_event(): + """Run on application shutdown""" + logger.info(f"{settings.APP_NAME} shutting down gracefully") if __name__ == "__main__": import uvicorn - port = int(os.getenv("PORT", 3000)) - uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True) + from pathlib import Path + + # Only watch the src directory to avoid watching logs, uploads, etc. + base_dir = Path(__file__).parent.parent + src_dir = str(base_dir / "src") + + uvicorn.run( + "src.main:app", + host=settings.HOST, + port=settings.PORT, + reload=settings.is_development, + log_level=settings.LOG_LEVEL.lower(), + reload_dirs=[src_dir] if settings.is_development else None, + reload_excludes=[ + "*.log", + "*.pyc", + "*.pyo", + "*.pyd", + "__pycache__", + "**/__pycache__/**", + "*.db", + "*.sqlite", + "*.sqlite3" + ], + reload_delay=0.5 # Increase delay to reduce false positives + ) diff --git a/Backend/src/middleware/__pycache__/auth.cpython-312.pyc b/Backend/src/middleware/__pycache__/auth.cpython-312.pyc index 69f3ec2d179997d98a3fcf2a5ccde2cc1df09d3e..045b62bc919cd2a508b5312acd6db87c3d8cdeb9 100644 GIT binary patch delta 1026 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 literal 0 HcmV?d00001 diff --git a/Backend/src/middleware/__pycache__/error_handler.cpython-312.pyc b/Backend/src/middleware/__pycache__/error_handler.cpython-312.pyc index 1775f1b9c2010e1a865c4e9dc6f2428da85f3148..93da6889e9c24dbf9b2bec63dd01815bec8f22b4 100644 GIT binary patch delta 1663 zcma)6-A`Oa6rb7qz2D2%@?l|1S7C{`utWuHV{I&2X|41ln#QRi6LKG@0V9b%G@&Lm_)z^9d|@Rin;VFl_@FPkjhNUsXIL&v6Ca%9p7Z;ibLPxB zcRs#5Rej3;gU{y%bZ~ZC=0R}D-wM~3dahiS5L9I(P4TMo5r4y{*0@5sn{xjl_fYPk zJaEW4%Dt2a4|#}kALR}Awi2%1SK`xp9>vqzT>NEZ%n-d0e^nClp%sBK`gnIRM511j|5$ zsw)Tbu;_veb2(R@D{>IHfaxMKYm<$2SkpzGcxng$6J)(sRcI~=>ogl1MZx5=ff_z8 zRgr`oZbBzl_9(mK17HQH5@~dJJ$40r!J7hIvB~k(bt&6YCk~>atq!j1|67JCuhLz- z>vkH0fOI%nOh}$^@Vbkl68-Q^(&ZRUsCo_@%$_x2HsW~dx+p#t?>&kiq4C}II0qG_ zV+BYg>e$)5w-~`b(&HFS(Uk7`oRn9ADPhmCpDH;Uph4ANl(mD&o^J! zrc;`lLuv%gO`-zH=-Oyx-ZdJD@pzafviU-aOxt1}k!fAE{YOzr@_qqbi@9t8%@Lfk zW$H<0v?-`tXZ>eNZFcjdu1#gq&#)5c!pR!N&#|O|L!VO=ex6z7WKv7zQ8H;uwDuGs z*cJ?`5oX8N=Glrk$RIb1b9jUzJj%oXvnl7D25=9szW0Z%2mU8Z{)~}S(M^=o3wfjw zTcNd1qIc;_VY{bvoKKM?Ej;GN>=nc8J|9$AD@d&UzJniu*+IvHOoXYhgBTeFT{94C zjL_5u%Mdvlm>mP#8Vq!lE>ZLj{fr6va{LDVSpa{_Kp5KN`+o6-SLJQr$%V1qQ0wjR zmnS|yamTx%Ziil2c&#b|!Sl(*4=z^RU0?HSSHDtrB8hS&vDG!Sk@$A<{;AFBjT>9? z*`M2^m8Op6@ul&drf9h-T4_14tSxD~o!x6gE18|nfwpqzz(Yj{xT{{!&{7QnA+!$^ zLELY(J`F6sLDy+)uQawl6u4G#i*KS)a&SlL{88%MjrQ+Ehs)97?da&1ObaZ_Cx7*X zD@|RMU}Rt5ykZq_g80Y~X?Gu*rj6|#x{)I zI;JtUW9-qf7h?y;Ra-lkD0of%Lp8E#31uXto~Q3ZcW$0;gHXu5wH#5fDYwWiK-inJ zS&t#Sq5c*Yq8zSp0e?SZ6aY5?1TTOLN!etHP=T+&YidKcKp@j44*&^^p_Qd*GDUi- z6#(RuaenosJuttavv3NV5uiZXZs;3$d$LeQc9k>$6ewCudqRN;x8W(vEOmy@nEY$p${UG;v-lnT0EX=9b7xEABW@6(6uQ!=@guhe1}6tc?r1hq%1G0x*%>J* ztKF{NHn+yiqIgo)s8m{G?@O|j%xG2_MWWKZNOVfGo_Z&xhf(#bs|~tU-W|Fi#8PM` zA`@$fL1f4fc~gj7AnG7L(!n%?f9jgMxxWu9lf>e|@Ay0T^%xWdzz9o2%U74XmfH6% zfg{ekIQL|3IgsaqZybAE_=xu$@O8U<-9BGmWYwr=Vg8$~=FoTUOLhHWO~Vhy;t`50 z=(_xVcx!k&k?$Pdnb_+bU*=Z*2mbJ`Kb-fse|B9gaD>NjcD4XGhAjvn0;BLB>q(l$ z#XTKpALxJ~pT@-^X*n_*M`Y9xnKE>Gk`eC&+f7i9y6LTDdx_eoe)I-}>sTcdn$D`n b-XOH9eAQ_6ImoV$6#y37-9;kM5T^VEf(YT( diff --git a/Backend/src/middleware/__pycache__/request_id.cpython-312.pyc b/Backend/src/middleware/__pycache__/request_id.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ea6154921d4ddb4384e707cb58df5eadcf83d219 GIT binary patch literal 2747 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 Lr(PoXw|xEsv+z09 literal 0 HcmV?d00001 diff --git a/Backend/src/middleware/__pycache__/timeout.cpython-312.pyc b/Backend/src/middleware/__pycache__/timeout.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12ef31a5ee55d99c8c41a889893149d039262c1d GIT binary patch literal 1967 zcma)7Uu;uV7(e%aZ@0Fii0hzZbpw_nYx%c;&X{aiH-i*}5<+>nx%QlOx7^-4&OOWi zhzWtkU}B+Dp4S^#vxDW1e1`x;fOOP(Lue|_heR+AU|R? z!K3KR4Dv{VSY_nSkVM&n&_=T+M!^UCYBniRt#yfpCUUtWnYjX*CYI|^)gvaE@sa|K zj+q|f!b9!n4@CBwiGoE$4BIUh5oTP#LxfmP(JM6lXi|QauhlpsmjD7Rc#s#Nke8r{ zmy-%zmuq^uW@({&6vB1@#7*F4Odi!*sL+bKVMJ&20yp+E%g{j}FoQZE zt!x2M@||g1ZW0lk70(KOMv3f;N~(|6_M5_umWB)LYre)Tzbjpm-cS_qx^z}L2KIrs z)B!-+4@!YHY=bQTUlRanR1H5Yh1#MvfXt&(xXre~WdJ26>1rReK`GjXZEzWUAd?L~ z%icZd7Ixz5mV~RIbQ3RX=|$13kVMWTTHtyha{dP=f)eUlDHx{kP|Z zSuiJ^1hc{?(yWl|$g$rZ7)7 zQ=_0cf=#wz?Ba#~$8@C4T8kjXLVobUi^J=A$qnTHZXO88J+)IZthmYrIXR8S^+y|;Q+ly+f zZ5`HJcpZNv&0rhzPfj(~f+iuDD%>o>yndT5#dyye!#npH9wXpFY?Va*v{F8Wh8TkeLaSqhPZasXzcT2T* zsM0%hx2xy9!8=|3E3rg1wzCr3Sy!aZ;opH23tyb51;7*Wi-&7Ey!D@U*CB9>&Z?M|8uhO^gp{{gCYYGT$s)Il% zT#thFiIsSNH9k^_kNmKC>vG?Lhl&)7))jSqxCYcvxVC}0@|U-bMR#5}uuE7>jeyUE zXXG!G5pbIyd?k#4ugCTrNJ-zOB*tfxDh(KhW0sL&(4b+IT{vTN+-VqRW=y*!(F_B+ z1;fC61Y)lLI#^{C;H{i=_=HawkJrs2xGO(ZlVeKwTCon;hIjaPk`cU};}|1Un>1N9 zO(r~PB`}|sbVI>)Caq$M*DNaz)&iWxdn zxlP{nO>dv(sNoZj;8EbYX5`P~U5w>p+#6=3CJBP@P!t7uRRu!id$79#cHaa0?}5}k du CookieConsent: + if not raw_value: + return CookieConsent() # Defaults: only necessary = True + + try: + data = json.loads(raw_value) + # Pydantic will validate and coerce as needed + return CookieConsent(**data) + except Exception as exc: # pragma: no cover - defensive + logger.warning(f"Failed to parse cookie consent cookie: {exc}") + return CookieConsent() + + +class CookieConsentMiddleware(BaseHTTPMiddleware): + """ + Middleware that parses the cookie consent cookie (if present) and attaches it + to `request.state.cookie_consent` for downstream handlers. + """ + + async def dispatch( + self, request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + raw_cookie = request.cookies.get(COOKIE_CONSENT_COOKIE_NAME) + consent = _parse_consent_cookie(raw_cookie) + + # Ensure 'necessary' is always true regardless of stored value + consent.categories.necessary = True + + request.state.cookie_consent = consent + + response = await call_next(request) + + # If there's no cookie yet, set a minimal default consent cookie + # so that the banner can be rendered based on server-side knowledge. + if COOKIE_CONSENT_COOKIE_NAME not in request.cookies: + try: + response.set_cookie( + key=COOKIE_CONSENT_COOKIE_NAME, + value=consent.model_dump_json(), + httponly=True, + secure=settings.is_production, + samesite="lax", + max_age=365 * 24 * 60 * 60, # 1 year + path="/", + ) + except Exception as exc: # pragma: no cover - defensive + logger.warning(f"Failed to set default cookie consent cookie: {exc}") + + return response + + +def is_analytics_allowed(request: Request) -> bool: + consent: CookieConsent | None = getattr(request.state, "cookie_consent", None) + if not consent: + return False + return consent.categories.analytics + + +def is_marketing_allowed(request: Request) -> bool: + consent: CookieConsent | None = getattr(request.state, "cookie_consent", None) + if not consent: + return False + return consent.categories.marketing + + +def is_preferences_allowed(request: Request) -> bool: + consent: CookieConsent | None = getattr(request.state, "cookie_consent", None) + if not consent: + return False + return consent.categories.preferences + + diff --git a/Backend/src/middleware/error_handler.py b/Backend/src/middleware/error_handler.py index 0cafedd0..41fa3be9 100644 --- a/Backend/src/middleware/error_handler.py +++ b/Backend/src/middleware/error_handler.py @@ -3,7 +3,6 @@ from fastapi.responses import JSONResponse from fastapi.exceptions import RequestValidationError from sqlalchemy.exc import IntegrityError from jose.exceptions import JWTError -import os import traceback @@ -96,10 +95,23 @@ async def general_exception_handler(request: Request, exc: Exception): """ Handle all other exceptions """ - # Log error - print(f"Error: {exc}") - if os.getenv("NODE_ENV") == "development": - traceback.print_exc() + from ..config.logging_config import get_logger + from ..config.settings import settings + + logger = get_logger(__name__) + request_id = getattr(request.state, "request_id", None) + + # Log error with context + logger.error( + f"Unhandled exception: {type(exc).__name__}: {str(exc)}", + extra={ + "request_id": request_id, + "path": request.url.path, + "method": request.method, + "exception_type": type(exc).__name__ + }, + exc_info=True + ) # Handle HTTPException with dict detail if isinstance(exc, Exception) and hasattr(exc, "status_code"): @@ -116,12 +128,17 @@ async def general_exception_handler(request: Request, exc: Exception): status_code = status.HTTP_500_INTERNAL_SERVER_ERROR message = str(exc) if str(exc) else "Internal server error" + response_content = { + "status": "error", + "message": message + } + + # Add stack trace in development + if settings.is_development: + response_content["stack"] = traceback.format_exc() + return JSONResponse( status_code=status_code, - content={ - "status": "error", - "message": message, - **({"stack": traceback.format_exc()} if os.getenv("NODE_ENV") == "development" else {}) - } + content=response_content ) diff --git a/Backend/src/middleware/request_id.py b/Backend/src/middleware/request_id.py new file mode 100644 index 00000000..6fe0732c --- /dev/null +++ b/Backend/src/middleware/request_id.py @@ -0,0 +1,65 @@ +""" +Request ID middleware for tracking requests across services +""" +import uuid +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response +from ..config.logging_config import get_logger + +logger = get_logger(__name__) + + +class RequestIDMiddleware(BaseHTTPMiddleware): + """Add unique request ID to each request for tracing""" + + async def dispatch(self, request: Request, call_next): + # Generate or get request ID + request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) + + # Add request ID to request state + request.state.request_id = request_id + + # Log request + logger.info( + f"Request started: {request.method} {request.url.path}", + extra={ + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "client_ip": request.client.host if request.client else None + } + ) + + # Process request + try: + response = await call_next(request) + + # Add request ID to response headers + response.headers["X-Request-ID"] = request_id + + # Log response + logger.info( + f"Request completed: {request.method} {request.url.path} - {response.status_code}", + extra={ + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "status_code": response.status_code + } + ) + + return response + except Exception as e: + logger.error( + f"Request failed: {request.method} {request.url.path} - {str(e)}", + extra={ + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "error": str(e) + }, + exc_info=True + ) + raise + diff --git a/Backend/src/middleware/security.py b/Backend/src/middleware/security.py new file mode 100644 index 00000000..e575fd31 --- /dev/null +++ b/Backend/src/middleware/security.py @@ -0,0 +1,57 @@ +""" +Security middleware for adding security headers +""" +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response +from ..config.logging_config import get_logger +from ..config.settings import settings + +logger = get_logger(__name__) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Add security headers to all responses""" + + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + + # Security headers + security_headers = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "geolocation=(), microphone=(), camera=()", + } + + # Allow resources (like banner images) to be loaded cross-origin by the frontend. + # This helps avoid Firefox's OpaqueResponseBlocking when the frontend runs + # on a different origin (e.g. Vite dev server on :5173) and loads images + # from the API origin (e.g. :8000). + # + # In production you may want a stricter policy (e.g. "same-site") depending + # on your deployment topology. + security_headers.setdefault("Cross-Origin-Resource-Policy", "cross-origin") + + # Add Content-Security-Policy + if settings.is_production: + security_headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self' data:; " + "connect-src 'self'" + ) + + # Add Strict-Transport-Security in production with HTTPS + if settings.is_production: + security_headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + + # Apply headers + for header, value in security_headers.items(): + response.headers[header] = value + + return response + diff --git a/Backend/src/middleware/timeout.py b/Backend/src/middleware/timeout.py new file mode 100644 index 00000000..db0c5ada --- /dev/null +++ b/Backend/src/middleware/timeout.py @@ -0,0 +1,41 @@ +""" +Request timeout middleware +""" +import asyncio +from fastapi import Request, HTTPException, status +from starlette.middleware.base import BaseHTTPMiddleware +from ..config.logging_config import get_logger +from ..config.settings import settings + +logger = get_logger(__name__) + + +class TimeoutMiddleware(BaseHTTPMiddleware): + """Add timeout to requests""" + + async def dispatch(self, request: Request, call_next): + try: + # Use asyncio.wait_for to add timeout + response = await asyncio.wait_for( + call_next(request), + timeout=settings.REQUEST_TIMEOUT + ) + return response + except asyncio.TimeoutError: + logger.warning( + f"Request timeout: {request.method} {request.url.path}", + extra={ + "request_id": getattr(request.state, "request_id", None), + "method": request.method, + "path": request.url.path, + "timeout": settings.REQUEST_TIMEOUT + } + ) + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail={ + "status": "error", + "message": "Request timeout. Please try again." + } + ) + diff --git a/Backend/src/models/__init__.py b/Backend/src/models/__init__.py index c1f28754..fc1e9f1c 100644 --- a/Backend/src/models/__init__.py +++ b/Backend/src/models/__init__.py @@ -13,6 +13,9 @@ from .checkin_checkout import CheckInCheckOut from .banner import Banner from .review import Review from .favorite import Favorite +from .audit_log import AuditLog +from .cookie_policy import CookiePolicy +from .cookie_integration_config import CookieIntegrationConfig __all__ = [ "Role", @@ -30,5 +33,8 @@ __all__ = [ "Banner", "Review", "Favorite", + "AuditLog", + "CookiePolicy", + "CookieIntegrationConfig", ] diff --git a/Backend/src/models/__pycache__/__init__.cpython-312.pyc b/Backend/src/models/__pycache__/__init__.cpython-312.pyc index 640e9f1ffb3facd1626a31f73d2145760b38d597..a019c14e63d32e0e10356763f843f5eee3790d55 100644 GIT binary patch delta 302 zcmeBYKhD8-nwOW00SFqnr82iMPvnzed@@mer$Gusia?HBu6&d{BLk2vn4^%Z7^Mhi z3*{)~Dn}_ZGNd!42rpucQmGWt6rK3ypatVC4#(1z%o3mcbWO%vJkI&~*_o*U`8k=% zl|a5YgzuSGlA2zWSdy8a=bWFHmYJ?8GM#m?E%?2BFi#?Z$V zQebyVOwLZtOPQ?1ti+`a6lMhCVyDS|%%-Au8RYLWsNZF<`@|y5&D6+VqzRM*04xkx A=l}o! delta 119 zcmX@k(a+9znwOW00SNw`m&|;_G?7n&@xVm&ojmCbDFTZaqvR?DHH9V%Fdk&&pUlbR z&FrTsKRJ!*y&!vhd}2;ceEdp=&mhgeY$vZ`R$wbmEhEpm6t=xy+v|;YyUCWM;YX{g2rSerBoS07Dq;yKBs8Sz7KAUB>zQoa_-8X7 zi)>Ge)C0$&BC$~}Ic}9mg0y*cYMM3zT2`7und>yaS&o=@PECGl>^2I>1q@Z9G0@;#-qE!qOO9@m<4Kzy= znNPtl1-hkkUiQmD*(y`M2o!%LFf2n9B;kqx)H?#ups`!X%2<_N$<%MZuD_cA4>$a% z(+DB}{l;p98W=h5x)*jDUeJvYc0$~6T_9}6>lo1x zBDditCfdA%VcQEJ(H9~Fy-v6UdqiHm{_(OYk`V-bhv!@Kx>VKx^~TiuNMhMVBDb6v zy4g-Msyq>nzZO}0pkRr#MG_FLf+Rp07JRpvm1ZgCZPIfO?SDMV%pjl}VvSPa|7Y-^c_ z*lR)zaYXU}()dQ$No*>y7fqDx;SdACI7*NUZQScZQqDyW5Z&wA4ggdY6P-sp9oiG3 zBe;>k7_($0o*F>xcz#Tj7&|zL5qlNHfCxp%4rn-yLn_swGOwLrH;g_fI&Ct2V8C`T zVgrcCtjK-gnDWBocbaQa0Gpj~vpEmrbsTk@A4C}X7h6%phCs7LhXD*hGe&Nc&xp90 zU++}6hpIFSCoFgTD2 zO)D$?D~HC|R||ugdrQNY_i<{>^%oB7lUr}@oZo(Xzj)A2>#hFP!_kSs_;8lmN9X!W zhqdu7Wq`KzVLPo&_ZKrc-YyS8TAMkMx&5G?)-F>GE!^IjPb=n0z3F?4!?Opa)M)W~ zgW2JQwBDqZYU2ZK`0BUK`^^XM|8VU_TKlBG_(%~(CI-r|GyE(qzxBmDVp!xSUN);l zwQUf&3^ehE@A&Q-40@-y;3(ktlwAyiNOqZ(Q}?L|1^i~Rd#k!pxaxJL=u~uW(m{xf zlX~b4#_{qqlzXhrWiY*rE>V%qxcD_n{wRo|_(+mO`ENxKuZcekD?bV=4~17A3NJns S&i|>@#7ke|zXbZ>$^HfQE;t6dwP?ah#;x^oLeOi!6lLM(V~wfcOzY(1!iVsx8uni!av1GfCWxJ=w8C zo1B8QS0rwv+5_d*RVz^ri0dAaxPS-}wX}x{A=KNhx?J|ejNO#wu<)Xczj<%o_ujns z>>rCo1915+x8(h-0PwrmOeAv-&TJ081PCCB03X;u5?CgZFWa)O*ov>(s-LklzGiEQ zO(9udw{?zHlp#6aunh^w;5tCsU4XLKIN-5_*!jL*&YcQamSP>j2uGfe%b8ePqon5t zv0ml6I@iU+z@go1({#1E7 zURa~F4|Qn9-+C6E3L|k zd7aW0!RChnB>l*9!xeKKN6`G(+rgw#m;-`8h zCXZ!BT-ifR7ogOFj*ns3OEY(P=zO!`bVWt^hKcu;i7v-lFLHTfVuN3jCyX2z#YURE zz)ZXZadDz(^o4g$=q?U5`uD*Z*MO&+R-5|RY6Ux16^EUOcC8yU!sP0zsC>|}R-xO$ z0kT5oTKtljgw}JLQt9?tk+UX?h!}p(m%HGxtYq{@2AF?sxPFqG{vx|qKbm_`ALnYr z)sw}Sc0b)~+#4KK$BXa$q|P7DEbhLrC*NDzN8_2-hqq2j3nP7h`5WuL_28p%sWz+~ zmll50FO8PJvUaVb8{cn!yZHltqR2Dau=ZF7^DmCx`ntA1_~n&%d5F2%aQ(P+dDPs0 zdt7?svl_cH$#0`vNXu!2cL*eI8~gpVFG;0E5t9z3-I@C5P=zu-t><()ixoaLu)#k? zEI%P=@}HpkE2usMSN;GmKhb8TH%I6% Iz#j?2-^k~b-2eap literal 0 HcmV?d00001 diff --git a/Backend/src/models/__pycache__/cookie_policy.cpython-312.pyc b/Backend/src/models/__pycache__/cookie_policy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..82349a377e0194eea36a73f2f2c255b5bafac077 GIT binary patch literal 1537 zcmaJ>O>7%Q6rNq%>&?cF$zO?}pjA|895uECIUs>JBuyHG6gZ?pT2)$&cP94Kvpdf0 znsjptQV$$^LF5RxREY$~9?OXXmq?gHM(QC#2=&$~aOsIRyRiwVFqX#eeeccpe&)^o zRING)R%E}%W{L>?s+Hj?T?pqM2wx+Dh`~{aJY;Cuc3lZx+B4z3v6>@HGJhkg%Pqk7vlud9#6Bg2XNtIWG;1oy7 zS{1w>M#{bk#w{=^XI)6j+R?`}RppPOgtn=K(-lcMPMC<|4(mNNbIRfhj%knw%XjNS zIiE0eRTOy!L7qtruRu(%Xd+so7Aff8aq$RxqL)|Ba2wFlu{Fwm?4_vlQgl2+E3|s| zYR-9i{|9UB|2+Tme0h}XE7^Gq_DNzNL6v$zHDjZ^r@%H%95Q}fIns@S<;|qk`h=>i z3ejcsQ^8q~?hPE*y~RZfb2rFvx0e|}ixMd~r^IcgE+!$1Se!_l2pKmVIJMPbu}cJv z-OW$7+!%1`_NZLy$5iUmF^!VhSJTGs0&TP#H(bKv%o9hLrwI#U7lJXEj5Sf+j#hN{FOLhq?6?k9ppfutc2tk+FdCUhJ3d7|v7&$D#u1vv%XQ?`iuJw3j1 z8q_^IOQ^v}-N*n{ybT#D>n`w709BFWy>zoat8Cwoa7cY$IldnX(&t*P`2J2G^9xJa z_lXDquKIqWOUrSoAE>l-c#{#SM^#OZiuS<^T#1^5y1MYJqV@=y?n$~$C5-|a4?(FP zhScv;S^Xr4#IACnbSR1VI8jbso|a`)8aFzP7(~R<3^8{$}|wdAvL*Z|u*XUAcPj*&%s!dvIm-#GF3;mpb?6!S46PNBzOv z+KD-PR-1j$Ikdm4J*+)`Yf#(RUp&2pEd1E~p?Pu#4{ELb#j~logIB*@JoJ8^e-A=U zZR{_d)?PnYKb#xX-uYrtULWSUS+C@+bmAS3dC;L@nq5$SXIZOt7xUYj_}i%=WSI97 zrZn1S?FI}LZox-Pjt|M@+gi$EEr9gG{ve;559JL|^?Q$9*q#*(!+3(;_#LhNg4Uj( V>%XD-Kdc$!-3QBmBKXS~{sICnt{MOU literal 0 HcmV?d00001 diff --git a/Backend/src/models/audit_log.py b/Backend/src/models/audit_log.py new file mode 100644 index 00000000..b566e76a --- /dev/null +++ b/Backend/src/models/audit_log.py @@ -0,0 +1,28 @@ +""" +Audit log model for tracking important actions +""" +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON +from sqlalchemy.orm import relationship +from datetime import datetime +from ..config.database import Base + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + action = Column(String(100), nullable=False, index=True) # e.g., "user.created", "booking.cancelled" + resource_type = Column(String(50), nullable=False, index=True) # e.g., "user", "booking" + resource_id = Column(Integer, nullable=True, index=True) + ip_address = Column(String(45), nullable=True) # IPv6 compatible + user_agent = Column(String(255), nullable=True) + request_id = Column(String(36), nullable=True, index=True) # UUID + details = Column(JSON, nullable=True) # Additional context + status = Column(String(20), nullable=False, default="success") # success, failed, error + error_message = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + # Relationships + user = relationship("User", foreign_keys=[user_id]) + diff --git a/Backend/src/models/cookie_integration_config.py b/Backend/src/models/cookie_integration_config.py new file mode 100644 index 00000000..2d2b1a20 --- /dev/null +++ b/Backend/src/models/cookie_integration_config.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from ..config.database import Base + + +class CookieIntegrationConfig(Base): + """ + Stores IDs for well-known integrations (e.g., Google Analytics, Meta Pixel). + Does NOT allow arbitrary script injection from the dashboard. + """ + + __tablename__ = "cookie_integration_configs" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + ga_measurement_id = Column(String(64), nullable=True) # e.g. G-XXXXXXXXXX + fb_pixel_id = Column(String(64), nullable=True) # e.g. 1234567890 + + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + + updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + updated_by = relationship("User", lazy="joined") + + diff --git a/Backend/src/models/cookie_policy.py b/Backend/src/models/cookie_policy.py new file mode 100644 index 00000000..8395b0fc --- /dev/null +++ b/Backend/src/models/cookie_policy.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer +from sqlalchemy.orm import relationship + +from ..config.database import Base + + +class CookiePolicy(Base): + """ + Global cookie policy controlled by administrators. + + This does NOT store per-user consent; it controls which cookie categories + are available to be requested from users (e.g., disable analytics entirely). + """ + + __tablename__ = "cookie_policies" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + + analytics_enabled = Column(Boolean, default=True, nullable=False) + marketing_enabled = Column(Boolean, default=True, nullable=False) + preferences_enabled = Column(Boolean, default=True, nullable=False) + + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + updated_by = relationship("User", lazy="joined") + + diff --git a/Backend/src/routes/__pycache__/admin_privacy_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/admin_privacy_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d0e0dc1818e339b1cbbb64e8092c4fd88c8e3ca GIT binary patch literal 4225 zcmd^BO>7&-6`tKCm&>0eX{{uitQ*s^Vwp}ws&RlMuA|nL;n)e_7_ox_ybvqykfOZH zWoMU8D9C^U6lmH@5%l05+gk$rpi2N9b1cw{85*E6h>-wE4!%*9ivl_I&Fn6@ax5FD z-BT9en|W_$-kW*z&BymoRZSrHK8xp@4^)J{WlHez5m0vjBqDSR8ORV!WC<0)5-Xx5 zRV0DgL^EPVD^ci6M#Pk@SS7~$QB$$vl{oLqX2MEVlDr=?Q8I$l|1N0B7s6G4cE8ZQpTa8se9ICDc6U_!`deJxuz0P>iY29uDo3 zgK3=pzvLhdIXD=KpW7z~&o{n?)ld%7b;URo+C#pZE9M`t8z@RNe)h_xckPag35}h{ zZQL>(Dm$+3cATO>V^^``H0{;{h9S`926n5)GW0Tf$6c{Wa}8ICZDKY#@}7f9F+y`o zwtc;cFST6UAi4|7uVUA2wi*sSy6@1t*lF7>2h&M!$`#ve*1DsGhaYDR)*USFsn#$FAUoDDMZWCN@en2Cx-f&=gjcyjZDV(?_i^t!aF1Y_fNHK3%i3AJF|&4>%+<$dcStW5{n zxLuFjkpll$Q8BW%m?*&aCUy%A(_Yrif|tL7mp{;2^=6~vfuL}Lr>kJM%x|KWpGq_Wias{#F3CagqCzszP>q4l^9&d4BWjWvZBnff437L^8Uw5Dx`eN% z@p{KJt1aEagdK_;W=^AIiuI?NXJzD0_WaY#cw>B_!)}tDweZ7RZ@B~U;Wj_h41D{pWN77|NPp$xwk%& z2THD|eRND8)&mVZFXCmc^HeNMe;z>gx2 zE0V}@2)3q{n`7p8eo}OJW<-aV&O3#=O$zQxlNj@Dow(gXI4zGM!^Z&U5Tl3CvN7%D z_tIWyj`JwAQgA6hqC5^LD%I)@4^<<2^FVbl#7ld8FMzWP@N=plM(p%PFZ0IMazAqx z*eP*jkT~5-oZfo5pIF>j`fm26PnI?hZ@Kqo&wnKSIsM1IR^T5n1P-Tj5MDj_T1z=gT*5(G=|ELScnzwPhg?^UKZLD=@cwwaB*rN zI2R=ggT$#`;?(B3t=Q*>`-#gNOPq^BIC@sxTrU2M#_BqhYwads|6(M<1j9K^JHIn^ zv$le*ZpkJVVU$WUUa?YQCCIW4|9z}>q8Y1c7$$ySC%D8aUJ_^4BrgV?68noJte&D% z4s)kFC0^qM|MVmYV}xgw)mEJ4=?i{w>Xd?dz+2;+y;?`+H3Fd$JVctjcdOlY z)py?J^#PUI9hWeUC+tBImf=qke=q#~{-B(J9d=B-NPYuDtm<@nAcvA52w$U850&nt zneWl;eKda`z3?9~Du}xX#J^KWSQ2(*l+f1eglD`&_V$1 x;E;_B6&5NEJwFt)og9VIwzXHD{Va3)pJl%QPddx-C!A$&e3qRAoP}Tce*r3dvc&)Z literal 0 HcmV?d00001 diff --git a/Backend/src/routes/__pycache__/audit_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/audit_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c3d24857fc1615018c2a464c2392a5ddbb01c82d GIT binary patch literal 10232 zcmd5?Yit`=cAnvI_tV^Zr1&jAW!aJ~*Ov0Iowb#{IjU`i z$|g>Lm39MILIOxaUDyT!L`K|JK>NekKbsV5G>A6~5mZZh>c9?)0`-pq)H*=ZY<~2d zp@yVnI`;ZU(F^d-x#ymH&bfE)-20s)-7}jF6qH7$B6P+_QU8t&rFfQ!CqJqvYMSCH zo{msax{r>k`czS-kBO@L)KRvNrLj*H(L}X%u`29QFEVJ zrrC%kYU#58t>Lv1Yn1EbWLg)oMeTj|XjxyG+}1}NQD>i1rVSBS)ZOQ%DHSEqypcBz z(znf@foE9yJc1{@IY$@QyyY`G*M?CnZ{@k$Hf78*$Nm(j>?w|eciwh=#;nlfeoD&o z6sLR@r-HBGD{oh=&{PdPFJE)p_nA7cUj@FE@qWd3&zt>= z%}LFd@=)eH> zk+hw{urS2OC3E-jWvyaa#jJGbo7t{Mjaz#32Bb3I`=E&bL3rOXl1J`v>GTgk2!dOfmqQz&iw| zB^_*+9HmA}5*rCcL!x9_@mf{<*`N^|P>KbJ_u=-QR7~j>Z64tL`K$E8v)L%pWH;kfRd4e69ta+{-`Kno| zH$k6oT;Z?k1@xF!>cCb)wMq~30(x+5SCs^_N{^bnC)yODms&IR$~7y}QDQxwvF^{- zVOM-BJYP(QatV!@O24;+sp##pWQU#-or;aHOQ8zS1k0w2dKqbuQ zf4*WbXh_dW4PvNTgtULW=&{$ z%_zL#gl-hRzA+AL`k$5;YZcy4uZ?0?d4}tiafN3h4-LX=3tWXt=<;Km6kg$(_uDo| zjoA~nPHK`qtpmTy5@q3a1v!NRZmF-ZtkhWovL$vpa5pI03Qypc*wMgkDaa@ka9>gA z!V|cg3N3{KZmF-mEWAaLC_F*7#O@>&rTO9Qg^>!StmY8SYkA!@)0hLswkf@ZC+RB9 zf5wWJ&=B5P(56t&(79XbFFbkuH74QU4cL{ihuaH13YBmuyM~4x@u5*o@y3Kb0lPb) zgIYHVg86+8_YZHnrsmDpRJ`Sy>KIi^iFldHrH&$MnmUDA{_7%ji8)1Gq@U6McSZgC zmWNX?zH-bd9#Go6RqRsegj4bIHm96HS77bOobNa<(u%gfWiHZ9w$aK%g6NZ>kPl&z zudxF!cwcNNa=GaTy8Z`U{i){qXmdUP7%|G@T~OTu=yne9j-&xeK!13>2V+A#L7d%t z%Ot5nJi)J<;E_OlP)Wu-Pylpb2o3iKcpg}B$sj`p002XxWFW$Y5g{%jg3vAl9T_@F zW`PjEiBTAVSiuk^0$>QfKOo*>2yR+QKQI!B^bZB1fFy({KqN_xnk8*~Bp8HYO=^i1 zV`3me8qg^`5+sAnKui<|G^ED)O9lWVp`ic*zFVrCaW&wTz~k=#W86|n#${v^jPWq> zoRdwp5(|!#%cKm|YI3MYo;_#{od+<5HsvKjbV2X{AltAqLnZ0rA^}Vp6$mb`WOy?W z84)8j?N~j}62~G$0b(-eeC}d$C6o7)Agu>GRl~AWV`N@U6*Jc$2wVtif@3Sd%)e z@c1pPD*+5c5jiuIK&KkRQk7mEt5gF}~EJB9dpF*e-V9TSB}^Zr=ud}wH}b$=ju9*nidNwAe* zxDwCB0T3x*qhab*3SjzHFKBdFU<9%xSeueZIY`^bB#%^ zac<+>n{(as9dp$wZu=9hy4o_{lQmGz3Y^!C>Xf5(ybGu5%Bs|6=L3%~?P*GSn&#Hc zhf|)8NjBrDzHXW|eR48g-EOeER&<`5R<@&7C(U&!=4bCYjZW4kYW_Q=Wa3EO=vcO&h0-ADPG5tcEIc&)B9K zXN07!c3hLOI0nXw~BxLw2|Sw)Z!^GeI1Y_HIjhw=H_x7G68PRBrZI#=EmViYvQ% z=~tH?fvv{b#_Q{6*Mq%JJvTjR|K_BBbEevN{p9S)>t|-qENRts_DS80E6Mq?CdyoP zwP&Jdrtx1*wb_l}eRdn=_C4}=A62i(RQWPc*EY^=y}4(8W3qP39cOaQ-X%R-V_#x* zPRkN&E1*6U5lv4=j~)%s(EuL;u6U zMa!-Q-L5~uD0cUc@i=@gSLogv`h(7$J#EzQ+S__O)ZOhWpzrSNF!eN2|ExJ^gVxt> zl=|AE@2O|L_A|(-qmizc=|+9eR_5!CGQEXHdaF#g>3ge~KhUmTH}eNKi*ywOdPX&^ zcp}Gx%lRVkf})J@dmnyJHo$S44-=Ue3=`E5CL+Mc^G_}%VH|{h%H=x7Ce)?Ej3N+Q z3i7cl0KOW6Mg;gjZIv#vs!3?Vcu1`(yb2&bb6cGU_?nmUPO}2wt3gLT@GBTppxv1E zxi(4wS)MHpFMm?M?xpl=URb~WrSxlGSid3hoZT139L%aBBIJ@wm{X|2vuJmf0O$$? z3|AILC={O$ev}spEXXRi;pD^$S2zQK~)K&9UOOP3)f~wPSO8p$dmND~~C1Flj zc;hwIl2}L0ADb>GI z=?|*tWe+Zbm&>&EA_i0lHRU1)M4Y%@X+uENtk8v2z>0v#HpacftqO?jqxCBSA|GBZ zp|}_dt_+O`M(YGE5RAo1D^~lU0`w2ThrmM=f&iYp4!p;B`a>!e`$-Th%(XVDa;O?5 zfc7C@rqwbmmh@*Y_veBQ$q1mfKbLZiOLlDJ!zoM*$4M6sK7>^_RJ~1Z8F|a$h#X>& zUhF%H6^0n(Rjgja3N6ZEPR<#;IdcIO8ws2hB9d+pQ1UPa9B*J3MjdjrL5?Hk!|G>P z;k89hLe*p|iV0Rm0;CUx-hyg5^pe3g#0R+$tv?sym~soa+%o1OKe>wx;`nn|$&n@c zE~$t}`hkespn57oAb*K6)&jhj!2%fuE(YuIw_&^I@IBXF3f{jg=4DG^-prqgd0my$ zZ%w`R(dltR9vOeyf3yFNJ_oSVp6;ZlJLUO{adXC9HJzABeA4~tt2bYrzm%+NPr3J{ z-3OEIgDH3SxG4jwB2$r1+CFW+**-s*tlg7x?M=HnldjH`>)^OCtE0@;tKAdb%K^m$ z&M|G6GF)pzB%I>brn%-M*E~0v;o%G~hK?1UCSv;CuJ z%3Sx*Y@awWx#`!Z9-6s{W4}I;wNZLUwxVDn7n!`Uk+f?=(zPLFZhpR*%B%;0aCz;c znhhE6x=eK=0B5fq0*F%pozgY@7h|9Q)t4i4+wOFweEU;1eCEt>y5U0d^}dDIk1d?& zyJP=cNUc4ZYPhgu(<4+iAyhUYR6bS$Ds!u$@{doB)09u3|L5NtE`0wT1WnID%@eQ* zuh2&w^at)E&D6EFBQ*7eLj~j)?hext8}(1MhhBl!S7p{Ce$!WLbV#qW9$9PoYKIo+ zdlZhnN9&JRn0p2WIc6GZi%i?}N4(6v3Yo5^k@i|qrdfZ~#@yTHI%;BmZ(@P|y^R4q zqipNjA%0B${(}(s<_l&iWIU|_ZXQpoUJOtF`G0OnP%Cg1o;j;Z9uqJLCTko78b;R#${p`}oet}V$gg~pV%g>5S& zyNFkhvG1^}kb-H{w-PD9ABqomuE?K;J_gV`TGoMobjIweZzv}E1|W~e=dNk88GIt> ze=Zxh52@2w0wIDyS} ztnx{~+)XZD;+k9{v2cIOZk<7K`Lac2xyv?p!UTynT8>WZOO`XH6)pA}YUEP|j zZq1ZeUH8m-u6t*_aM$=CuQT~((o~hTz(tgCR?KuSIO>;J2JaCD?-59fm18Q!lF^wT z7#%;IRlzLk75X4ef6!&>vQc+Ar0?47U3;0kwXUum%-tO<(t8=;H`PemfdC+!;Sj;& zo2appH;Mf6wa}C-@e7eaBzR7UUT%R*Fwr5?P+;aD9)3Rrk5x$n2otiX1W(Ldy4niK z{(;b73#P@-1|Z3fNeIar4e@+LxELTp3;r_(;cx(gGRYW)WO*dsf(dzo=cdFhbMh(I z<;=O9ktGrXyGaI<5)&E{Tj7sfHxg@C{Y@4ORUgRNX_W{Ts^r4Yl@Ly?v&6E-^Q>sNeIh2DX+; z_sfd+D#j1IU-NC1|3=-ymV=8`hweir&9SiS_+sse`_*f1Y+2aay;yx%=FXp7IC6Ti z_RRg9bYQXS?0sMT4Su2R^+n$s_r0|@Iv2L}EP8wIbI!@YOvl`wMQ+D^n`@HH1a2Hy z*xI#dJ9xj`2YFq0%D*F3zVp7le5T=})+P5KP4CW_9FxZ;da?{Q9+g$j9GKdZWw8OS zT3nOCiPKrF+|*HyD!ErLHw~28DUUSDO%r7+pDCL%XU%fcLRsCD@rh{GDmOXGXvx}S z(hfU4TPBkZ$gyUfGU=l9+#h9!o-mZkmQ~5z<3Q(Dt2;$Izb!rMQd85zMuj}ONA?JJ z?e0e~7xah427ch~tX3vPL V;R&iiANHU+=aLF2@-A$d{{`xX99sYY literal 0 HcmV?d00001 diff --git a/Backend/src/routes/__pycache__/auth_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/auth_routes.cpython-312.pyc index bf148a57a7c5261554ec38001d885ac26472c2b9..9d78f8d9cca0009ec129528506527d3d5c084606 100644 GIT binary patch delta 2310 zcmZuzYiv|S6rQ<{eZRWfd%KT5ZVN3eP}))oMPA*73Mf*#JQT#NW$&%Dmfd=Hmm;*T z2+^7lBbh`RLBcOBX-&Y5(O)DK{`rT^{@Ar?V*R12_HkbAs~^fJqRb;>fiM)AoNim6{G zOXv70@TcwYa`GutMIJDc;L@y|+_1>%2djRGQZ+43F=Q|Hcq&U?OBZ>qc(=;mm|n%^ zS$vJ~`lYyotk6&60LkekeXuX`wi1=eKYE{TBb`y*n65P(WsTPuRR^Ez$j9b7^Lh|F z0XC4ddAWHLh?@bnkYCNx%B>(8DfkU)HOMspwE$fJs{sN4LDFUs&D%iSp+P!inPc!S z(qgaMQe>oRKc_AQ>r)089Oo!Vw5c$D)~YJ~suDjIPQ>UE)SmQt`xT5k$;yh2#R+5`inH^;Yu*_X%_M!FYym<*qz;d;=6sd>DSeYkhoCmOu zeW{2rLF;Zk-{U}fw-bd(O5$2k0qnBx|bg?rop2{Sp29`fQ8ZZ8Fwbb%XEfaZHSK&^Gp(D_7a7kYS$~?w@)>)P{gCGo zJtl)}lue^7JxudumfR49RL>ZffEcwl`h<6kPgu&@L0egR+K=gE%P2os@l;cm%D3?Z zQXPvpoMrpdeoU7TAStvqsvG5}ty4_#>jNz7$GG>n5u6Z94v1#jDjKX%{$51xhOJE5 zp0|)o=nLj~yd&r&dkBr-uh|jox1aJI7^X+3NRgzfD$#_LgfuN3>a4LpIW!cG_8nK$ zdOABj?PIa15{@R1A5~Pf0aW%#GNp~&+ZT_I#8la@*V1=Z4@}_!S$&9o$ z-V$%rzX7>Gq@ zYj^J7zc<{`+8W-qXMcBSPhfX=Uw0^Vusam)4uyI`bnS;=+;c9m8U%xnLL9Ob>Jx2R ziBjJL6TtWM7Z>QgDBfsWZRtk0W`x$fy&_*JUF^BglNTFuVpB$Jx_UY*w&$Ix+MKf? z<7~LPKI>eW_cZ1_{*1>zxjO6FkcWEEm=PMU4rYb6e2qU>)10YkzPoyBwx(;rZV*j> zB7?&;&J`+Am6WgQSX3nRIw%r4S|nW5<>7*aEaH7+pdq<^6M;**aqFB|ofF$KDY0$b zoHvUT%{gy##@n3rwq(pLb6zRuZO?ey$6uba`SR{1d6%@nvv$hK8clyM7?4G{k2p$a z^Ub+ybMDO<_vXB?eA+eHJ0(sU?z-9*xffXK`8d9lZqNsKyMWIH)&}+HRwoYf+`3yE z%=BZ11H%lDLDn0$JJ}gWz_iWF-u7DP$L*$N+pX*!D^KM+PIkLMzO1~d>!5^cCm9r) ztGi(Hp@Ka*mwqq;%%{`BFW6CZ2IT>O-DI+=F{G97C+VYZ0n3x*@$*nzr+GDFdff{iSIB5@7A4u>D%(BDD#4INdL=weB z(xL{UYv=(*q~+Kn!=~dgIXR?kQBP7GO)(S?rF*?6a7x6cyi+W&w4XQF@AI^Oz#_(7 zV5n4BgBZg{-ZS3um$JC(o(_5I@7A|vJ!^h+dM27k%RTx>p*WoO^OT0CeL;tej{9IK i5rJ+G1}5Jb-(73nb^H6iEbgLoE-!iP>*GdfJpTbea1?$3 delta 1011 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 mWvt-hTnx*Wr}_ diff --git a/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc index 3521603a310d1c0b457c75aa37c4dd78321f5ed9..d73a5c9ab09204a8411a53e35a98244de909bdbb 100644 GIT binary patch delta 7447 zcmb_heQ;YD?z!il``*3x{eI^@{pj$2exEa4t*FowC{4r_%J#l$s^&KduWs6x zB$7H}m^4TlNgFXnD<&&A0=h0@idIfmGF^__sm+VW9dn``LE>$npToMXV_+V8@ z6h2syugz#AS5OT4YQR-;gVt0-T0pH0`lLGed4e{nKB%2BO5T^%mvZsQWOM#jhZ^#M z;8wnS5FQ38#?nZQC9zrxv3$Q_tfqTnHJ8L{g;Y0jl<#9P6&&R=29}mt+ zQ^9B;6oH!WWZ?N|a890@j?K-4Xfz;)Vsp9HDLEKD841Wi<4W zfudJgGx|E%4K`uD@Km%FtD8{lMez`dgD7g0EfqVsy~=EbS33}xJ^?eW{6R&#`blWC zUjF+EpU`LTJeFIT&eQ7(Jm})|`r8S9gqYy>68yfor6y#AaO8C&k_4~ZuCXZ*l|>1w z?MhT7Du=61b1lkpl|!*udN|h3%VuR#-7K0h4i@DZi(Ls-nYQ7?ZYXlWE9}_N1l4ej zlC`;%-5QZ|qqccbHA@!N3DsV5mV4?age+SEiLX>HQ zz_%V?-v&>pg4LlsW3xNqdVeeH>JzjO=@y3UVv1dPM`eR$y`{5ryBHSg-{cco z$*>axs>6Hk50ud6k&%N<^U6mmA{nKMggV4YrZ@S=iI2#Pjd`}3Ex|jiK91rriX$NWV(uo2 zfXAMWJ=jt51b9uL>u;fe8i|V_y!k>Wp_XwWN9E@o;CreFd-NDM{5JNB^Mw32`Q;gs zHFc_Y-cpvDtCj2ZSDDtyY`$ zS)vy8zr15q`f9dzjdXA?5APV>MLvC)1O3xomXS8$s@XBpB3x}zquwTf{gk@%G{7}& zzx=g%vpPwif}6iv^=OVm%k39B1s?Pn6yLp3*d!J=iYK>G*d8cs6dJ%7#chHMk<4ut zMp5f~0(|~|wglU9y*OrZH)zsq!@Fuc=-aexO!E zjv`e{HIgf#188@@DLhVk$SX#Gcf>1J0L-fw4Ke~0E0Qx|z^FxncmiVXS~R?1c%?uuUF^ZBm<*3uG$QmQ0r+KhlyuKexaa8wO&0QFqERNQ030 zdgp=*dCJHEA$!XJt8$o|QB4Qp#~I#cR5SCCeF7dN34IzJ3Y1ZVfQ&I3peLpRalpfn zG{&&kuPuqpfSkcM!)s)D=9JM7jvm-Ic6jQ@q0zhclCc0L%Fr{)6DV2&tQP(=NVemWAuTz{TM?%ML8HJD5MgOYcP_o9&D#~z(ME&u zIGg}zKh$zmxH%&LG602vfu+#*lHx|JD^j8bdmu!>ap5mK&6?aaDW7|4k{K1A8fXPJ zo&b^IV{vvs3eG)CtI^8lN5pk1nVlk9GVVmy%Tc5gW2o64PL_Lc*g%g{jOe0wHjEPq|sqC(|Ier(+AHy&HPep3n z&0P-F`#EKx#j3pKtxp=QX+uNG(D3$C>E@lO=AG9KJFhrDsr_kfmN!=%;MVGz({){` zx~}VW(gy<{9(e!2-$a&2r&qR1XZ34!yH|ai)4m-k-;UM#mh*{oiFAE;s=j;8?Y-c9 z*YlQV#oe_wc<`TR(_>OzcDCZ=*jg+`}(i@`jam?PS5L!R}*QcKjrkVdbYmTliqe9we7(2*5T#ak<}XC z9j(e~`2|r`S+Y!6h||03>|V8dE@+o+n{I1W$lj_hQJ<{>r(fOC6I=3673shfAs;R^x_|pn8_0e^!gYaGAsG> zlrmIX;TEL=7%ysNL_StIUT#jU!EMag}IR=;UIKxVwCV+y= zdWJD&+faa^i=1ArmvYG>?uU4`4IQDH&@LGYgsqbY^B&(d3ChpJR<#jZajy0r|`K;~cQ z_&<&b&!H!7v)uPJT=^WcokmTM;uH#&Wh|oh0t&`gc(g83DLsSLhK9d@sS=lp_ z<=sF%w_LU7H=Wz`cF&5dE$!Nxa&7&f=fl4D`>t40+Xh!$d(*Drlxuj!HF{RNYIVKd zuk>G7csKc0GTr*nO6x;w4gPe)_Ef|6>kY^Mmj4IwBk@Z6@}sjWy~occhu0eRuC{jM zocE<#_W|d=+y7R7+Silv0a@=!`Sz^UH(i)`_vyEuUa8-{HuTu?p(oOZj;9VCzY$)z zG4$Mpg>>t~sn&HSoLj507Hw{@mOCEM^Odjy) z|0G9j9?Tn#a%&FXd&2V0!#5m9md!^nV>rs)&Gx~~{pybEJF|<5`_hZ#8Q4EBa^oWR z@}A}~k^JLk^Ffe(13RGdv+etO4>pre+qr{2)u&qqY*4wzX`$ts4!c|vod>#`gXMD#`g|^0#zA+tjB~;loEr2mv>E{d`L`(E)&oiQ z-exMP2fu_v4bHRlY)_W z2g|a}Xfdr@)<5p#K3bJz=m;VOr}eKslx6z(if4@TatPw#9}$qZBT8iKIa4l0&j0-o zXPA@G(AYfwV}P)h5g-$1U#KX4T4&#p*q4Qj3Sb4!EmCM&rV{4R8R6u-O!2M0NMHwp zGk%r2tJ?`TKOYJ1qVGTz-ggq;3W+u>JjZ=W_Wldmdy_QYB<`D}?%#>{U!>_KY59`$ z-z1(d$@mHx|B`IGNt$nxA?3uTzuvoS^sR7Bw>8Aou-x##O3l`Pv(&uNqIBNYc5^#d z&DN{{*IO1xRt;D9rX`wxjxFB^gm2FBXgkR%4u80FKL`HcC!oSQpt9ZrRqSHP^2~aO qSN_t!<6+cSb%ymmRraiUy^qzI)iPCw1B$3(_X+Mc>rdZ?r~Y3z3pn%u delta 2785 zcmah~TWnlM8J=^_-gmwC^?I|utZ$p$II-inPUF;e94C&c3T|jaL$`(1dgpAs@j1KJ zoU=7)ow!O}3BgksE^0w7;sGg&d7zef;~}(79|}?tLW&MDDM$z+A+#ZVDCLF!pOdv0 zsfdxzcmCV_GxJ|&{`|W1=7r#g9UVRbW9PT2YS+MxV3+)sd?UA@I;l(Z>w!W*BGgIU zT8ADi1ocoMBzTV&)+2?8puJjDj}>Biyb#wDg@oQ&=oE6FmejinT@sOrDp5ZTT#~js zZppY9A%&Ehx;)7r48{1hgWgRranjH&$yVAN4R1$U=0Xys(e2oON#Zn06Wg7)l)ZVf z8f^0-tAQ3*anYpeRnsXjrCmUE3o50i=>fGTP19zbzo5Jrx{qngOP1@BY8L4-|FuGWlI5PS z*H^2xOZ=K6CoS=xIRj*VG)wCB=N}9&?I|OnT5z3cJ zT0>XkavOC}pf)g;1W{1+XAqrxbI{N6#?AhJUUa|n+k zJc*Ft?%W3N={RhHXHcOPL(DyR*xg~EW$~IX@GpM zy*W$(JB?t+w^C(>$($%Svxz7iEFdM0iXmlBA$$>GFOuv_$bMcN?S!Is3;_emmJmcJ zF@Nkag!2er2FSa_Uf7@rp#ZV>3uy2a0K)~45raL)ivvmL*Fo^romU1bvi}ZT5l<0` z{D64*KeAmrj^Qm?O7LQ?H@x8~VvfbwkS`zPKc9&4*<3)8FX#D#dusuE57NO_hF5;NGEI7FB%k?X|7N)ZDjBUioPa15Mu^VEo6=`_dr_*9Ia90~Gg zrlOn4m!oHxS~As4OC^nslrR6A>j1>__%TZ}S4L=X6mm`n*@AFE4y?~Qn z1h4{niLDk(MsclFrAs2fc{eNr?I(J{PM1iM$co5?NW*dd{lf!g4D(HeVR$XKt{MjP zDtiLmi;_uM3fu~I_UBNF4Z2@E>}$x{rGu0x8c|;nWJP<$c>2ijCN6F%B}y$~M`aAfD1(Y(aJH8yx4nYo*syqoC#vHPQV?t$A8_BDya z>-+Q*AOFMA$-~$R*QLdNY3p3~xqfmdgY=#L=wh#OXCk|pQa(sIf&QRZS?uRm#uxo= znD82W58sh@J{&(H@n0S5fdlcy#@FSCrbd{p!+reQ1GD^3Pt5bt={X*rO-X4!GTWp4`;#F|?-%d{*QJG|v~_y;Y?9nQ z6h1RUZl9c&fq8ct74Oa@&&HMa;tELKOGX!f&Sw`w%KITF(C^0;u*nbcW2c_vub%4K z)ZvEK)b$cmM@tQJg>}FJEC^uv^*U8GV-%k{7D7qzk)&0rBJ6E#)NZqEzs;f>Ef=dd z@GZr3SW2~Kinua(Vpa*mYtnM8)eRG#%xc+W*Z`KY)-XkcZjy(<6A{VJJkzj+hNjN4 z*FgqhBgRL7O+}KVd*tlz$=O{puuIarq~|YW=pH$>OM35-r+-JD-X%kPckUf-oh~05 zm!|JVJDUppKZd^({!JJ0hX3gX>Y+m9Xj4Ynio~_~smHTm?(sJj_7&-6`ox#xl2+c^+)|sinaO|lbCd40|jC?jx0I;jqS?np}cHY+!3|%F3HR+ zZBvi|IW>?RTl5egdhpSKlw*M$d+)`BRFGKMh=Zg+bCWF>ZG7sR*QBH@gm>n;~IUTV&>_j2KY1vBJsY1%`EOhd@Vs+Wwg>Ftq ztsc9#&}*j)X}hn`XZIKS`FhM6um=l+0um99gwbu6guUYBS_fMkfv#UAS*qGP>_coBq+l2uPg$U%sCyvES&y5|x#7 zgMguHwI$0eUL)pBy;z^Cl$Xs?pifqE2j}>~Io-jf3aMWsco`F1E@J8?{4GzSn|@^) zh;TE%sT#oi+9rQE+@7v__iDOfn`Mn+a?>p0H6`z_yRuKf^CDHa+`R2dj$Wc`seF-Z zcGQ0n97q!1j+$LDu;s=*Yr2?-E+>u8m4 zz1pq%zIt%gk%C#^6L$nBh-0)av{yw#SQl57;Pn2eg#+3FJn-7OxFrUh*U?v@TS7Lp zb}yzvTi_2+ofWJWYXrWAqk6W0fqz|?m`HtXd(e zXBny*cv-JmjyhAu@S&+r>T0EI)w7Wi)S^tJd9a7Al^GFz_456x`4cKY9kbf1>|vpxs{7J^01Q zA4i&96OFEkoypVNT_0{-e4HGAaB(}C-#E7$K@&3@=bBPqL+aa+)bHc!gVIhs_eje9 zNFyNT(^+LU2C*3UU(rWH*g_}@Kki!vSoM8N__@Ai9Pl2e?Ii)m+zEk~G6i01ce@T> zUk_<*|AiBWcp~RKWF0u$UJjf*f4zDs=R9N`INN#~BFx(QI;lXyddLvBLO~9>BdrUo zUBTY|=rHf$ZCzST2lW0JSj7;XzF>8AAfWa~Lo&ifWJ})kV^ly6b4cD+-wLSx(NN&N zBm47!I?VEKIC+>w50`m}1vh=mgPx&&L*2vMpF{+HZV}*Px*eL+ zsvog+)uaqZ98$3?Y^Y0hju;G?F;Vrhq3YHxy-rn!)UXE#Zp4eR#jHq}hHiXCr<#F_ zrhyGNWa+ofjpN@6FNHiHM(75vyWyL`GPLSy?+X=RsD0a5bJ)jQ2q!-Kslgk^X9uzN^|gxq(?eT9(N;N%628Qyjb@jD6$6ATb-4qHB zyg*7g@}d`oOoM4eb&~|roRsH&8jxmDTI~HN`wpZSN z1UOrx%mls{l3Ad6rvX97q2t-~MFu6un~776#Hp_rwi6$2eDpXz_3)#e`0+>5@gFI} zvQNh*ge%!`S6H;$VM z_JUQ0B^)DoXp2 zX;&}t>ID$7LLI`wo!kFQvO*zi(01z=RO@CRf8)|hkBA?SBQf*^c@X8(?6pP;dS zpou3a_XJISgMRx2o&L8N7Q{US-M>1JFef~Vpjhu8&TmKG+K7A`?Y{r^!wU~j?nHmN z5rF^~mCrJFGc76I>X~VE_BA`l8l7XiG7>xXkR*z`vAB?bP})T>JbLfkb2je9gN@x` z#y@m-=n(#d&wo_Nw-VF4AvPXA$;YSR%=0kJeL+C&{to_+`kEwKJ-I_PdDek?GWVzc elz$eA3MT+CcG>tetUeFJ$VEMi0Y!cdM*1)39m~i7 literal 0 HcmV?d00001 diff --git a/Backend/src/routes/__pycache__/report_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/report_routes.cpython-312.pyc index ad6196863163d97866767c24634f744ddfe460b2..77a874af856590b4ae0e63ad1da66f9b13d98742 100644 GIT binary patch delta 9221 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 delta 2131 zcmZ{kUu;uV9LMjUwrjh!>(;gF*6nViY@@Js}<0ft(i;R3I7?1)^p#@xfqBOnfjtm`F4?{u#svNqi`O9tcmK-)%c4#wPdE z@9%fc@7&-0o!>d{f6M;B&5a&qjKzTTj`(RI!p;IrpCuLcAT^T>tVDgH|JWdSB| z+^B>ldNJyw*SwBFHo=N&Q0*0OdAQ`Md(?B8FNU*mfjwU2Lw-8JxAoQID%{$@22o&D zHLR*e>_b6ZG3i82C#{!@OK_uY#T0za|FddKP(bBK3zF7Et}7+~17Fq7GG7z_Z)8yz>ryxbz*| zJQV_cHdObn9r@^RASv3<`01yC6#JKGLqo^G5T}opC$Hy7a6!_?$~N-rJIi`rDu2n9 z`tnYk;1Rzdy)NbXJU1&I;qv@dG0%qs^n-@;mAx#IYzAzh!;KrZZ7JJEg_cVuv8Eji z+YPs_>T)`sP}QSKHl-P^qD}BIoXuhSTVuiz7KsgF`{?0dhuA}^=<#5`%?mCc{W{ph z`{}R2w67i<=ETRCGDIaCCv|kJDaiNIxu)H_wt`ns5P2MgPe-1vm!*W~%$C(Y(bA37ct=A*Ku-Yc2H0;buB5S%n8X=qahyreSgn6M9wJ z=+oGo8un;kvL*iF?jPq0wa+a7E&XYwT^&8%+cgfK`*wxP<1BD4hx7iuF_GpvQsVu4 zy)-`3ws@L_p5PN5{7IWU#J%O8uyL0Jras&_F~EJ}K44+`r`m0Dul>4hxSbilZ|au& zoPPwmnO?C%ZB0h0jeGBVB( zc9nVy9A<0Hf`a}sDs`28Yh2T1qLVprzY2g3GwgYckH+PcN?rrsJYa@_-tFo0MozGiOUv!O7X**Jr*zUt3WBGWuZ@K4gPou^0AOznt9d2~93XR_ z2(zi1vDoJ6dl^O$05cd)MN_h@9N8o`9Ezq+sw5-p z5BDWpQ>q9=6c0WqXd}aFZiXSUb}8Ngr@-qeW)x2lEFY!On2SG2pNq8?@4?d$JVR$< zjXa@eVgcJZ=2+pDKZ)%W`StY9cr0mF0NTW;X6-(yq#{Vx<%G;mezob%CYGV8=v7IITr;Y%Zw&DD2fc{+#BdVG)yVS4|HK$`@ zh^%eQ&LkSV1R91w#vgNc894&I^}xQJy(E{$gXAJJdG=^|dT64HzB#dPWUs()|E0SA z9dT))AcC>t@|{gCjTNk56l`3j_w3fC?m~rW+Bt81!C^`#=kVOA1bJ8F1W!RQz2g?z ODes|Q$k=j$z33n8#PjU{ diff --git a/Backend/src/routes/admin_privacy_routes.py b/Backend/src/routes/admin_privacy_routes.py new file mode 100644 index 00000000..05da625d --- /dev/null +++ b/Backend/src/routes/admin_privacy_routes.py @@ -0,0 +1,120 @@ +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session + +from ..config.database import get_db +from ..middleware.auth import authorize_roles +from ..models.user import User +from ..schemas.admin_privacy import ( + CookieIntegrationSettings, + CookieIntegrationSettingsResponse, + CookiePolicySettings, + CookiePolicySettingsResponse, +) +from ..services.privacy_admin_service import privacy_admin_service + + +router = APIRouter(prefix="/admin/privacy", tags=["admin-privacy"]) + + +@router.get( + "/cookie-policy", + response_model=CookiePolicySettingsResponse, + status_code=status.HTTP_200_OK, +) +def get_cookie_policy( + db: Session = Depends(get_db), + _: User = Depends(authorize_roles("admin")), +) -> CookiePolicySettingsResponse: + """ + Get global cookie policy configuration (admin only). + """ + settings = privacy_admin_service.get_policy_settings(db) + policy = privacy_admin_service.get_or_create_policy(db) + updated_by_name = ( + policy.updated_by.full_name if getattr(policy, "updated_by", None) else None + ) + + return CookiePolicySettingsResponse( + data=settings, + updated_at=policy.updated_at, + updated_by=updated_by_name, + ) + + +@router.put( + "/cookie-policy", + response_model=CookiePolicySettingsResponse, + status_code=status.HTTP_200_OK, +) +def update_cookie_policy( + payload: CookiePolicySettings, + db: Session = Depends(get_db), + current_user: User = Depends(authorize_roles("admin")), +) -> CookiePolicySettingsResponse: + """ + Update global cookie policy configuration (admin only). + """ + policy = privacy_admin_service.update_policy(db, payload, current_user) + settings = privacy_admin_service.get_policy_settings(db) + updated_by_name = ( + policy.updated_by.full_name if getattr(policy, "updated_by", None) else None + ) + + return CookiePolicySettingsResponse( + data=settings, + updated_at=policy.updated_at, + updated_by=updated_by_name, + ) + + +@router.get( + "/integrations", + response_model=CookieIntegrationSettingsResponse, + status_code=status.HTTP_200_OK, +) +def get_cookie_integrations( + db: Session = Depends(get_db), + _: User = Depends(authorize_roles("admin")), +) -> CookieIntegrationSettingsResponse: + """ + Get IDs for third-party integrations (admin only). + """ + settings = privacy_admin_service.get_integration_settings(db) + cfg = privacy_admin_service.get_or_create_integrations(db) + updated_by_name = ( + cfg.updated_by.full_name if getattr(cfg, "updated_by", None) else None + ) + + return CookieIntegrationSettingsResponse( + data=settings, + updated_at=cfg.updated_at, + updated_by=updated_by_name, + ) + + +@router.put( + "/integrations", + response_model=CookieIntegrationSettingsResponse, + status_code=status.HTTP_200_OK, +) +def update_cookie_integrations( + payload: CookieIntegrationSettings, + db: Session = Depends(get_db), + current_user: User = Depends(authorize_roles("admin")), +) -> CookieIntegrationSettingsResponse: + """ + Update IDs for third-party integrations (admin only). + """ + cfg = privacy_admin_service.update_integrations(db, payload, current_user) + settings = privacy_admin_service.get_integration_settings(db) + updated_by_name = ( + cfg.updated_by.full_name if getattr(cfg, "updated_by", None) else None + ) + + return CookieIntegrationSettingsResponse( + data=settings, + updated_at=cfg.updated_at, + updated_by=updated_by_name, + ) + + diff --git a/Backend/src/routes/audit_routes.py b/Backend/src/routes/audit_routes.py new file mode 100644 index 00000000..dde2d513 --- /dev/null +++ b/Backend/src/routes/audit_routes.py @@ -0,0 +1,239 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from sqlalchemy import desc, or_, func +from typing import Optional +from datetime import datetime + +from ..config.database import get_db +from ..middleware.auth import get_current_user, authorize_roles +from ..models.user import User +from ..models.audit_log import AuditLog + +router = APIRouter(prefix="/audit-logs", tags=["audit-logs"]) + + +@router.get("/") +async def get_audit_logs( + action: Optional[str] = Query(None, description="Filter by action"), + resource_type: Optional[str] = Query(None, description="Filter by resource type"), + user_id: Optional[int] = Query(None, description="Filter by user ID"), + status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"), + search: Optional[str] = Query(None, description="Search in action, resource_type, or details"), + start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"), + end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"), + page: int = Query(1, ge=1, description="Page number"), + limit: int = Query(20, ge=1, le=100, description="Items per page"), + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Get audit logs (Admin only)""" + try: + query = db.query(AuditLog) + + # Apply filters + if action: + query = query.filter(AuditLog.action.like(f"%{action}%")) + + if resource_type: + query = query.filter(AuditLog.resource_type == resource_type) + + if user_id: + query = query.filter(AuditLog.user_id == user_id) + + if status_filter: + query = query.filter(AuditLog.status == status_filter) + + if search: + search_filter = or_( + AuditLog.action.like(f"%{search}%"), + AuditLog.resource_type.like(f"%{search}%"), + AuditLog.ip_address.like(f"%{search}%") + ) + query = query.filter(search_filter) + + # Date range filter + if start_date: + try: + start = datetime.strptime(start_date, "%Y-%m-%d") + query = query.filter(AuditLog.created_at >= start) + except ValueError: + pass + + if end_date: + try: + end = datetime.strptime(end_date, "%Y-%m-%d") + # Set to end of day + end = end.replace(hour=23, minute=59, second=59) + query = query.filter(AuditLog.created_at <= end) + except ValueError: + pass + + # Get total count + total = query.count() + + # Apply pagination and ordering + offset = (page - 1) * limit + logs = query.order_by(desc(AuditLog.created_at)).offset(offset).limit(limit).all() + + # Format response + result = [] + for log in logs: + log_dict = { + "id": log.id, + "user_id": log.user_id, + "action": log.action, + "resource_type": log.resource_type, + "resource_id": log.resource_id, + "ip_address": log.ip_address, + "user_agent": log.user_agent, + "request_id": log.request_id, + "details": log.details, + "status": log.status, + "error_message": log.error_message, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + + # Add user info if available + if log.user: + log_dict["user"] = { + "id": log.user.id, + "full_name": log.user.full_name, + "email": log.user.email, + } + + result.append(log_dict) + + return { + "status": "success", + "data": { + "logs": 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.get("/stats") +async def get_audit_stats( + start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"), + end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"), + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Get audit log statistics (Admin only)""" + try: + query = db.query(AuditLog) + + # Date range filter + if start_date: + try: + start = datetime.strptime(start_date, "%Y-%m-%d") + query = query.filter(AuditLog.created_at >= start) + except ValueError: + pass + + if end_date: + try: + end = datetime.strptime(end_date, "%Y-%m-%d") + end = end.replace(hour=23, minute=59, second=59) + query = query.filter(AuditLog.created_at <= end) + except ValueError: + pass + + # Get statistics + total_logs = query.count() + success_count = query.filter(AuditLog.status == "success").count() + failed_count = query.filter(AuditLog.status == "failed").count() + error_count = query.filter(AuditLog.status == "error").count() + + # Get top actions + top_actions = ( + db.query( + AuditLog.action, + func.count(AuditLog.id).label("count") + ) + .group_by(AuditLog.action) + .order_by(desc("count")) + .limit(10) + .all() + ) + + # Get top resource types + top_resource_types = ( + db.query( + AuditLog.resource_type, + func.count(AuditLog.id).label("count") + ) + .group_by(AuditLog.resource_type) + .order_by(desc("count")) + .limit(10) + .all() + ) + + return { + "status": "success", + "data": { + "total": total_logs, + "by_status": { + "success": success_count, + "failed": failed_count, + "error": error_count, + }, + "top_actions": [{"action": action, "count": count} for action, count in top_actions], + "top_resource_types": [{"resource_type": rt, "count": count} for rt, count in top_resource_types], + }, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{id}") +async def get_audit_log_by_id( + id: int, + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Get audit log by ID (Admin only)""" + try: + log = db.query(AuditLog).filter(AuditLog.id == id).first() + + if not log: + raise HTTPException(status_code=404, detail="Audit log not found") + + log_dict = { + "id": log.id, + "user_id": log.user_id, + "action": log.action, + "resource_type": log.resource_type, + "resource_id": log.resource_id, + "ip_address": log.ip_address, + "user_agent": log.user_agent, + "request_id": log.request_id, + "details": log.details, + "status": log.status, + "error_message": log.error_message, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + + if log.user: + log_dict["user"] = { + "id": log.user.id, + "full_name": log.user.full_name, + "email": log.user.email, + } + + return { + "status": "success", + "data": {"log": log_dict} + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/Backend/src/routes/auth_routes.py b/Backend/src/routes/auth_routes.py index da2a3d5f..a8f365ea 100644 --- a/Backend/src/routes/auth_routes.py +++ b/Backend/src/routes/auth_routes.py @@ -163,7 +163,12 @@ async def get_profile( """Get current user profile""" try: user = await auth_service.get_profile(db, current_user.id) - return user + return { + "status": "success", + "data": { + "user": user + } + } except ValueError as e: if "User not found" in str(e): raise HTTPException( @@ -176,6 +181,46 @@ async def get_profile( ) +@router.put("/profile") +async def update_profile( + profile_data: dict, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Update current user profile""" + try: + user = await auth_service.update_profile( + db=db, + user_id=current_user.id, + full_name=profile_data.get("full_name"), + email=profile_data.get("email"), + phone_number=profile_data.get("phone_number"), + password=profile_data.get("password"), + current_password=profile_data.get("currentPassword") + ) + return { + "status": "success", + "message": "Profile updated successfully", + "data": { + "user": user + } + } + except ValueError as e: + error_message = str(e) + status_code = status.HTTP_400_BAD_REQUEST + if "not found" in error_message.lower(): + status_code = status.HTTP_404_NOT_FOUND + raise HTTPException( + status_code=status_code, + detail=error_message + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"An error occurred: {str(e)}" + ) + + @router.post("/forgot-password", response_model=MessageResponse) async def forgot_password( request: ForgotPasswordRequest, diff --git a/Backend/src/routes/banner_routes.py b/Backend/src/routes/banner_routes.py index a6061a3a..3328f36a 100644 --- a/Backend/src/routes/banner_routes.py +++ b/Backend/src/routes/banner_routes.py @@ -1,9 +1,12 @@ -from fastapi import APIRouter, Depends, HTTPException, status, Query, Request +from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, UploadFile, File from sqlalchemy.orm import Session from sqlalchemy import and_, or_ from typing import Optional from datetime import datetime +from pathlib import Path import os +import aiofiles +import uuid from ..config.database import get_db from ..middleware.auth import get_current_user, authorize_roles @@ -215,6 +218,12 @@ async def delete_banner( if not banner: raise HTTPException(status_code=404, detail="Banner not found") + # Delete image file if it exists and is a local upload + if banner.image_url and banner.image_url.startswith('/uploads/banners/'): + file_path = Path(__file__).parent.parent.parent / "uploads" / "banners" / Path(banner.image_url).name + if file_path.exists(): + file_path.unlink() + db.delete(banner) db.commit() @@ -227,3 +236,51 @@ async def delete_banner( except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/upload", dependencies=[Depends(authorize_roles("admin"))]) +async def upload_banner_image( + request: Request, + image: UploadFile = File(...), + current_user: User = Depends(authorize_roles("admin")), +): + """Upload banner image (Admin only)""" + try: + # Validate file type + if not image.content_type or not image.content_type.startswith('image/'): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be an image" + ) + + # Create uploads directory + upload_dir = Path(__file__).parent.parent.parent / "uploads" / "banners" + upload_dir.mkdir(parents=True, exist_ok=True) + + # Generate filename + ext = Path(image.filename).suffix + filename = f"banner-{uuid.uuid4()}{ext}" + file_path = upload_dir / filename + + # Save file + async with aiofiles.open(file_path, 'wb') as f: + content = await image.read() + await f.write(content) + + # Return the image URL + image_url = f"/uploads/banners/{filename}" + base_url = get_base_url(request) + full_url = normalize_image_url(image_url, base_url) + + return { + "status": "success", + "message": "Image uploaded successfully", + "data": { + "image_url": image_url, + "full_url": full_url + } + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/Backend/src/routes/booking_routes.py b/Backend/src/routes/booking_routes.py index f370d089..37a88312 100644 --- a/Backend/src/routes/booking_routes.py +++ b/Backend/src/routes/booking_routes.py @@ -4,14 +4,21 @@ from sqlalchemy import and_, or_ from typing import Optional from datetime import datetime import random +import os from ..config.database import get_db +from ..config.settings import settings from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.booking import Booking, BookingStatus from ..models.room import Room from ..models.room_type import RoomType from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus +from ..utils.mailer import send_email +from ..utils.email_templates import ( + booking_confirmation_email_template, + booking_status_changed_email_template +) router = APIRouter(prefix="/bookings", tags=["bookings"]) @@ -255,6 +262,33 @@ async def create_booking( # Fetch with relations booking = db.query(Booking).filter(Booking.id == booking.id).first() + # Send booking confirmation email (non-blocking) + try: + client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + room = db.query(Room).filter(Room.id == room_id).first() + room_type_name = room.room_type.name if room and room.room_type else "Room" + + email_html = booking_confirmation_email_template( + booking_number=booking.booking_number, + guest_name=current_user.full_name, + room_number=room.room_number if room else "N/A", + room_type=room_type_name, + check_in=check_in.strftime("%B %d, %Y"), + check_out=check_out.strftime("%B %d, %Y"), + num_guests=guest_count, + total_price=float(total_price), + requires_deposit=requires_deposit, + deposit_amount=deposit_amount if requires_deposit else None, + client_url=client_url + ) + await send_email( + to=current_user.email, + subject=f"Booking Confirmation - {booking.booking_number}", + html=email_html + ) + except Exception as e: + print(f"Failed to send booking confirmation email: {e}") + return { "success": True, "data": {"booking": booking}, @@ -354,6 +388,23 @@ async def cancel_booking( booking.status = BookingStatus.cancelled db.commit() + # Send cancellation email (non-blocking) + try: + client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + email_html = booking_status_changed_email_template( + booking_number=booking.booking_number, + guest_name=booking.user.full_name if booking.user else "Guest", + status="cancelled", + client_url=client_url + ) + await send_email( + to=booking.user.email if booking.user else None, + subject=f"Booking Cancelled - {booking.booking_number}", + html=email_html + ) + except Exception as e: + print(f"Failed to send cancellation email: {e}") + return { "success": True, "data": {"booking": booking} @@ -378,6 +429,7 @@ async def update_booking( if not booking: raise HTTPException(status_code=404, detail="Booking not found") + old_status = booking.status status_value = booking_data.get("status") if status_value: try: @@ -388,6 +440,24 @@ async def update_booking( db.commit() db.refresh(booking) + # Send status change email if status changed (non-blocking) + if status_value and old_status != booking.status: + try: + client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + email_html = booking_status_changed_email_template( + booking_number=booking.booking_number, + guest_name=booking.user.full_name if booking.user else "Guest", + status=booking.status.value, + client_url=client_url + ) + await send_email( + to=booking.user.email if booking.user else None, + subject=f"Booking Status Updated - {booking.booking_number}", + html=email_html + ) + except Exception as e: + print(f"Failed to send status change email: {e}") + return { "status": "success", "message": "Booking updated successfully", diff --git a/Backend/src/routes/payment_routes.py b/Backend/src/routes/payment_routes.py index c72a8392..554fc8e6 100644 --- a/Backend/src/routes/payment_routes.py +++ b/Backend/src/routes/payment_routes.py @@ -2,12 +2,16 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from typing import Optional from datetime import datetime +import os from ..config.database import get_db +from ..config.settings import settings from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus -from ..models.booking import Booking +from ..models.booking import Booking, BookingStatus +from ..utils.mailer import send_email +from ..utils.email_templates import payment_confirmation_email_template router = APIRouter(prefix="/payments", tags=["payments"]) @@ -85,6 +89,63 @@ async def get_payments( raise HTTPException(status_code=500, detail=str(e)) +@router.get("/booking/{booking_id}") +async def get_payments_by_booking_id( + booking_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get all payments for a specific booking""" + try: + # Check if booking exists and user has access + booking = db.query(Booking).filter(Booking.id == booking_id).first() + if not booking: + raise HTTPException(status_code=404, detail="Booking not found") + + # Check access - users can only see their own bookings unless admin + if current_user.role_id != 1 and booking.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Forbidden") + + # Get all payments for this booking + payments = db.query(Payment).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all() + + result = [] + for payment in payments: + payment_dict = { + "id": payment.id, + "booking_id": payment.booking_id, + "amount": float(payment.amount) if payment.amount else 0.0, + "payment_method": payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, + "payment_type": payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, + "deposit_percentage": payment.deposit_percentage, + "related_payment_id": payment.related_payment_id, + "payment_status": payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, + "transaction_id": payment.transaction_id, + "payment_date": payment.payment_date.isoformat() if payment.payment_date else None, + "notes": payment.notes, + "created_at": payment.created_at.isoformat() if payment.created_at else None, + } + + if payment.booking: + payment_dict["booking"] = { + "id": payment.booking.id, + "booking_number": payment.booking.booking_number, + } + + result.append(payment_dict) + + return { + "status": "success", + "data": { + "payments": result + } + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/{id}") async def get_payment_by_id( id: int, @@ -169,11 +230,32 @@ async def create_payment( # If marked as paid, update status if payment_data.get("mark_as_paid"): payment.payment_status = PaymentStatus.completed + payment.payment_date = datetime.utcnow() db.add(payment) db.commit() db.refresh(payment) + # Send payment confirmation email if payment was marked as paid (non-blocking) + if payment.payment_status == PaymentStatus.completed and booking.user: + try: + client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + email_html = payment_confirmation_email_template( + booking_number=booking.booking_number, + guest_name=booking.user.full_name, + amount=float(payment.amount), + payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), + transaction_id=payment.transaction_id, + client_url=client_url + ) + await send_email( + to=booking.user.email, + subject=f"Payment Confirmed - {booking.booking_number}", + html=email_html + ) + except Exception as e: + print(f"Failed to send payment confirmation email: {e}") + return { "status": "success", "message": "Payment created successfully", @@ -209,6 +291,7 @@ async def update_payment_status( if status_data.get("transaction_id"): payment.transaction_id = status_data["transaction_id"] + old_status = payment.payment_status if status_data.get("mark_as_paid"): payment.payment_status = PaymentStatus.completed payment.payment_date = datetime.utcnow() @@ -216,6 +299,37 @@ async def update_payment_status( db.commit() db.refresh(payment) + # Send payment confirmation email if payment was just completed (non-blocking) + if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed: + try: + # Refresh booking relationship + payment = db.query(Payment).filter(Payment.id == id).first() + if payment.booking and payment.booking.user: + client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + email_html = payment_confirmation_email_template( + booking_number=payment.booking.booking_number, + guest_name=payment.booking.user.full_name, + amount=float(payment.amount), + payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), + transaction_id=payment.transaction_id, + client_url=client_url + ) + await send_email( + to=payment.booking.user.email, + subject=f"Payment Confirmed - {payment.booking.booking_number}", + html=email_html + ) + + # If this is a deposit payment, update booking deposit_paid status + if payment.payment_type == PaymentType.deposit and payment.booking: + payment.booking.deposit_paid = True + # Optionally auto-confirm booking if deposit is paid + if payment.booking.status == BookingStatus.pending: + payment.booking.status = BookingStatus.confirmed + db.commit() + except Exception as e: + print(f"Failed to send payment confirmation email: {e}") + return { "status": "success", "message": "Payment status updated successfully", diff --git a/Backend/src/routes/privacy_routes.py b/Backend/src/routes/privacy_routes.py new file mode 100644 index 00000000..57f5233c --- /dev/null +++ b/Backend/src/routes/privacy_routes.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, Depends, Request, Response, status +from sqlalchemy.orm import Session + +from ..config.database import get_db +from ..config.logging_config import get_logger +from ..config.settings import settings +from ..middleware.cookie_consent import COOKIE_CONSENT_COOKIE_NAME, _parse_consent_cookie +from ..schemas.admin_privacy import PublicPrivacyConfigResponse +from ..schemas.privacy import ( + CookieCategoryPreferences, + CookieConsent, + CookieConsentResponse, + UpdateCookieConsentRequest, +) +from ..services.privacy_admin_service import privacy_admin_service + + +logger = get_logger(__name__) + +router = APIRouter(prefix="/privacy", tags=["privacy"]) + + +@router.get( + "/cookie-consent", + response_model=CookieConsentResponse, + status_code=status.HTTP_200_OK, +) +async def get_cookie_consent(request: Request) -> CookieConsentResponse: + """ + Return the current cookie consent preferences. + Reads from the cookie (if present) or returns default (necessary only). + """ + raw_cookie = request.cookies.get(COOKIE_CONSENT_COOKIE_NAME) + consent = _parse_consent_cookie(raw_cookie) + + # Ensure necessary is always true + consent.categories.necessary = True + + return CookieConsentResponse(data=consent) + + +@router.post( + "/cookie-consent", + response_model=CookieConsentResponse, + status_code=status.HTTP_200_OK, +) +async def update_cookie_consent( + request: UpdateCookieConsentRequest, response: Response +) -> CookieConsentResponse: + """ + Update cookie consent preferences. + + The 'necessary' category is controlled by the server and always true. + """ + # Build categories from existing cookie (if any) so partial updates work + existing_raw = response.headers.get("cookie") # usually empty here + # We can't reliably read cookies from the response; rely on defaults. + # For the purposes of this API, we always start from defaults and then + # override with the request payload. + categories = CookieCategoryPreferences() + + if request.analytics is not None: + categories.analytics = request.analytics + if request.marketing is not None: + categories.marketing = request.marketing + if request.preferences is not None: + categories.preferences = request.preferences + + # 'necessary' enforced server-side + categories.necessary = True + + consent = CookieConsent(categories=categories, has_decided=True) + + # Persist consent as a secure, HttpOnly cookie + response.set_cookie( + key=COOKIE_CONSENT_COOKIE_NAME, + value=consent.model_dump_json(), + httponly=True, + secure=settings.is_production, + samesite="lax", + max_age=365 * 24 * 60 * 60, # 1 year + path="/", + ) + + logger.info( + "Cookie consent updated: analytics=%s, marketing=%s, preferences=%s", + consent.categories.analytics, + consent.categories.marketing, + consent.categories.preferences, + ) + + return CookieConsentResponse(data=consent) + + +@router.get( + "/config", + response_model=PublicPrivacyConfigResponse, + status_code=status.HTTP_200_OK, +) +async def get_public_privacy_config( + db: Session = Depends(get_db), +) -> PublicPrivacyConfigResponse: + """ + Public privacy configuration for the frontend: + - Global policy flags + - Public integration IDs (e.g. GA measurement ID) + """ + config = privacy_admin_service.get_public_privacy_config(db) + return PublicPrivacyConfigResponse(data=config) + + diff --git a/Backend/src/routes/report_routes.py b/Backend/src/routes/report_routes.py index b0b75843..bfebd8b3 100644 --- a/Backend/src/routes/report_routes.py +++ b/Backend/src/routes/report_routes.py @@ -10,6 +10,8 @@ from ..models.user import User from ..models.booking import Booking, BookingStatus from ..models.payment import Payment, PaymentStatus from ..models.room import Room +from ..models.service_usage import ServiceUsage +from ..models.service import Service router = APIRouter(prefix="/reports", tags=["reports"]) @@ -140,6 +142,33 @@ async def get_reports( for room_id, room_number, bookings, revenue in top_rooms_data ] + # Service usage statistics + service_usage_query = db.query( + Service.id, + Service.name, + func.count(ServiceUsage.id).label('usage_count'), + func.sum(ServiceUsage.total_price).label('total_revenue') + ).join(ServiceUsage, Service.id == ServiceUsage.service_id) + + if start_date: + service_usage_query = service_usage_query.filter(ServiceUsage.usage_date >= start_date) + if end_date: + service_usage_query = service_usage_query.filter(ServiceUsage.usage_date <= end_date) + + service_usage_data = service_usage_query.group_by(Service.id, Service.name).order_by( + func.sum(ServiceUsage.total_price).desc() + ).limit(10).all() + + service_usage = [ + { + "service_id": service_id, + "service_name": service_name, + "usage_count": int(usage_count or 0), + "total_revenue": float(total_revenue or 0) + } + for service_id, service_name, usage_count, total_revenue in service_usage_data + ] + return { "status": "success", "success": True, @@ -152,6 +181,7 @@ async def get_reports( "revenue_by_date": revenue_by_date if revenue_by_date else None, "bookings_by_status": bookings_by_status, "top_rooms": top_rooms if top_rooms else None, + "service_usage": service_usage if service_usage else None, } } except Exception as e: @@ -221,6 +251,171 @@ async def get_dashboard_stats( raise HTTPException(status_code=500, detail=str(e)) +@router.get("/customer/dashboard") +async def get_customer_dashboard_stats( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Get customer dashboard statistics""" + try: + from datetime import datetime, timedelta + + # Total bookings count for user + total_bookings = db.query(Booking).filter( + Booking.user_id == current_user.id + ).count() + + # Total spending (sum of completed payments from user's bookings) + user_bookings = db.query(Booking.id).filter( + Booking.user_id == current_user.id + ).subquery() + + total_spending = db.query(func.sum(Payment.amount)).filter( + and_( + Payment.booking_id.in_(db.query(user_bookings.c.id)), + Payment.payment_status == PaymentStatus.completed + ) + ).scalar() or 0.0 + + # Currently staying (checked_in bookings) + now = datetime.utcnow() + currently_staying = db.query(Booking).filter( + and_( + Booking.user_id == current_user.id, + Booking.status == BookingStatus.checked_in, + Booking.check_in_date <= now, + Booking.check_out_date >= now + ) + ).count() + + # Upcoming bookings (confirmed/pending with check_in_date in future) + upcoming_bookings_query = db.query(Booking).filter( + and_( + Booking.user_id == current_user.id, + Booking.status.in_([BookingStatus.confirmed, BookingStatus.pending]), + Booking.check_in_date > now + ) + ).order_by(Booking.check_in_date.asc()).limit(5).all() + + upcoming_bookings = [] + for booking in upcoming_bookings_query: + booking_dict = { + "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 isinstance(booking.status, BookingStatus) else booking.status, + "total_price": float(booking.total_price) if booking.total_price else 0.0, + } + + if booking.room: + booking_dict["room"] = { + "id": booking.room.id, + "room_number": booking.room.room_number, + "room_type": { + "name": booking.room.room_type.name if booking.room.room_type else None + } + } + + upcoming_bookings.append(booking_dict) + + # Recent activity (last 5 bookings ordered by created_at) + recent_bookings_query = db.query(Booking).filter( + Booking.user_id == current_user.id + ).order_by(Booking.created_at.desc()).limit(5).all() + + recent_activity = [] + for booking in recent_bookings_query: + activity_type = None + if booking.status == BookingStatus.checked_out: + activity_type = "Check-out" + elif booking.status == BookingStatus.checked_in: + activity_type = "Check-in" + elif booking.status == BookingStatus.confirmed: + activity_type = "Booking Confirmed" + elif booking.status == BookingStatus.pending: + activity_type = "Booking" + else: + activity_type = "Booking" + + activity_dict = { + "action": activity_type, + "booking_id": booking.id, + "booking_number": booking.booking_number, + "created_at": booking.created_at.isoformat() if booking.created_at else None, + } + + if booking.room: + activity_dict["room"] = { + "room_number": booking.room.room_number, + } + + recent_activity.append(activity_dict) + + # Calculate percentage change (placeholder - can be enhanced) + # For now, compare last month vs this month + last_month_start = (now - timedelta(days=30)).replace(day=1, hour=0, minute=0, second=0) + last_month_end = now.replace(day=1, hour=0, minute=0, second=0) - timedelta(seconds=1) + + last_month_bookings = db.query(Booking).filter( + and_( + Booking.user_id == current_user.id, + Booking.created_at >= last_month_start, + Booking.created_at <= last_month_end + ) + ).count() + + this_month_bookings = db.query(Booking).filter( + and_( + Booking.user_id == current_user.id, + Booking.created_at >= now.replace(day=1, hour=0, minute=0, second=0), + Booking.created_at <= now + ) + ).count() + + booking_change_percentage = 0 + if last_month_bookings > 0: + booking_change_percentage = ((this_month_bookings - last_month_bookings) / last_month_bookings) * 100 + + last_month_spending = db.query(func.sum(Payment.amount)).filter( + and_( + Payment.booking_id.in_(db.query(user_bookings.c.id)), + Payment.payment_status == PaymentStatus.completed, + Payment.payment_date >= last_month_start, + Payment.payment_date <= last_month_end + ) + ).scalar() or 0.0 + + this_month_spending = db.query(func.sum(Payment.amount)).filter( + and_( + Payment.booking_id.in_(db.query(user_bookings.c.id)), + Payment.payment_status == PaymentStatus.completed, + Payment.payment_date >= now.replace(day=1, hour=0, minute=0, second=0), + Payment.payment_date <= now + ) + ).scalar() or 0.0 + + spending_change_percentage = 0 + if last_month_spending > 0: + spending_change_percentage = ((this_month_spending - last_month_spending) / last_month_spending) * 100 + + return { + "status": "success", + "success": True, + "data": { + "total_bookings": total_bookings, + "total_spending": float(total_spending), + "currently_staying": currently_staying, + "upcoming_bookings": upcoming_bookings, + "recent_activity": recent_activity, + "booking_change_percentage": round(booking_change_percentage, 1), + "spending_change_percentage": round(spending_change_percentage, 1), + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/revenue") async def get_revenue_report( start_date: Optional[str] = Query(None), diff --git a/Backend/src/schemas/__pycache__/admin_privacy.cpython-312.pyc b/Backend/src/schemas/__pycache__/admin_privacy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b5e98f9815fb750f1e058276dc51831490f330c GIT binary patch literal 3077 zcmcgu&2Q936d!xNUhgI$AB6ImF!_MSkYo=9SuAx=5M!u3) zh)Qk{SNoB;d8R$hWicwcwCZ3jbXg11YJj!aWi_O=1lFD|Yf)Nz!P?hlElKMUu=aOZ zdy)Zj@NZn&R8qsWV-|Z2hWZy#?1h2tn`&B|vjw{qy39}W*FEODPWG*t)Y>mQJ}8d~ zBNfFZ74;4zp^{Sx(_GcfJ=315*|8#-9FT-owt9N+~=`D15jV^CddW+~yN@hXe4LN%E8yd zAm*Vj=)GmnS*8voq8@UO2~Y!CWwa@nOTB=`%Z%Dl1nb$DpKSK+wJQrXTC;t@rl}1{ zOJdJ~sd61r77q8KSWvMXHhq@{Vcdym$JF-y*(YjRa9Pc6`f=LhvTOo`PX{~EEEeD% z-0r0sHeZ1Q3+n%2sdTV2s}u60y|0frt1-?3hwa8Z|wk^a(VrgafoN^yqt3j z=8VCd9sFYkxZd;rBD3VDuaFhWkmoDZW-=6Dn}qzR6j=4VkHLm@MB95wu8 zDNchTE`fMJ{unyGQQR83aR2&G*EY2a+ojP>ZFKvbdH?f=#`=lh2gWxwbDK`e*Q1a6 zwzSDCAjWzl)g6YafTugvhcT2)qVZOql1$VdlOa9O_{Qo1R5W?^2? zTFB`==KC`%L3j@t#pCWwWb-%yg6T{Y(*VGXyMTGB36=Gm@y+7e2RFj74vcoSLpGOa zgW00VS%U>JU6`j+Y_>j2Z_Ip?UFIw=!uHd_x@~ohv^;n16)&H3|i^=OCeiIh@6Sp)i2Jh&nrPY0bCmBAb-z zS^H0WyD_(D77na&Nn?9Qu&=16V7nZ}hj0sYwj0UI66qV;)adrm#Vze(R;M`b;Cv$M zb?5Wl8UYZzo}NA+yN6buY3$#p4y$LSNl}xVl^ff)*=h8m_!Mq|-W{ zB2+h`G1cQFg7z$lA~mAKwFCH3;`sZV(TIVX>;9G3JgDkF6{+n VdfjXhc(jI9Wpu;*hrmPb=3j$a-y{G4 literal 0 HcmV?d00001 diff --git a/Backend/src/schemas/__pycache__/privacy.cpython-312.pyc b/Backend/src/schemas/__pycache__/privacy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..df21ff7c4d8cfa1fca516ff557ce26e752f89b1f GIT binary patch literal 2973 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 OV5)U*ocWTAul0XMTYm-s diff --git a/Backend/src/services/__pycache__/privacy_admin_service.cpython-312.pyc b/Backend/src/services/__pycache__/privacy_admin_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..75fd2ed3835dbe2cf9a9006812799516fa5263c0 GIT binary patch literal 4664 zcmds4O>7&-6`tMY&+^|ZO4d(mdM#Or=tv@Lp@w7FZEQ78np&<~wJ1sySWw)hOo@M< zUAdva06zFY0|wfn2ctlNIH$yLuLXQ;ZUNGR2nryxumPh@54q8jfdDxaeQ)-MOPK=V zqNfhPH*e>?eY1Q&-<$a&77G&?e+^9)e-}*NfH4*Qg86(dBA&v#4FKmWug0Jdaz?KduLj zit8Zq{ZiLxS}-SHQd!juqgYwj%~7v4cQtu^YYog_r^Q>jeBH8_x4(`}A^TzfAHduv z8p(1h$?__f6*Qm9YkpPG0tKJyyW?~JRnV1)An|)7AzKpAL^Y^|RdG8=2(uunA&*|f zwGaghVUHx{O5z}icq9qec6`8gv>>WckKPg2f&>;~9?3{uOvlZn?Tkxmxma1%=&fR2 z+rAo>Vc1V{DOcC1T&Pkxhe`|iYQ><{Qb|+gJd2rJV?mR16;&?wlalu*{xI?9`L^^%M_On}3vDUW415`py2;7r%2(2{Kcs}n$ z(B06z2r}Wj;oqO#6{ot1v1V)^43dGIKyR^}xC6|6VxR&W8*V(E6#~(6BKIL@xLMKQ zHUfPehl5qo;rlbJGKlQ2AomyUk-gh0N%PkLX{1~wSE?Juysl`K+#005DV1|{Q$w_I zmy$IIsYbO5M8a822Pi-qaukRib2d`mw6BIr93?h#)o zOsDWGL}zO=yj4>nWmILYel72f0`mCVm!UFWAyxu--DyAYx-%Mr;@0{hF%%Sc|3l&_ zD6ZuOPo)MwEWWUln;}r~3Fiy?b3<@4i`Bf;mpI%2CSY|O7y}lBRYH$|LOSe8hQT~A z`J!sZ`mR=rDn+w&6v-IA>|;1VWn4IcWE_Z&{}<8PN*>#SQ{N|srH!?Ttulu;D3IIa zg-Awb2U4ax^JZt}VrvGnXIGRDJk2h2W-qm7FQIJu%g9)FX5o{IA75-<*^SEIsrp%> z4OG9IQU7lp!HRx>Vj@w@JvxHqK!BY_01XxnIs>3EH3-VyF1x)rEXarJpM#zCp8|2A ze0S<}TRPK`&b6d-ZRva;%b!SdZRyl^1oM}}iK)o|q5{k-0K|U@<^bC^fLO52o6>qt zDQh`>i)v-9Vn9uqk;0l%D}JDr;34g|$`NaTVt1gYkh}>bZO1~$7X||{fsXaKzYo## zhlOF7_A>0DXMg}s$4_+P7h3TPkAL2dzugp`B<8xQ6P?sjE49>}csPf*=>(izyD zo=w3qAc~Clls!1C%J(1Lhx8xy;KS6tIKp4U%rU!!EmFKMf+Z1-iW8JCg&eURS zYOyKYOCY4mr#^{&9BVG`Mvpm=s$1u%fdkdjy+k)L(zIIl0HV4jSc4CEh3w!wCoeaS zV8R}rlNWEeXX*ym;9n|2)}_p-*LBdz*qI0qD|7(~9>UUs)!(qI(bGV5#16N!O|oMV zyT2(l`;=<|<@Um1l~)_4{%bUCmmVIG5cM99w51DhL>fQU8DDOVFaPawd;HyI^hsj+ z)7Kw2b`w{3#H$S7(n(WPluE9wDT*0ZlyX(wDj^?Ll$%?*k}U};idxO%vTnfLHDA_@ z8&#F!A7U2*&vB4B0bNLEk+9~_2PEbl{8;89dIq?MgnbF?_m&^gnOIO8FaFxxQGXWfm%P6vC^O^hN8MYoC~`8=X8T z@}#$7WhING8L`#2;3`Hj%I27ko8xyQlvekar{Qzr$#nl{h@_gVnN3ntTQ{Y?i zf|Z0npg#lwUXu0i1L+AI$2}#}|00>E~~Z?0*0X(Az%% literal 0 HcmV?d00001 diff --git a/Backend/src/services/audit_service.py b/Backend/src/services/audit_service.py new file mode 100644 index 00000000..98d52b2b --- /dev/null +++ b/Backend/src/services/audit_service.py @@ -0,0 +1,82 @@ +""" +Audit logging service for tracking important actions +""" +from sqlalchemy.orm import Session +from typing import Optional, Dict, Any +from datetime import datetime +from ..models.audit_log import AuditLog +from ..config.logging_config import get_logger + +logger = get_logger(__name__) + + +class AuditService: + """Service for creating audit log entries""" + + @staticmethod + async def log_action( + db: Session, + action: str, + resource_type: str, + user_id: Optional[int] = None, + resource_id: Optional[int] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + request_id: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + status: str = "success", + error_message: Optional[str] = None + ): + """ + Create an audit log entry + + Args: + db: Database session + action: Action performed (e.g., "user.created", "booking.cancelled") + resource_type: Type of resource (e.g., "user", "booking") + user_id: ID of user who performed the action + resource_id: ID of the resource affected + ip_address: IP address of the request + user_agent: User agent string + request_id: Request ID for tracing + details: Additional context as dictionary + status: Status of the action (success, failed, error) + error_message: Error message if action failed + """ + try: + audit_log = AuditLog( + user_id=user_id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + ip_address=ip_address, + user_agent=user_agent, + request_id=request_id, + details=details, + status=status, + error_message=error_message + ) + + db.add(audit_log) + db.commit() + + logger.info( + f"Audit log created: {action} on {resource_type}", + extra={ + "action": action, + "resource_type": resource_type, + "resource_id": resource_id, + "user_id": user_id, + "status": status, + "request_id": request_id + } + ) + except Exception as e: + logger.error(f"Failed to create audit log: {str(e)}", exc_info=True) + db.rollback() + # Don't raise exception - audit logging failures shouldn't break the app + + +# Global audit service instance +audit_service = AuditService() + diff --git a/Backend/src/services/auth_service.py b/Backend/src/services/auth_service.py index 7e1234eb..823114da 100644 --- a/Backend/src/services/auth_service.py +++ b/Backend/src/services/auth_service.py @@ -5,19 +5,29 @@ import secrets import hashlib from sqlalchemy.orm import Session from typing import Optional +import logging from ..models.user import User from ..models.refresh_token import RefreshToken from ..models.password_reset_token import PasswordResetToken from ..models.role import Role from ..utils.mailer import send_email +from ..utils.email_templates import ( + welcome_email_template, + password_reset_email_template, + password_changed_email_template +) +from ..config.settings import settings import os +logger = logging.getLogger(__name__) + class AuthService: def __init__(self): - self.jwt_secret = os.getenv("JWT_SECRET") - self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET") + # Use settings, fallback to env vars, then to defaults for development + self.jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv("JWT_SECRET", "dev-secret-key-change-in-production-12345") + self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET") or (self.jwt_secret + "-refresh") self.jwt_expires_in = os.getenv("JWT_EXPIRES_IN", "1h") self.jwt_refresh_expires_in = os.getenv("JWT_REFRESH_EXPIRES_IN", "7d") @@ -70,6 +80,7 @@ class AuthService: "name": user.full_name, "email": user.email, "phone": user.phone, + "avatar": user.avatar, "role": user.role.name if user.role else "customer", "createdAt": user.created_at.isoformat() if user.created_at else None, "updatedAt": user.updated_at.isoformat() if user.updated_at else None, @@ -115,33 +126,16 @@ class AuthService: # Send welcome email (non-blocking) try: - client_url = os.getenv("CLIENT_URL", "http://localhost:5173") + client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + email_html = welcome_email_template(user.full_name, user.email, client_url) await send_email( to=user.email, subject="Welcome to Hotel Booking", - html=f""" - - """ + html=email_html ) + logger.info(f"Welcome email sent successfully to {user.email}") except Exception as e: - print(f"Failed to send welcome email: {e}") + logger.error(f"Failed to send welcome email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True) return { "user": self.format_user_response(user), @@ -170,14 +164,42 @@ class AuthService: expiry_days = 7 if remember_me else 1 expires_at = datetime.utcnow() + timedelta(days=expiry_days) - # Save refresh token - refresh_token = RefreshToken( - user_id=user.id, - token=tokens["refreshToken"], - expires_at=expires_at - ) - db.add(refresh_token) - db.commit() + # Delete old/expired refresh tokens for this user to prevent duplicates + # This ensures we don't have multiple active tokens and prevents unique constraint violations + try: + db.query(RefreshToken).filter( + RefreshToken.user_id == user.id + ).delete() + db.flush() # Flush to ensure deletion happens before insert + + # Save new refresh token + refresh_token = RefreshToken( + user_id=user.id, + token=tokens["refreshToken"], + expires_at=expires_at + ) + db.add(refresh_token) + db.commit() + except Exception as e: + db.rollback() + logger.error(f"Error saving refresh token for user {user.id}: {str(e)}", exc_info=True) + # If there's still a duplicate, try to delete and retry once + try: + db.query(RefreshToken).filter( + RefreshToken.token == tokens["refreshToken"] + ).delete() + db.flush() + refresh_token = RefreshToken( + user_id=user.id, + token=tokens["refreshToken"], + expires_at=expires_at + ) + db.add(refresh_token) + db.commit() + except Exception as retry_error: + db.rollback() + logger.error(f"Retry failed for refresh token: {str(retry_error)}", exc_info=True) + raise ValueError("Failed to create session. Please try again.") return { "user": self.format_user_response(user), @@ -235,6 +257,53 @@ class AuthService: return self.format_user_response(user) + async def update_profile( + self, + db: Session, + user_id: int, + full_name: Optional[str] = None, + email: Optional[str] = None, + phone_number: Optional[str] = None, + password: Optional[str] = None, + current_password: Optional[str] = None + ) -> dict: + """Update user profile""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise ValueError("User not found") + + # If password is being changed, verify current password + if password: + if not current_password: + raise ValueError("Current password is required to change password") + if not self.verify_password(current_password, user.password): + raise ValueError("Current password is incorrect") + # Hash new password + user.password = self.hash_password(password) + + # Update other fields + if full_name is not None: + user.full_name = full_name + if email is not None: + # Check if email is already taken by another user + existing_user = db.query(User).filter( + User.email == email, + User.id != user_id + ).first() + if existing_user: + raise ValueError("Email already registered") + user.email = email + if phone_number is not None: + user.phone = phone_number + + db.commit() + db.refresh(user) + + # Load role + user.role = db.query(Role).filter(Role.id == user.role_id).first() + + return self.format_user_response(user) + def generate_reset_token(self) -> tuple: """Generate reset token""" reset_token = secrets.token_hex(32) @@ -270,22 +339,41 @@ class AuthService: db.commit() # Build reset URL - client_url = os.getenv("CLIENT_URL", "http://localhost:5173") + client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") reset_url = f"{client_url}/reset-password/{reset_token}" # Try to send email try: + logger.info(f"Attempting to send password reset email to {user.email}") + logger.info(f"Reset URL: {reset_url}") + email_html = password_reset_email_template(reset_url) + + # Create plain text version for better email deliverability + plain_text = f""" +Password Reset Request + +You (or someone) has requested to reset your password for your Hotel Booking account. + +Click the link below to reset your password. This link will expire in 1 hour: + +{reset_url} + +If you did not request this, please ignore this email. + +Best regards, +Hotel Booking Team + """.strip() + await send_email( to=user.email, subject="Reset password - Hotel Booking", - html=f""" -

You (or someone) has requested to reset your password.

-

Click the link below to reset your password (expires in 1 hour):

-

{reset_url}

- """ + html=email_html, + text=plain_text ) + logger.info(f"Password reset email sent successfully to {user.email} with reset URL: {reset_url}") except Exception as e: - print(f"Failed to send reset email: {e}") + logger.error(f"Failed to send password reset email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True) + # Still return success to prevent email enumeration, but log the error return { "success": True, @@ -332,13 +420,16 @@ class AuthService: # Send confirmation email (non-blocking) try: + logger.info(f"Attempting to send password changed confirmation email to {user.email}") + email_html = password_changed_email_template(user.email) await send_email( to=user.email, subject="Password Changed", - html=f"

The password for account {user.email} has been changed successfully.

" + html=email_html ) + logger.info(f"Password changed confirmation email sent successfully to {user.email}") except Exception as e: - print(f"Failed to send confirmation email: {e}") + logger.error(f"Failed to send password changed confirmation email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True) return { "success": True, diff --git a/Backend/src/services/privacy_admin_service.py b/Backend/src/services/privacy_admin_service.py new file mode 100644 index 00000000..610c4b0b --- /dev/null +++ b/Backend/src/services/privacy_admin_service.py @@ -0,0 +1,98 @@ +from sqlalchemy.orm import Session + +from ..models.cookie_policy import CookiePolicy +from ..models.cookie_integration_config import CookieIntegrationConfig +from ..models.user import User +from ..schemas.admin_privacy import ( + CookieIntegrationSettings, + CookiePolicySettings, + PublicPrivacyConfig, +) + + +class PrivacyAdminService: + """ + Service layer for admin-controlled cookie policy and integrations. + """ + + # Policy + @staticmethod + def get_or_create_policy(db: Session) -> CookiePolicy: + policy = db.query(CookiePolicy).first() + if policy: + return policy + + policy = CookiePolicy() + db.add(policy) + db.commit() + db.refresh(policy) + return policy + + @staticmethod + def get_policy_settings(db: Session) -> CookiePolicySettings: + policy = PrivacyAdminService.get_or_create_policy(db) + return CookiePolicySettings( + analytics_enabled=policy.analytics_enabled, + marketing_enabled=policy.marketing_enabled, + preferences_enabled=policy.preferences_enabled, + ) + + @staticmethod + def update_policy( + db: Session, settings: CookiePolicySettings, updated_by: User | None + ) -> CookiePolicy: + policy = PrivacyAdminService.get_or_create_policy(db) + policy.analytics_enabled = settings.analytics_enabled + policy.marketing_enabled = settings.marketing_enabled + policy.preferences_enabled = settings.preferences_enabled + if updated_by: + policy.updated_by_id = updated_by.id + db.add(policy) + db.commit() + db.refresh(policy) + return policy + + # Integrations + @staticmethod + def get_or_create_integrations(db: Session) -> CookieIntegrationConfig: + config = db.query(CookieIntegrationConfig).first() + if config: + return config + config = CookieIntegrationConfig() + db.add(config) + db.commit() + db.refresh(config) + return config + + @staticmethod + def get_integration_settings(db: Session) -> CookieIntegrationSettings: + cfg = PrivacyAdminService.get_or_create_integrations(db) + return CookieIntegrationSettings( + ga_measurement_id=cfg.ga_measurement_id, + fb_pixel_id=cfg.fb_pixel_id, + ) + + @staticmethod + def update_integrations( + db: Session, settings: CookieIntegrationSettings, updated_by: User | None + ) -> CookieIntegrationConfig: + cfg = PrivacyAdminService.get_or_create_integrations(db) + cfg.ga_measurement_id = settings.ga_measurement_id + cfg.fb_pixel_id = settings.fb_pixel_id + if updated_by: + cfg.updated_by_id = updated_by.id + db.add(cfg) + db.commit() + db.refresh(cfg) + return cfg + + @staticmethod + def get_public_privacy_config(db: Session) -> PublicPrivacyConfig: + policy = PrivacyAdminService.get_policy_settings(db) + integrations = PrivacyAdminService.get_integration_settings(db) + return PublicPrivacyConfig(policy=policy, integrations=integrations) + + +privacy_admin_service = PrivacyAdminService() + + diff --git a/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc b/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f446400c75bc406457fc927542acc05d431ba493 GIT binary patch literal 13015 zcmeHNOKcoRdhVY0gKtqJB}#T_DA}ega`+NOkt13nMS5jDOiQv9YrURrb@y=E?3teJ z?h(bt$S`*I;8Wr~McG{izFCGBh=WBC2M8A01W2504r6G6l?Mr6ILINnqy>2|I_3YX zyLz6aDXk(HNSd7L>gwvM`m6rO_y1MDX>X5laP8-ID;GyN?%(l&UAx@O10TD?E>Rzv|2RulOOqPYTEZDG0xi9LR*E@Q;I6f>H#YqwtHlnNFKQ~vOzMMPOb^Mq7@C2*Gwg&h=QqTc_a3pID5l}-KMNmf!QBdV$&C!XQ7k&%mr;^TtGr(ll8&>M7CT$SI+DtlMJooT0Z^Gd8s@ZOt=PTLJUM4?)5yLpQ!*&%pH! zbIab~9o}v-40wZc(L99a2Adn%_F_5fv`uz&1q#K03fMHg=0LrIY#NFM5;8sk>s}*q zb+i^3yLzhxRWD{0gTTL#r%RRtJuEqTZj`L3GO9jFL#<78xu9;0rjmu`IiXY8)cj!- zHvEHb{_Z1Uy+i6`VU(OuRiZ1aS<`^VL6KaOrPl3#)122WRw{}5%o!xeo2Qe9{W=Ae87KNDMR(ggiW%%xXC~xthP8JSiJ%rdCMOQIEJ1oLm4+C+DT4 zp^M3)si;PhT7|S{?nVn6mF`v9Okah`^NRI0Xg`7-Ekg1xX9q%EU3MGS-S^(Q&2{we zv-fa`;SZuV7d<%rbq{ysrN37HQvGHAchjdnpFUMS{EE$mPV+%~Zm2C{b3?(%189b; z1khAef^-O#n5ZhC&C*3(6_HeSc{XwaN~n!Q>*%AF(qHm9w(drawJiCi;K#1T1REH# z4qcX25$2`A0#eJKm)BwWRu#k49jk7oJc&$YCm7Q)u632Ft0PBXg3-Vv=2;Kc?R~In z6ICWl6@W>F{2JNNinK$gGq5}PRm!%a2zJLLf=N;^1}(qZsB_)YdftL&-_VLW5!}zS zfgU6!NpJ$4G^?sIp-?9XabU zj>^2DD_UJu<}ZNYh*_9;2@JHhMzYkt>6(@^Qb~C4tv!B#R|*Qx3VC5w#ue1*SFm(j z)PB8yaL}n;kaYvWAix}ek)WOc8aDTnN)}z@MSD!b^j8N`oupQZwwCV67IdcslGWwR zv4r(6NmT=EW%l)~V#+nkjOv+8OyRoHp-8f*IYKiFDmD*QUChH+U>d7R ziUB|xaF>!-mApK1Rn^3`R?Bk+heyt7>ve08N(wb|!PK`kzy!|J>hx4 zmCv5Hx!8HW&+g^=_r3q7%>@!cd$7AR^6txD@8{a~-n?9X?)csKiE`kC-j01m(MCAR z&4cw&jsv0&z;lqPDz{ty8+;Z1m%t6HQD^~c?}}g;>mYB`@|X>JO*mc)D;`0Kd99wu zv6A!h4~nv3Ht91I%oo5ZGC-xmvJVqfKDtvG%rC46rPY-Hcce|~Yf)9iHDYFE0&KAc z%1hPOH~W@QJ5TGaD`0lz>jg!ZfuqPcNN`aH9Z_S~w<9sL1f_+l?s|4(@*>Q_Bz}8^ z#aSV?VD%)llo+a6JblZ|St}5R0-l&Zm7x|(QY05XQC@)cQj9~S;6N+zKr~neEOSAZ zw+>bZ8ym0@Fq7T`{0)eu7jBLyI*7#)#JvTVOVX5c8s)JMK6)8`#z9C>9)mmj>=@Ti z-v6%61@{H*c#H~OpDTFNcjHIOfg@D##6Mix4tb=2%Asy@=8WbE6*N{5?C3pqr4BAOp|^+Sf5fOjB+Cg4WA6w#xls2&EC838j=I6QTfVvu&y@49aF-)O5bEp1YF zjcMtzhFni!D;}?K#l<=k^9^`$X?b$-h=&eTIb+Te&@x~^R8TKN%ZHCHjV;sy4e-61 zuwx^*aMYA1Z1wt$;ZBH_Q2%mWMn`QH+EArA&jmd?F*P=}1j_EKpMLP)pZ*Cs>8utt ze6W0yr_Hc&ysDOkbx`#Zy;hIROdcLvhE`n%v>8aF*Q}0EMp0qO3Iew5fVxnmgz4PjJJX0}MZZSjH$y7* zQ(7hC8+iGN`99gnR=x``EVIqy%M(W@j{qOB={gCqSMbQj~9h> z+Sx3e)FK)Q{v8G**=VO1j!ewJv)lU=gg~n!KSwn!ptQ}RS*^mIZULJQ#|{BZ$pdb@UN3La;qz-FfPCETUl>ftY#gp0_zIRa$=C zx}E>Um2fBkgZ!jO_|;P<;blZ1#uFNU@e9BaPo0E|h=0fm;-AN(V>@yV7SnodyQD$A z`WSeF?ebK<^@f-AGF40e2Sr^T#vq(a(Wa5KKL7T*lb2w#j_vXQ45D_%n+vJ~S~`_h zFSAXxrrg2y?Kbo%+#AFX9dog(ilV>p@nC!zOfe8j`Ds2oa)0i3c4KRX&qwp@r zZ5(pvHtj+tYTp}O1@sUE_!(b80?y+euD9>qGhc-J|LmOY^Y3|!w?kYS_PfiuLiu|? zusOfZN9}NEpzXajyUVwu`^)aZ_uFhPviBlycX6-tB455BLcB-fPw;kx!+6l0_-8Za zi|O)(??bu3%3tB{#Nhcfd&YY3e1<)r;qO4ZfwTN&Z0C=#VKGy_v95Ss)BJLfqs~a-Z56M7=`XQlGKo5Zrg5HH6JdltSpdLt&dJ@bJ z@ktTzP@>cyiBaz(PCbz}TC4q5_lytM%5W0r%txeRoiIHHFu70!Sf+z#WC{2TDG%O*6K;0l%JrU<_WU(fSU z9uGDzEiRv2KD@aL$Y`$H>-MU%c=C3(6uuEwk4+zjjd66(T*mu$jdVqARss)WmimnYw_G0c=e(H4b~>=!&bj z#|ArJ>jLamKx`wc876Lgg3k{7q%|>nZwyZecPhQcmAI8ZdWbh@`!g9 zpyh^FFJqNvq!p<_TEjrLj$v~BMNBB5>Z&vPxDiP~6>jGcqhOq@*2Xa^ZfVu@(O?9kR+IB2svbFKc>v)d0j}GSsxfmyfSN%dhc(%=VSL641g>AAH}0kd8b{ z0TOJ6Ioomd0Lg%}TSY?*01@o+0MI`}2?8L=C;25f^7!+>jX){zHkiT2tK@qdAydP} zj^;@rc#m`4aIyCpAA>?~LYV7D5CB$~LZ=9YSy2k0>chGi^lhi*9kgaAg;#h)f?g2z z?Lr84%WD}9Ky+LC8BBMm#shq?kI}GmG3>L#Lyk~XqAr)o5-nX?o`MTX1qc+Xs#`j_ zFf*|{ZN;bq4pGEYd0Kg9;qW39$64{KMRPa+adRiwCrglJ&A^!t3_r*cM%Lpj53W3n z#IXm~peG#70Y+=S86`1FFVOWR&SpGBavRQMIMKzwf+cN&OLehQcf!2m6-Bza-*C{U zI>e_HJp4Z&@cYf9n7Af?fvLSMkOZ@;9xOQr9Y^(L57a0DQo7D)@7oej2aO|xTgkuP@P7l>N2T5wJYZ8>KV^iB5#Q>HP zPBR*nAiY=OZYjo$HZ!sZT)>oOEjT;AjbV!vboH=+x`6Sb{+hD*TLJ2my}R_@ z93EjAyf;X0!m$-T(S6T}X@oJRQEz!)LCbrZU*SLNFRw^txlooMCKA%&q)S_^iG|k7 zm)|U3`X1Qkx8Pvk^ZnG=j!|2CJMzikC++3u=I_Q|DhFPoA&Ft$YOgy$IqW<4aA(#u z3$sZ$nFOc55gX1P9zQZU%F`agfK89c`7Qmy9HoM}7gKKnw=xH4t zj|k|vD_rp_c;r5#YJyn_z+9_3Mu2pLEjmu>M;sjwMLZ89shlPi;h?O(1h3G*8-EU| z?dN&^zR$-8ejnoaXa0>FydOHiM{WWVf!pm#db@Ms0Y2F)d@sMif3U;m@P7M(bN>`# zMEHD1d_eE^XgfcHFX8qHdAN{Jq|O+mD$q zyLQ+?%mBFR*=dJqHo`$^lxAaGXOA7H*)}X~r`Zm!yVvfd*)Fc9-|nW_9U;oLg_D=fPhxKX4_5c@(KG;P|FZs9w!#Je&33o(rrvrjJjHkWF4r4CD?HsV9 zn2TYv7k+Zqj^h&?6799|Ti`Tkv=ehW&-IVqkG{Yk=P8Ytp-o~2+w8K#G#kN>X+3B) M6#Y7mrF8xN2iMQdyZ`_I literal 0 HcmV?d00001 diff --git a/Backend/src/utils/__pycache__/mailer.cpython-312.pyc b/Backend/src/utils/__pycache__/mailer.cpython-312.pyc index 2ebd5ddef025260f8ab231868b231c53eb049f9d..40b320bd1e8646551b7b1ccc1875b527345c6816 100644 GIT binary patch literal 4311 zcma)9TWk|o8b0Iu*khacmf$2Xw~$t(6Njmu(5OmgAWu6ULtG%$R@~ z(+beaS*_IeWl7aV%G(CPs#$HNuBvr5G!`Hh4*v zny@kU8|E?RWg6X-ZD=s!0aZglWA~t#m?p9p0aQXF;eB5fCbgf6#wi6n&Tv z3@N`;;cKG@>lQW^#;V&)*XaFJ>pK<5*eDY+XuGpnR3OQY?+gEFbVw`%fFKl;hYTsV9(-Gs=dttWW=B$IMQ5>J4TZXKC zQw%{}zgj~Kc&U1_ltO)m7_VZ7(s#?0bt)#&q?kp6Vv(8{f7T^8=vLD^byTetmp%eu zc$Y4(O0N-evo2)auDLS?Q3E=-*Xch|se2I$|2{?$Ku@fvy4f zS?^WvETNC@&&F9Iv^O(jr{Ni9Qw+@};~Yv#R~g{%G&uNNjYd zKjWX0<(b2g2rne!{FETchY#%U?y5LDHFSC)W5XH}sHiAot!O_bA|gIaGGt14MPz6A z%f~a$w*;Bx>63zR0jS6r=%Y`GH!>rb5*ky)igZelD>S4jVb$lf_U)I)PAIt=X46W}PUI^w*{imABFU4)=c0llhW1TGZAl?#P=hL3Y85nq$I z0$gK8PH}xO-b}*@S!RN$7n>5M|X`QCG~JTWgAn&B{wuTSiyM2t&K2%#M!-ak}QBT1VS20>CSldKFRRm9o^ zaXv$1rR9t^Nw}1(nt5TC6-9i!s%}jc=RDSn=Z!W=hJL2#v;DOsg}4b#}iW`Y+gt;;c=_>I44L+8QhJlChShN!n#P| zOCW`_5laFg@kyZdH)-gR?7fs=AzeZWrtak#U@{iNjK*TBQIf@wPqcz>5#QXjWQ+LL zrIMK97|u`y;zU#+&V&we;vAZ;#3o=ys`doWL1JQQkyk0aXk&4P5n)8Cg%Ln8rIMOd zOR7VIQ*4~U&NT{RNoi8GRzeV)@Tl;yt2%JVv32Jv!Ah8Jynj6ZJUtSb5|V6WGIc4^ z!%7!qVJ4!js#hwjDsmDw3ZN+>iHS&B=6ETB$Ipteo2Yh;Ky5n`&p;0jcqJ#0|1b39 zL$qc;LzrFP`x;8V-38z7Rp0TuO`nHuhnD(^9mjLz!~fgRQSf!F`g-n;f6m?J?m3F# zo@Xrj?>6gA%cI3Gct2-!1yj=Eu$z{O5D0%8k1hE-p;v z>m#{-a19;%_7r@3R()?Qevl)}+neSc3!Mx6!uw16^MM07$9LPdf6CpCmg`cv=tIi$ zQU6^3{PqV_^FvR={Mn+XEl;(98!x@!EPBE@YdP4Ovwm!ubC>Ia08Deva$^esvS15- z=Wv%P??;1kgMU1@(6iW9tnYk4b$$J;ba%0S{{yP?M+@5K`?RO3e{#`Utl#&5+P_92 z%0F*hb<}@)l5gq4)Cdv-$TX@)J^C&gP}7`KuqSA>=<{c#IHv(g4BL?z$;_ zB9#3*$~D2Vuf81EiLrmjW2d#&o^z~uEM#lh(^&Gf7Cfy$Nr}R&Ji?NWf~RA#yXfgE zQC)ee3lrEk*Ow0-SQ=j%$=4lzKpoNanhKt#qNk-qwG^n9H3xFGKY2uTJ%JdUt^fIW zPsvkNSfyHi{@&sHH!_-C#y&W4)hid^yXhZwLJcn_x{@}f%~?+ z?`@cT5I!|WeSCJ&m!lro!eyKD=QS(X|5v&W%i^C{F)bZR!E}HYg)0JFm_?v6*mE`D>Yi_C9vG@>)U5s2@!pJZ7UrX zjQ1I_yo($_#+9ST`i~gDI${R=4`c)nA;aKc$R*+njOx}FZ8*s#*)aYIUf(f&o1{to zPg>hqMYzf$)rz-YILTD^`qd(=Uo9$U0~`kZ+6FBi19cH!$i(Bgz?;Ku^b?dwJPpND z+tH-Jqpc0VG6x!MvLERezZgGI9C hL(7krQG#&Id)E-I7o_!isd>F#e)F*=7vBM&{{!Ls46OhF literal 2211 zcma)7U2NM_6ux$B$BEOXNz-mEAPh`n*-xVVrR$gw`$^YqO*=I$Oa&?P;@j4#6KB^q zU0YnK+Yn+B(pG7rK?tdjoeEMPdE{jh5)ZV?qy?{gn0Nu62CW3z1L9sc?gk8XSMojI zz2}_o9N%+%{e44&ACU3a4{h2*9)O?d!X3UkSnDES4yZt7bTC9Et1|_5h-E1D=$-;M z#5tJJ`5|8QsT>qko;(71yigbxRq-Q!$UDhI{l8Gs-Y8>>=~Oy704FfP4HT!#I@U@G z!ZwQy+iR9D=3x%o+$b*SHiwC2j;m)x1CN31275+-N4F;vdAxXNI!ySoJ|uL_IxH*meDy<7NrRnhgy zyOndp5xH2$s&8x;0Mp_YY<^X8*`|G4*ap-F%a@m)3FgLXRD&+dbM;M6^mRT^hmUmFk`p|WJUQ5(wE1C0HzE1{Q5o9)lRc?q z??862zh_E<6H1|^!&uHJ)G509`_fasqOlLbl0Ny)lsJlU$vhg5+hEE|F@=(%V~C0h z*2W2K7b4#50J(26_4|e|>55iFv=rJ|@)1S_TO2h^TvQ6gzGRU5$y+vI7VD<%GcmcZ z94L}K!b)t!l46>d4W#x)C7U%&TNr_u9IfqtSJBHbiI9P8k5pEA&Q$Q7cXN?H{%<95-UyGe)G;c8@mwdnw13P&0rqSUy}@ z-hHXsjAnY)1rU6>lCC!Ho;h7>eSPNiCtX+iYLQj~PF+dY+GGM!D_rX%uT&F*HyW=u z-kO-_=0~dAV)w-OkN?~qu5R0RPmB>xspUredYpLFLhZGN)>`D%o58zHhvrjD%_}<& z)mmS>8NVA&%xg;rS9T_-O{jf6C`!T~K;(tr>GZ@5lT*H$j3y5M$X zw>K)HH;K{?Iwgq}v;vILU+qTwMV{F$xW8woO_5B!JSIj?8E=C%vIb~BnVyKp3Wi$N z;oIm4S<-*5NlX3M!!XPP5PAr9JOr@^!1tT5ixDc#j{y<$rX!Z#aKyJK*XbteAZ~vH DTR$1* diff --git a/Backend/src/utils/email_templates.py b/Backend/src/utils/email_templates.py new file mode 100644 index 00000000..b70ef0a9 --- /dev/null +++ b/Backend/src/utils/email_templates.py @@ -0,0 +1,261 @@ +""" +Email templates for various notifications +""" +from datetime import datetime +from typing import Optional + + +def get_base_template(content: str, title: str = "Hotel Booking") -> str: + """Base HTML email template""" + return f""" + + + + + + {title} + + + + + + + + + + + + +
+

Hotel Booking

+
+ + + + +
+ {content} +
+
+

This is an automated email. Please do not reply.

+

© {datetime.now().year} Hotel Booking. All rights reserved.

+
+ + + """ + + +def welcome_email_template(name: str, email: str, client_url: str) -> str: + """Welcome email template for new registrations""" + content = f""" +

Welcome {name}!

+

Thank you for registering an account at Hotel Booking.

+

Your account has been successfully created with email: {email}

+
+

You can:

+
    +
  • Search and book hotel rooms
  • +
  • Manage your bookings
  • +
  • Update your personal information
  • +
+
+

+ + Login Now + +

+ """ + return get_base_template(content, "Welcome to Hotel Booking") + + +def password_reset_email_template(reset_url: str) -> str: + """Password reset email template""" + content = f""" +

Password Reset Request

+

You (or someone) has requested to reset your password.

+

Click the link below to reset your password. This link will expire in 1 hour:

+

+ + Reset Password + +

+

If you did not request this, please ignore this email.

+ """ + return get_base_template(content, "Password Reset") + + +def password_changed_email_template(email: str) -> str: + """Password changed confirmation email template""" + content = f""" +

Password Changed Successfully

+

The password for account {email} has been changed successfully.

+

If you did not make this change, please contact our support team immediately.

+ """ + return get_base_template(content, "Password Changed") + + +def booking_confirmation_email_template( + booking_number: str, + guest_name: str, + room_number: str, + room_type: str, + check_in: str, + check_out: str, + num_guests: int, + total_price: float, + requires_deposit: bool, + deposit_amount: Optional[float] = None, + client_url: str = "http://localhost:5173" +) -> str: + """Booking confirmation email template""" + deposit_info = "" + if requires_deposit and deposit_amount: + deposit_info = f""" +
+

⚠️ Deposit Required

+

Please pay a deposit of €{deposit_amount:.2f} to confirm your booking.

+

Your booking will be confirmed once the deposit is received.

+
+ """ + + content = f""" +

Booking Confirmation

+

Dear {guest_name},

+

Thank you for your booking! We have received your reservation request.

+ +
+

Booking Details

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Booking Number:{booking_number}
Room:{room_type} - Room {room_number}
Check-in:{check_in}
Check-out:{check_out}
Guests:{num_guests}
Total Price:€{total_price:.2f}
+
+ + {deposit_info} + +

+ + View Booking Details + +

+ """ + return get_base_template(content, "Booking Confirmation") + + +def payment_confirmation_email_template( + booking_number: str, + guest_name: str, + amount: float, + payment_method: str, + transaction_id: Optional[str] = None, + client_url: str = "http://localhost:5173" +) -> str: + """Payment confirmation email template""" + transaction_info = "" + if transaction_id: + transaction_info = f""" + + Transaction ID: + {transaction_id} + + """ + + content = f""" +

Payment Received

+

Dear {guest_name},

+

We have successfully received your payment for booking {booking_number}.

+ +
+

Payment Details

+ + + + + + + + + + + + + + {transaction_info} +
Booking Number:{booking_number}
Amount:€{amount:.2f}
Payment Method:{payment_method}
+
+ +

Your booking is now confirmed. We look forward to hosting you!

+ +

+ + View Booking + +

+ """ + return get_base_template(content, "Payment Confirmation") + + +def booking_status_changed_email_template( + booking_number: str, + guest_name: str, + status: str, + client_url: str = "http://localhost:5173" +) -> str: + """Booking status change email template""" + status_colors = { + "confirmed": ("#10B981", "Confirmed"), + "cancelled": ("#EF4444", "Cancelled"), + "checked_in": ("#3B82F6", "Checked In"), + "checked_out": ("#8B5CF6", "Checked Out"), + } + + color, status_text = status_colors.get(status.lower(), ("#6B7280", status.title())) + + content = f""" +

Booking Status Updated

+

Dear {guest_name},

+

Your booking status has been updated.

+ +
+ + + + + + + + + +
Booking Number:{booking_number}
New Status:{status_text}
+
+ +

+ + View Booking + +

+ """ + return get_base_template(content, f"Booking {status_text}") + diff --git a/Backend/src/utils/mailer.py b/Backend/src/utils/mailer.py index f52996a9..d059a589 100644 --- a/Backend/src/utils/mailer.py +++ b/Backend/src/utils/mailer.py @@ -2,47 +2,96 @@ import aiosmtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart import os +import logging +from ..config.settings import settings + +logger = logging.getLogger(__name__) async def send_email(to: str, subject: str, html: str = None, text: str = None): """ Send email using SMTP - Requires MAIL_HOST, MAIL_USER and MAIL_PASS to be set in env. + Uses settings from config/settings.py with fallback to environment variables """ - # Require SMTP credentials to be present - mail_host = os.getenv("MAIL_HOST") - mail_user = os.getenv("MAIL_USER") - mail_pass = os.getenv("MAIL_PASS") + try: + # Get SMTP settings from settings.py, fallback to env vars + mail_host = settings.SMTP_HOST or os.getenv("MAIL_HOST") + mail_user = settings.SMTP_USER or os.getenv("MAIL_USER") + mail_pass = settings.SMTP_PASSWORD or os.getenv("MAIL_PASS") + mail_port = settings.SMTP_PORT or int(os.getenv("MAIL_PORT", "587")) + mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true" + client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + + # Get from address - prefer settings, then env, then generate from client_url + from_address = settings.SMTP_FROM_EMAIL or os.getenv("MAIL_FROM") + if not from_address: + # Generate from client_url if not set + domain = client_url.replace('https://', '').replace('http://', '').split('/')[0] + from_address = f"no-reply@{domain}" + + # Use from name if available + from_name = settings.SMTP_FROM_NAME or "Hotel Booking" + from_header = f"{from_name} <{from_address}>" - if not (mail_host and mail_user and mail_pass): - raise ValueError( - "SMTP mailer not configured. Set MAIL_HOST, MAIL_USER and MAIL_PASS in env." + if not (mail_host and mail_user and mail_pass): + error_msg = "SMTP mailer not configured. Set SMTP_HOST, SMTP_USER and SMTP_PASSWORD in .env file." + logger.error(error_msg) + raise ValueError(error_msg) + + # Create message + message = MIMEMultipart("alternative") + message["From"] = from_header + message["To"] = to + message["Subject"] = subject + + if text: + message.attach(MIMEText(text, "plain")) + if html: + message.attach(MIMEText(html, "html")) + + # If no content provided, add a default text + if not text and not html: + message.attach(MIMEText("", "plain")) + + # Determine TLS/SSL settings + # For port 587: use STARTTLS (use_tls=False, start_tls=True) + # For port 465: use SSL/TLS (use_tls=True, start_tls=False) + # For port 25: plain (usually not used for authenticated sending) + if mail_port == 465 or mail_secure: + # SSL/TLS connection (port 465) + use_tls = True + start_tls = False + elif mail_port == 587: + # STARTTLS connection (port 587) + use_tls = False + start_tls = True + else: + # Plain connection (port 25 or other) + use_tls = False + start_tls = False + + logger.info(f"Attempting to send email to {to} via {mail_host}:{mail_port} (use_tls: {use_tls}, start_tls: {start_tls})") + + # Send email using SMTP client + smtp_client = aiosmtplib.SMTP( + hostname=mail_host, + port=mail_port, + use_tls=use_tls, + start_tls=start_tls, + username=mail_user, + password=mail_pass, ) - - mail_port = int(os.getenv("MAIL_PORT", "587")) - mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true" - client_url = os.getenv("CLIENT_URL", "example.com") - from_address = os.getenv("MAIL_FROM", f"no-reply@{client_url.replace('https://', '').replace('http://', '')}") - - # Create message - message = MIMEMultipart("alternative") - message["From"] = from_address - message["To"] = to - message["Subject"] = subject - - if text: - message.attach(MIMEText(text, "plain")) - if html: - message.attach(MIMEText(html, "html")) - - # Send email - await aiosmtplib.send( - message, - hostname=mail_host, - port=mail_port, - use_tls=not mail_secure and mail_port == 587, - start_tls=not mail_secure and mail_port == 587, - username=mail_user, - password=mail_pass, - ) + + try: + await smtp_client.connect() + # Authentication happens automatically if username/password are provided in constructor + await smtp_client.send_message(message) + logger.info(f"Email sent successfully to {to}") + finally: + await smtp_client.quit() + + except Exception as e: + error_msg = f"Failed to send email to {to}: {type(e).__name__}: {str(e)}" + logger.error(error_msg, exc_info=True) + raise diff --git a/Frontend/index.html b/Frontend/index.html index aa52639a..8de18e4a 100644 --- a/Frontend/index.html +++ b/Frontend/index.html @@ -4,7 +4,10 @@ - Hotel Booking - Management System + + + + Luxury Hotel - Excellence Redefined
diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index 616352d7..7852c35f 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, lazy, Suspense } from 'react'; import { BrowserRouter, Routes, @@ -7,6 +7,12 @@ import { } from 'react-router-dom'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; +import { GlobalLoadingProvider } from './contexts/GlobalLoadingContext'; +import { CookieConsentProvider } from './contexts/CookieConsentContext'; +import OfflineIndicator from './components/common/OfflineIndicator'; +import CookieConsentBanner from './components/common/CookieConsentBanner'; +import AnalyticsLoader from './components/common/AnalyticsLoader'; +import Loading from './components/common/Loading'; // Store import useAuthStore from './store/useAuthStore'; @@ -22,52 +28,42 @@ import { AdminRoute } from './components/auth'; -// Pages -import HomePage from './pages/HomePage'; -import DashboardPage from - './pages/customer/DashboardPage'; -import RoomListPage from - './pages/customer/RoomListPage'; -import RoomDetailPage from - './pages/customer/RoomDetailPage'; -import SearchResultsPage from - './pages/customer/SearchResultsPage'; -import FavoritesPage from - './pages/customer/FavoritesPage'; -import MyBookingsPage from - './pages/customer/MyBookingsPage'; -import BookingPage from - './pages/customer/BookingPage'; -import BookingSuccessPage from - './pages/customer/BookingSuccessPage'; -import BookingDetailPage from - './pages/customer/BookingDetailPage'; -import DepositPaymentPage from - './pages/customer/DepositPaymentPage'; -import PaymentConfirmationPage from - './pages/customer/PaymentConfirmationPage'; -import PaymentResultPage from - './pages/customer/PaymentResultPage'; -import { - LoginPage, - RegisterPage, - ForgotPasswordPage, - ResetPasswordPage -} from './pages/auth'; +// Lazy load pages for code splitting +const HomePage = lazy(() => import('./pages/HomePage')); +const DashboardPage = lazy(() => import('./pages/customer/DashboardPage')); +const RoomListPage = lazy(() => import('./pages/customer/RoomListPage')); +const RoomDetailPage = lazy(() => import('./pages/customer/RoomDetailPage')); +const SearchResultsPage = lazy(() => import('./pages/customer/SearchResultsPage')); +const FavoritesPage = lazy(() => import('./pages/customer/FavoritesPage')); +const MyBookingsPage = lazy(() => import('./pages/customer/MyBookingsPage')); +const BookingPage = lazy(() => import('./pages/customer/BookingPage')); +const BookingSuccessPage = lazy(() => import('./pages/customer/BookingSuccessPage')); +const BookingDetailPage = lazy(() => import('./pages/customer/BookingDetailPage')); +const DepositPaymentPage = lazy(() => import('./pages/customer/DepositPaymentPage')); +const PaymentConfirmationPage = lazy(() => import('./pages/customer/PaymentConfirmationPage')); +const PaymentResultPage = lazy(() => import('./pages/customer/PaymentResultPage')); +const ProfilePage = lazy(() => import('./pages/customer/ProfilePage')); +const AboutPage = lazy(() => import('./pages/AboutPage')); +const LoginPage = lazy(() => import('./pages/auth/LoginPage')); +const RegisterPage = lazy(() => import('./pages/auth/RegisterPage')); +const ForgotPasswordPage = lazy(() => import('./pages/auth/ForgotPasswordPage')); +const ResetPasswordPage = lazy(() => import('./pages/auth/ResetPasswordPage')); -// Admin Pages -import { - DashboardPage as AdminDashboardPage, - RoomManagementPage, - UserManagementPage, - BookingManagementPage, - PaymentManagementPage, - ServiceManagementPage, - ReviewManagementPage, - PromotionManagementPage, - CheckInPage, - CheckOutPage, -} from './pages/admin'; +// Lazy load admin pages +const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage')); +const RoomManagementPage = lazy(() => import('./pages/admin/RoomManagementPage')); +const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage')); +const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage')); +const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage')); +const ServiceManagementPage = lazy(() => import('./pages/admin/ServiceManagementPage')); +const ReviewManagementPage = lazy(() => import('./pages/admin/ReviewManagementPage')); +const PromotionManagementPage = lazy(() => import('./pages/admin/PromotionManagementPage')); +const BannerManagementPage = lazy(() => import('./pages/admin/BannerManagementPage')); +const ReportsPage = lazy(() => import('./pages/admin/ReportsPage')); +const CookieSettingsPage = lazy(() => import('./pages/admin/CookieSettingsPage')); +const AuditLogsPage = lazy(() => import('./pages/admin/AuditLogsPage')); +const CheckInPage = lazy(() => import('./pages/admin/CheckInPage')); +const CheckOutPage = lazy(() => import('./pages/admin/CheckOutPage')); // Demo component for pages not yet created const DemoPage: React.FC<{ title: string }> = ({ title }) => ( @@ -125,8 +121,16 @@ function App() { }; return ( - - + + + + }> + {/* Public Routes with Main Layout */} } + element={} /> {/* Protected Routes - Requires login */} @@ -225,7 +229,7 @@ function App() { path="profile" element={ - + } /> @@ -301,15 +305,19 @@ function App() { /> } + element={} /> - } + element={} /> - } + /> + } + element={} /> @@ -320,18 +328,27 @@ function App() { /> - - + + + + + + + + ); } diff --git a/Frontend/src/components/common/AnalyticsLoader.tsx b/Frontend/src/components/common/AnalyticsLoader.tsx new file mode 100644 index 00000000..2762b76b --- /dev/null +++ b/Frontend/src/components/common/AnalyticsLoader.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import privacyService, { + PublicPrivacyConfig, +} from '../../services/api/privacyService'; +import { useCookieConsent } from '../../contexts/CookieConsentContext'; + +declare global { + interface Window { + dataLayer: any[]; + gtag: (...args: any[]) => void; + fbq: (...args: any[]) => void; + } +} + +const AnalyticsLoader: React.FC = () => { + const location = useLocation(); + const { consent } = useCookieConsent(); + const [config, setConfig] = useState(null); + const gaLoadedRef = useRef(false); + const fbLoadedRef = useRef(false); + + // Load public privacy config once + useEffect(() => { + let mounted = true; + const loadConfig = async () => { + try { + const cfg = await privacyService.getPublicConfig(); + if (!mounted) return; + setConfig(cfg); + } catch { + // Fail silently in production; analytics are non-critical + } + }; + void loadConfig(); + return () => { + mounted = false; + }; + }, []); + + // Load Google Analytics when allowed + useEffect(() => { + if (!config || !consent) return; + const measurementId = config.integrations.ga_measurement_id; + const analyticsAllowed = + config.policy.analytics_enabled && consent.categories.analytics; + if (!measurementId || !analyticsAllowed || gaLoadedRef.current) return; + + // Inject GA script + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent( + measurementId + )}`; + document.head.appendChild(script); + + window.dataLayer = window.dataLayer || []; + function gtag(...args: any[]) { + window.dataLayer.push(args); + } + window.gtag = gtag; + gtag('js', new Date()); + gtag('config', measurementId, { anonymize_ip: true }); + + gaLoadedRef.current = true; + + return () => { + // We don't remove GA script on unmount; typical SPA behaviour is to keep it. + }; + }, [config, consent]); + + // Track GA page views on route change + useEffect(() => { + if (!gaLoadedRef.current || !config?.integrations.ga_measurement_id) return; + if (typeof window.gtag !== 'function') return; + window.gtag('config', config.integrations.ga_measurement_id, { + page_path: location.pathname + location.search, + }); + }, [location, config]); + + // Load Meta Pixel when allowed + useEffect(() => { + if (!config || !consent) return; + const pixelId = config.integrations.fb_pixel_id; + const marketingAllowed = + config.policy.marketing_enabled && consent.categories.marketing; + if (!pixelId || !marketingAllowed || fbLoadedRef.current) return; + + // Meta Pixel base code + !(function (f: any, b, e, v, n?, t?, s?) { + if (f.fbq) return; + n = f.fbq = function () { + (n.callMethod ? n.callMethod : n.queue.push).apply(n, arguments); + }; + if (!f._fbq) f._fbq = n; + (n as any).push = n; + (n as any).loaded = true; + (n as any).version = '2.0'; + (n as any).queue = []; + t = b.createElement(e); + t.async = true; + t.src = 'https://connect.facebook.net/en_US/fbevents.js'; + s = b.getElementsByTagName(e)[0]; + s.parentNode?.insertBefore(t, s); + })(window, document, 'script'); + + window.fbq('init', pixelId); + window.fbq('track', 'PageView'); + fbLoadedRef.current = true; + }, [config, consent]); + + return null; +}; + +export default AnalyticsLoader; + + diff --git a/Frontend/src/components/common/ConfirmationDialog.tsx b/Frontend/src/components/common/ConfirmationDialog.tsx new file mode 100644 index 00000000..714cb796 --- /dev/null +++ b/Frontend/src/components/common/ConfirmationDialog.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import { AlertTriangle, X } from 'lucide-react'; + +interface ConfirmationDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + variant?: 'danger' | 'warning' | 'info'; + isLoading?: boolean; +} + +const ConfirmationDialog: React.FC = ({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmText = 'Confirm', + cancelText = 'Cancel', + variant = 'info', + isLoading = false, +}) => { + if (!isOpen) return null; + + const variantStyles = { + danger: { + icon: 'text-red-600', + button: 'bg-red-600 hover:bg-red-700 focus:ring-red-500', + }, + warning: { + icon: 'text-yellow-600', + button: 'bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500', + }, + info: { + icon: 'text-blue-600', + button: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500', + }, + }; + + const styles = variantStyles[variant]; + + return ( +
+ {/* Backdrop */} + + ); +}; + +export default ConfirmationDialog; + diff --git a/Frontend/src/components/common/CookieConsentBanner.tsx b/Frontend/src/components/common/CookieConsentBanner.tsx new file mode 100644 index 00000000..5fb4d3b3 --- /dev/null +++ b/Frontend/src/components/common/CookieConsentBanner.tsx @@ -0,0 +1,200 @@ +import React, { useEffect, useState } from 'react'; +import { useCookieConsent } from '../../contexts/CookieConsentContext'; + +const CookieConsentBanner: React.FC = () => { + const { consent, isLoading, hasDecided, updateConsent } = useCookieConsent(); + const [showDetails, setShowDetails] = useState(false); + const [analyticsChecked, setAnalyticsChecked] = useState(false); + const [marketingChecked, setMarketingChecked] = useState(false); + const [preferencesChecked, setPreferencesChecked] = useState(false); + + useEffect(() => { + if (consent) { + setAnalyticsChecked(consent.categories.analytics); + setMarketingChecked(consent.categories.marketing); + setPreferencesChecked(consent.categories.preferences); + } + }, [consent]); + + useEffect(() => { + const handleOpenPreferences = () => { + setShowDetails(true); + }; + + window.addEventListener('open-cookie-preferences', handleOpenPreferences); + return () => { + window.removeEventListener( + 'open-cookie-preferences', + handleOpenPreferences + ); + }; + }, []); + + if (isLoading || hasDecided) { + return null; + } + + const handleAcceptAll = async () => { + await updateConsent({ + analytics: true, + marketing: true, + preferences: true, + }); + }; + + const handleRejectNonEssential = async () => { + await updateConsent({ + analytics: false, + marketing: false, + preferences: false, + }); + }; + + const handleSaveSelection = async () => { + await updateConsent({ + analytics: analyticsChecked, + marketing: marketingChecked, + preferences: preferencesChecked, + }); + }; + + return ( +
+
+ {/* Gold inner border */} +
+ + {/* Subtle glow */} +
+ +
+ {/* Left: copy + details */} +
+
+ + Privacy Suite +
+ +
+

+ A tailored privacy experience +

+

+ We use cookies to ensure a seamless booking journey, enhance performance, + and offer curated experiences. Choose a level of personalization that + matches your comfort. +

+
+ + + + {showDetails && ( +
+
+
+
+
+

Strictly necessary

+

+ Essential for security, authentication, and core booking flows. + These are always enabled. +

+
+
+ +
+ setAnalyticsChecked(e.target.checked)} + /> + +
+ +
+ setMarketingChecked(e.target.checked)} + /> + +
+ +
+ setPreferencesChecked(e.target.checked)} + /> + +
+
+
+ )} +
+ + {/* Right: actions */} +
+ + + {showDetails && ( + + )} +
+
+
+
+ ); +}; + +export default CookieConsentBanner; + + diff --git a/Frontend/src/components/common/CookiePreferencesLink.tsx b/Frontend/src/components/common/CookiePreferencesLink.tsx new file mode 100644 index 00000000..20e48074 --- /dev/null +++ b/Frontend/src/components/common/CookiePreferencesLink.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useCookieConsent } from '../../contexts/CookieConsentContext'; + +const CookiePreferencesLink: React.FC = () => { + const { hasDecided } = useCookieConsent(); + + if (!hasDecided) { + return null; + } + + const handleClick = () => { + // Dispatch a custom event listened by the banner to reopen details. + window.dispatchEvent(new CustomEvent('open-cookie-preferences')); + }; + + return ( + + ); +}; + +export default CookiePreferencesLink; + + diff --git a/Frontend/src/components/common/GlobalLoading.tsx b/Frontend/src/components/common/GlobalLoading.tsx new file mode 100644 index 00000000..4dc77816 --- /dev/null +++ b/Frontend/src/components/common/GlobalLoading.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Loader2 } from 'lucide-react'; + +interface GlobalLoadingProps { + isLoading: boolean; + text?: string; +} + +const GlobalLoading: React.FC = ({ + isLoading, + text = 'Loading...', +}) => { + if (!isLoading) return null; + + return ( +
+
+ +

{text}

+
+
+ ); +}; + +export default GlobalLoading; + diff --git a/Frontend/src/components/common/OfflineIndicator.tsx b/Frontend/src/components/common/OfflineIndicator.tsx new file mode 100644 index 00000000..f878bf94 --- /dev/null +++ b/Frontend/src/components/common/OfflineIndicator.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { WifiOff } from 'lucide-react'; +import { useOffline } from '../../hooks/useOffline'; + +const OfflineIndicator: React.FC = () => { + const isOffline = useOffline(); + + if (!isOffline) return null; + + return ( +
+ + + You're currently offline. Some features may be unavailable. + +
+ ); +}; + +export default OfflineIndicator; + diff --git a/Frontend/src/components/common/Skeleton.tsx b/Frontend/src/components/common/Skeleton.tsx new file mode 100644 index 00000000..c6f63c43 --- /dev/null +++ b/Frontend/src/components/common/Skeleton.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +interface SkeletonProps { + width?: string | number; + height?: string | number; + className?: string; + variant?: 'text' | 'circular' | 'rectangular'; + animation?: 'pulse' | 'wave' | 'none'; +} + +const Skeleton: React.FC = ({ + width, + height, + className = '', + variant = 'rectangular', + animation = 'pulse', +}) => { + const baseClasses = 'bg-gray-200'; + + const variantClasses = { + text: 'h-4 rounded', + circular: 'rounded-full', + rectangular: 'rounded', + }; + + const animationClasses = { + pulse: 'animate-pulse', + wave: 'animate-shimmer', + none: '', + }; + + const style: React.CSSProperties = {}; + if (width) style.width = typeof width === 'number' ? `${width}px` : width; + if (height) style.height = typeof height === 'number' ? `${height}px` : height; + + return ( +
+ ); +}; + +export default Skeleton; + diff --git a/Frontend/src/components/common/index.ts b/Frontend/src/components/common/index.ts new file mode 100644 index 00000000..cda42fd6 --- /dev/null +++ b/Frontend/src/components/common/index.ts @@ -0,0 +1,12 @@ +export { default as EmptyState } from './EmptyState'; +export { default as ErrorBoundary } from './ErrorBoundary'; +export { default as Loading } from './Loading'; +export { default as OptimizedImage } from './OptimizedImage'; +export { default as Pagination } from './Pagination'; +export { default as PaymentMethodSelector } from './PaymentMethodSelector'; +export { default as PaymentStatusBadge } from './PaymentStatusBadge'; +export { default as ConfirmationDialog } from './ConfirmationDialog'; +export { default as GlobalLoading } from './GlobalLoading'; +export { default as OfflineIndicator } from './OfflineIndicator'; +export { default as Skeleton } from './Skeleton'; + diff --git a/Frontend/src/components/layout/Footer.tsx b/Frontend/src/components/layout/Footer.tsx index 22472eef..20c502ab 100644 --- a/Frontend/src/components/layout/Footer.tsx +++ b/Frontend/src/components/layout/Footer.tsx @@ -7,189 +7,277 @@ import { Instagram, Mail, Phone, - MapPin + MapPin, + Linkedin, + Youtube, + Award, + Shield, + Star } from 'lucide-react'; +import CookiePreferencesLink from '../common/CookiePreferencesLink'; const Footer: React.FC = () => { return ( -