Repository: magweter/spacepad Branch: main Commit: 215510b0ea9c Files: 461 Total size: 1.2 MB Directory structure: gitextract_qzg9plm7/ ├── .github/ │ └── workflows/ │ ├── docker-build.yml │ └── tests.yml ├── .gitignore ├── CLAUDE.md ├── CONTRIBUTING.md ├── LICENSE.md ├── LICENSE_PRO.md ├── README.md ├── app/ │ ├── .gitignore │ ├── .metadata │ ├── README.md │ ├── analysis_options.yaml │ ├── android/ │ │ ├── .gitignore │ │ ├── app/ │ │ │ ├── .gitignore │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── debug/ │ │ │ │ └── AndroidManifest.xml │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── kotlin/ │ │ │ │ │ └── com/ │ │ │ │ │ └── magweter/ │ │ │ │ │ └── spacepad/ │ │ │ │ │ └── MainActivity.kt │ │ │ │ └── res/ │ │ │ │ ├── drawable/ │ │ │ │ │ └── launch_background.xml │ │ │ │ ├── drawable-v21/ │ │ │ │ │ └── launch_background.xml │ │ │ │ ├── values/ │ │ │ │ │ └── styles.xml │ │ │ │ └── values-night/ │ │ │ │ └── styles.xml │ │ │ └── profile/ │ │ │ └── AndroidManifest.xml │ │ ├── build.gradle.kts │ │ ├── gradle/ │ │ │ └── wrapper/ │ │ │ └── gradle-wrapper.properties │ │ ├── gradle.properties │ │ └── settings.gradle.kts │ ├── ios/ │ │ ├── .gitignore │ │ ├── Flutter/ │ │ │ ├── AppFrameworkInfo.plist │ │ │ ├── Debug.xcconfig │ │ │ └── Release.xcconfig │ │ ├── Podfile │ │ ├── Runner/ │ │ │ ├── AppDelegate.swift │ │ │ ├── Assets.xcassets/ │ │ │ │ ├── AppIcon.appiconset/ │ │ │ │ │ └── Contents.json │ │ │ │ └── LaunchImage.imageset/ │ │ │ │ ├── Contents.json │ │ │ │ └── README.md │ │ │ ├── Base.lproj/ │ │ │ │ ├── LaunchScreen.storyboard │ │ │ │ └── Main.storyboard │ │ │ ├── Info.plist │ │ │ └── Runner-Bridging-Header.h │ │ ├── Runner.xcodeproj/ │ │ │ ├── project.pbxproj │ │ │ ├── project.xcworkspace/ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ └── xcshareddata/ │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ └── WorkspaceSettings.xcsettings │ │ │ └── xcshareddata/ │ │ │ └── xcschemes/ │ │ │ └── Runner.xcscheme │ │ ├── Runner.xcworkspace/ │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata/ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ │ └── RunnerTests/ │ │ └── RunnerTests.swift │ ├── lib/ │ │ ├── components/ │ │ │ ├── action_button.dart │ │ │ ├── action_panel.dart │ │ │ ├── admin_actions.dart │ │ │ ├── authenticated_background.dart │ │ │ ├── authenticated_image.dart │ │ │ ├── calendar_modal.dart │ │ │ ├── custom_booking_modal.dart │ │ │ ├── event_line.dart │ │ │ ├── frosted_panel.dart │ │ │ ├── solid_button.dart │ │ │ ├── spinner.dart │ │ │ └── toast.dart │ │ ├── controllers/ │ │ │ ├── dashboard_controller.dart │ │ │ ├── display_controller.dart │ │ │ └── login_controller.dart │ │ ├── date_format_helper.dart │ │ ├── exceptions/ │ │ │ └── api_exception.dart │ │ ├── main.dart │ │ ├── models/ │ │ │ ├── device_model.dart │ │ │ ├── display_data_model.dart │ │ │ ├── display_model.dart │ │ │ ├── display_settings_model.dart │ │ │ ├── event_model.dart │ │ │ ├── event_status.dart │ │ │ └── user_model.dart │ │ ├── pages/ │ │ │ ├── dashboard_page.dart │ │ │ ├── display_page.dart │ │ │ ├── login_page.dart │ │ │ └── splash_page.dart │ │ ├── services/ │ │ │ ├── api_service.dart │ │ │ ├── auth_service.dart │ │ │ ├── device_service.dart │ │ │ ├── display_service.dart │ │ │ ├── font_service.dart │ │ │ └── server_service.dart │ │ ├── theme.dart │ │ └── translations/ │ │ └── translations.dart │ ├── pubspec.yaml │ └── test/ │ └── widget_test.dart ├── backend/ │ ├── .editorconfig │ ├── .gitattributes │ ├── .gitignore │ ├── Dockerfile │ ├── FARO_SETUP.md │ ├── README.md │ ├── WORKSPACE_SETUP.md │ ├── app/ │ │ ├── Console/ │ │ │ └── Commands/ │ │ │ ├── CheckMarketingTriggers.php │ │ │ ├── CleanupExpiredEvents.php │ │ │ ├── RenewEventSubscriptions.php │ │ │ ├── SendHeartbeat.php │ │ │ ├── TriggerRegistrationWebhookForMissingNames.php │ │ │ ├── UpdateLemonSqueezySubscriptions.php │ │ │ └── ValidateLicense.php │ │ ├── Data/ │ │ │ ├── CalendarWebhookData.php │ │ │ ├── DisplayWebhookData.php │ │ │ ├── InstanceData.php │ │ │ ├── LicenseData.php │ │ │ ├── OrderWebhookData.php │ │ │ ├── PermissionResult.php │ │ │ ├── UserData.php │ │ │ └── UserWebhookData.php │ │ ├── Enums/ │ │ │ ├── AccountStatus.php │ │ │ ├── DisplayStatus.php │ │ │ ├── EventSource.php │ │ │ ├── EventStatus.php │ │ │ ├── GoogleBookingMethod.php │ │ │ ├── OAuthDriver.php │ │ │ ├── PermissionType.php │ │ │ ├── Plan.php │ │ │ ├── Provider.php │ │ │ ├── UsageType.php │ │ │ ├── UserStatus.php │ │ │ └── WorkspaceRole.php │ │ ├── Events/ │ │ │ ├── TrialExpiredOrCancelled.php │ │ │ ├── UserActivatedAfter24h.php │ │ │ ├── UserInactive.php │ │ │ ├── UserNotActivatedAfter24h.php │ │ │ ├── UserOnboarded.php │ │ │ ├── UserPassive.php │ │ │ └── UserRegistered.php │ │ ├── Exceptions/ │ │ │ └── Handler.php │ │ ├── Helpers/ │ │ │ ├── DisplaySettings.php │ │ │ └── Settings.php │ │ ├── Http/ │ │ │ ├── Controllers/ │ │ │ │ ├── API/ │ │ │ │ │ ├── ApiController.php │ │ │ │ │ ├── Auth/ │ │ │ │ │ │ └── AuthController.php │ │ │ │ │ ├── Cloud/ │ │ │ │ │ │ └── InstanceController.php │ │ │ │ │ ├── DeviceController.php │ │ │ │ │ ├── DisplayController.php │ │ │ │ │ └── EventController.php │ │ │ │ ├── AdminController.php │ │ │ │ ├── Auth/ │ │ │ │ │ ├── AuthController.php │ │ │ │ │ ├── GoogleController.php │ │ │ │ │ ├── LoginController.php │ │ │ │ │ ├── MicrosoftController.php │ │ │ │ │ ├── RegisterController.php │ │ │ │ │ └── SocialAuthController.php │ │ │ │ ├── BoardController.php │ │ │ │ ├── CalDAVAccountsController.php │ │ │ │ ├── CalendarController.php │ │ │ │ ├── Controller.php │ │ │ │ ├── DashboardController.php │ │ │ │ ├── DisplayController.php │ │ │ │ ├── DisplaySettingsController.php │ │ │ │ ├── GoogleAccountsController.php │ │ │ │ ├── GoogleWebhookController.php │ │ │ │ ├── LicenseController.php │ │ │ │ ├── OnboardingController.php │ │ │ │ ├── OutlookAccountsController.php │ │ │ │ ├── OutlookWebhookController.php │ │ │ │ ├── RoomController.php │ │ │ │ ├── UsageController.php │ │ │ │ └── WorkspaceController.php │ │ │ ├── Middleware/ │ │ │ │ ├── CheckUserActive.php │ │ │ │ ├── CheckUserOnboarding.php │ │ │ │ └── UpdateLastActivity.php │ │ │ ├── Requests/ │ │ │ │ ├── API/ │ │ │ │ │ ├── Auth/ │ │ │ │ │ │ └── LoginRequest.php │ │ │ │ │ ├── BookEventRequest.php │ │ │ │ │ ├── ChangeDisplayRequest.php │ │ │ │ │ ├── InstanceHeartbeatRequest.php │ │ │ │ │ └── ValidateInstanceRequest.php │ │ │ │ ├── ActivateLicenseRequest.php │ │ │ │ ├── Auth/ │ │ │ │ │ ├── LoginRequest.php │ │ │ │ │ ├── OAuth2TokenRequest.php │ │ │ │ │ └── RegisterRequest.php │ │ │ │ ├── CreateBoardRequest.php │ │ │ │ ├── CreateDisplayRequest.php │ │ │ │ ├── UpdateBoardRequest.php │ │ │ │ └── UpdateDisplayCustomizationRequest.php │ │ │ └── Resources/ │ │ │ └── API/ │ │ │ ├── DeviceResource.php │ │ │ ├── DisplayDataResource.php │ │ │ ├── DisplayResource.php │ │ │ ├── DisplaySettingsResource.php │ │ │ ├── EventResource.php │ │ │ └── UserResource.php │ │ ├── Infrastructure/ │ │ │ └── Cloud/ │ │ │ └── LicenseService.php │ │ ├── Listeners/ │ │ │ ├── ActivateUser.php │ │ │ ├── SendOnboardingCompleteNotification.php │ │ │ ├── SendOrderCreatedNotification.php │ │ │ ├── SendRegistrationNotification.php │ │ │ ├── SendTrialExpiredOrCancelledNotification.php │ │ │ ├── SendUserActivatedAfter24hNotification.php │ │ │ ├── SendUserInactiveNotification.php │ │ │ ├── SendUserNotActivatedAfter24hNotification.php │ │ │ └── SendUserPassiveNotification.php │ │ ├── Models/ │ │ │ ├── Board.php │ │ │ ├── CalDAVAccount.php │ │ │ ├── Calendar.php │ │ │ ├── Device.php │ │ │ ├── Display.php │ │ │ ├── DisplaySetting.php │ │ │ ├── Event.php │ │ │ ├── EventSubscription.php │ │ │ ├── GoogleAccount.php │ │ │ ├── Instance.php │ │ │ ├── OutlookAccount.php │ │ │ ├── PersonalAccessToken.php │ │ │ ├── Room.php │ │ │ ├── Setting.php │ │ │ ├── User.php │ │ │ ├── Workspace.php │ │ │ └── WorkspaceMember.php │ │ ├── Notifications/ │ │ │ └── MagicLoginNotification.php │ │ ├── Observers/ │ │ │ └── EventObserver.php │ │ ├── Policies/ │ │ │ ├── BoardPolicy.php │ │ │ └── DisplayPolicy.php │ │ ├── Providers/ │ │ │ ├── AppServiceProvider.php │ │ │ └── AuthServiceProvider.php │ │ ├── Services/ │ │ │ ├── CalDAVService.php │ │ │ ├── DisplayService.php │ │ │ ├── EventService.php │ │ │ ├── GoogleService.php │ │ │ ├── ImageService.php │ │ │ ├── InstanceService.php │ │ │ └── OutlookService.php │ │ └── Traits/ │ │ ├── HasLastActivity.php │ │ ├── HasUlid.php │ │ └── RespondsWithApiResponse.php │ ├── artisan │ ├── bootstrap/ │ │ ├── app.php │ │ ├── cache/ │ │ │ └── .gitignore │ │ ├── opentelemetry.php │ │ └── providers.php │ ├── composer.json │ ├── config/ │ │ ├── app.php │ │ ├── auth.php │ │ ├── broadcasting.php │ │ ├── cache.php │ │ ├── database.php │ │ ├── faro.php │ │ ├── filesystems.php │ │ ├── googletagmanager.php │ │ ├── lemon-squeezy.php │ │ ├── logging.php │ │ ├── magiclink.php │ │ ├── mail.php │ │ ├── queue.php │ │ ├── recaptchav3.php │ │ ├── sanctum.php │ │ ├── sentry.php │ │ ├── services.php │ │ ├── session.php │ │ ├── settings.php │ │ └── wave.php │ ├── database/ │ │ ├── .gitignore │ │ ├── factories/ │ │ │ ├── BoardFactory.php │ │ │ ├── CalDAVAccountFactory.php │ │ │ ├── CalendarFactory.php │ │ │ ├── DeviceFactory.php │ │ │ ├── DisplayFactory.php │ │ │ ├── EventSubscriptionFactory.php │ │ │ ├── GoogleAccountFactory.php │ │ │ ├── InstanceFactory.php │ │ │ ├── OutlookAccountFactory.php │ │ │ ├── RoomFactory.php │ │ │ ├── UserFactory.php │ │ │ └── WorkspaceFactory.php │ │ ├── migrations/ │ │ │ ├── 2014_10_12_000000_create_users_table.php │ │ │ ├── 2014_10_12_100000_create_password_reset_tokens_table.php │ │ │ ├── 2017_07_06_000000_create_table_magic_links.php │ │ │ ├── 2019_08_19_000000_create_failed_jobs_table.php │ │ │ ├── 2019_12_14_000001_create_personal_access_tokens_table.php │ │ │ ├── 2021_03_06_211907_add_access_code_to_magic_links_table.php │ │ │ ├── 2024_03_19_000000_add_usage_type_to_users_table.php │ │ │ ├── 2024_10_08_193424_create_outlook_accounts_table.php │ │ │ ├── 2024_10_08_193455_create_calendars_table.php │ │ │ ├── 2024_10_12_203020_create_displays_table.php │ │ │ ├── 2024_10_17_212003_create_event_subscriptions_table.php │ │ │ ├── 2025_01_12_122905_create_devices_table.php │ │ │ ├── 2025_01_12_190259_create_rooms_table.php │ │ │ ├── 2025_05_04_204354_remove_unique_from_outlook_accounts.php │ │ │ ├── 2025_05_07_181029_create_sessions_table.php │ │ │ ├── 2025_05_07_181034_create_cache_table.php │ │ │ ├── 2025_05_17_130507_create_google_accounts_table.php │ │ │ ├── 2025_05_17_153857_add_google_account_id_to_calendars_table.php │ │ │ ├── 2025_05_18_010101_remove_unique_from_google_accounts.php │ │ │ ├── 2025_05_18_010201_remove_unique_from_calendars.php │ │ │ ├── 2025_05_18_114502_add_status_to_accounts.php │ │ │ ├── 2025_05_21_000000_add_google_account_id_to_event_subscriptions.php │ │ │ ├── 2025_05_23_000000_create_caldav_accounts_table.php │ │ │ ├── 2025_05_23_000001_add_caldav_account_id_to_calendars_table.php │ │ │ ├── 2025_05_23_201433_add_google_id_to_users_table.php │ │ │ ├── 2025_05_27_203928_add_last_activity_at_to_users_table.php │ │ │ ├── 2025_05_27_204843_add_last_activity_at_to_devices_table.php │ │ │ ├── 2025_05_28_193657_add_is_billing_exempt_to_users_table.php │ │ │ ├── 2025_05_28_194845_add_is_unlimited_to_users_table.php │ │ │ ├── 2025_06_08_000001_add_terms_accepted_at_to_users_table.php │ │ │ ├── 2025_06_09_115819_drop_is_billing_exempt_from_users_table.php │ │ │ ├── 2025_06_09_122516_add_hosted_domain_to_google_accounts_table.php │ │ │ ├── 2025_06_09_122702_add_tenant_id_to_outlook_accounts_table.php │ │ │ ├── 2025_06_09_125231_add_uid_to_devices_table.php │ │ │ ├── 2025_06_09_150001_create_instances_table.php │ │ │ ├── 2025_06_15_000000_create_settings_table.php │ │ │ ├── 2025_06_15_120000_change_billable_id_to_ulid_on_lemonsqueezy_tables.php │ │ │ ├── 2025_06_16_000000_create_events_table.php │ │ │ ├── 2025_07_05_000000_create_display_settings_table.php │ │ │ ├── 2025_07_05_000001_alter_avatar_column_on_google_accounts_table.php │ │ │ ├── 2025_07_27_000000_add_is_admin_to_users_table.php │ │ │ ├── 2025_11_28_000000_add_permission_type_to_outlook_accounts_table.php │ │ │ ├── 2025_11_28_000001_add_permission_type_to_google_accounts_table.php │ │ │ ├── 2025_11_28_000002_add_permission_type_to_caldav_accounts_table.php │ │ │ ├── 2025_12_03_000000_add_service_account_file_path_to_google_accounts_table.php │ │ │ ├── 2025_12_04_000000_add_booking_method_to_google_accounts_table.php │ │ │ ├── 2025_12_05_000000_encrypt_existing_tokens_in_google_and_outlook_accounts.php │ │ │ ├── 2025_12_06_000003_add_first_name_and_last_name_to_users_table.php │ │ │ ├── 2025_12_30_000000_create_workspaces_table.php │ │ │ ├── 2025_12_30_000001_create_workspace_members_table.php │ │ │ ├── 2025_12_30_000002_add_workspace_id_to_tables.php │ │ │ ├── 2025_12_30_000003_add_workspace_id_to_accounts_tables.php │ │ │ ├── 2025_12_30_000004_create_workspaces_for_existing_users.php │ │ │ ├── 2026_02_28_000000_increase_events_description_column_size.php │ │ │ ├── 2026_02_28_000001_increase_caldav_accounts_password_column_size.php │ │ │ ├── 2026_02_28_120000_create_boards_table.php │ │ │ ├── 2026_02_28_120001_create_board_displays_table.php │ │ │ ├── 2026_02_28_120002_add_theme_to_boards_table.php │ │ │ ├── 2026_02_28_120003_add_logo_to_boards_table.php │ │ │ ├── 2026_02_28_120004_add_display_options_to_boards_table.php │ │ │ ├── 2026_02_28_120005_add_additional_settings_to_boards_table.php │ │ │ ├── 2026_02_28_120007_add_title_and_subtitle_to_boards_table.php │ │ │ ├── 2026_02_28_120008_add_view_mode_to_boards_table.php │ │ │ └── 2026_02_28_140000_add_boards_count_to_instances_table.php │ │ └── seeders/ │ │ └── DatabaseSeeder.php │ ├── docs/ │ │ ├── CODING_STANDARDS.md │ │ └── WORKSPACE_SETUP.md │ ├── lang/ │ │ ├── de/ │ │ │ └── boards.php │ │ ├── en/ │ │ │ ├── boards.php │ │ │ └── validation.php │ │ ├── es/ │ │ │ └── boards.php │ │ ├── fr/ │ │ │ └── boards.php │ │ ├── nl/ │ │ │ └── boards.php │ │ └── sv/ │ │ └── boards.php │ ├── package.json │ ├── phpunit.xml │ ├── public/ │ │ ├── .htaccess │ │ ├── images/ │ │ │ └── backgrounds/ │ │ │ └── README.md │ │ ├── index.php │ │ ├── robots.txt │ │ └── site.webmanifest │ ├── requests/ │ │ ├── .gitignore │ │ ├── api/ │ │ │ ├── activate.http │ │ │ ├── auth/ │ │ │ │ └── login.http │ │ │ ├── book-room.http │ │ │ ├── cancel-event.http │ │ │ ├── change-display.http │ │ │ ├── check-in-event.http │ │ │ ├── get-display-data.http │ │ │ ├── get-displays.http │ │ │ ├── get-events.http │ │ │ ├── get-me.http │ │ │ ├── heartbeat.http │ │ │ └── outlook/ │ │ │ ├── get-outlook-calendars.http │ │ │ └── outlook-auth.http │ │ ├── graph/ │ │ │ ├── get-calendar-by-email.http │ │ │ ├── get-calendars.http │ │ │ ├── get-events.http │ │ │ └── get-rooms.http │ │ └── webhook-tests.http │ ├── resources/ │ │ ├── css/ │ │ │ └── app.css │ │ ├── js/ │ │ │ ├── app.js │ │ │ ├── bootstrap.js │ │ │ └── echo.js │ │ └── views/ │ │ ├── .gitkeep │ │ ├── auth/ │ │ │ ├── login.blade.php │ │ │ └── register.blade.php │ │ ├── components/ │ │ │ ├── alerts/ │ │ │ │ └── alert.blade.php │ │ │ ├── calendars/ │ │ │ │ └── picker.blade.php │ │ │ ├── cards/ │ │ │ │ └── card.blade.php │ │ │ ├── displays/ │ │ │ │ └── table-row.blade.php │ │ │ ├── icons/ │ │ │ │ ├── arrow-left.blade.php │ │ │ │ ├── brush.blade.php │ │ │ │ ├── building.blade.php │ │ │ │ ├── caldav.blade.php │ │ │ │ ├── calendar.blade.php │ │ │ │ ├── display.blade.php │ │ │ │ ├── external.blade.php │ │ │ │ ├── google.blade.php │ │ │ │ ├── information.blade.php │ │ │ │ ├── logout.blade.php │ │ │ │ ├── microsoft.blade.php │ │ │ │ ├── pause.blade.php │ │ │ │ ├── play.blade.php │ │ │ │ ├── plus.blade.php │ │ │ │ ├── room.blade.php │ │ │ │ ├── settings.blade.php │ │ │ │ ├── trash.blade.php │ │ │ │ └── users.blade.php │ │ │ ├── impersonation-banner.blade.php │ │ │ ├── modals/ │ │ │ │ ├── google-service-account.blade.php │ │ │ │ ├── license-key.blade.php │ │ │ │ ├── manage-subscription.blade.php │ │ │ │ ├── select-google-booking-method.blade.php │ │ │ │ └── select-permission.blade.php │ │ │ ├── rooms/ │ │ │ │ └── picker.blade.php │ │ │ └── scripts/ │ │ │ ├── clarity.blade.php │ │ │ └── faro.blade.php │ │ ├── errors/ │ │ │ ├── 403.blade.php │ │ │ ├── 404.blade.php │ │ │ ├── 419.blade.php │ │ │ ├── 429.blade.php │ │ │ └── 500.blade.php │ │ ├── layouts/ │ │ │ ├── base.blade.php │ │ │ ├── blank.blade.php │ │ │ └── error.blade.php │ │ ├── pages/ │ │ │ ├── admin/ │ │ │ │ └── user.blade.php │ │ │ ├── admin.blade.php │ │ │ ├── boards/ │ │ │ │ ├── form.blade.php │ │ │ │ ├── index.blade.php │ │ │ │ └── show.blade.php │ │ │ ├── caldav-accounts/ │ │ │ │ └── create.blade.php │ │ │ ├── dashboard.blade.php │ │ │ ├── displays/ │ │ │ │ ├── create.blade.php │ │ │ │ ├── customization.blade.php │ │ │ │ └── settings.blade.php │ │ │ ├── onboarding.blade.php │ │ │ └── usage/ │ │ │ └── index.blade.php │ │ └── vendor/ │ │ ├── googletagmanager/ │ │ │ └── body.blade.php │ │ └── pagination/ │ │ └── tailwind.blade.php │ ├── routes/ │ │ ├── api.php │ │ ├── channels.php │ │ ├── console.php │ │ └── web.php │ ├── storage/ │ │ ├── app/ │ │ │ └── .gitignore │ │ ├── framework/ │ │ │ ├── .gitignore │ │ │ ├── cache/ │ │ │ │ └── .gitignore │ │ │ ├── sessions/ │ │ │ │ └── .gitignore │ │ │ ├── testing/ │ │ │ │ └── .gitignore │ │ │ └── views/ │ │ │ └── .gitignore │ │ └── logs/ │ │ └── .gitignore │ ├── tailwind.config.js │ ├── tests/ │ │ ├── Feature/ │ │ │ ├── API/ │ │ │ │ ├── AuthControllerTest.php │ │ │ │ └── EventControllerTest.php │ │ │ ├── AdminBoardsTest.php │ │ │ ├── BoardControllerTest.php │ │ │ ├── BoardUsageTest.php │ │ │ ├── DisplaySettingsApiTest.php │ │ │ ├── InstanceHeartbeatTest.php │ │ │ └── PageReachabilityTest.php │ │ ├── Pest.php │ │ ├── TestCase.php │ │ └── Unit/ │ │ ├── DisplaySettingsTest.php │ │ ├── SettingsTest.php │ │ └── WorkspaceUsageTest.php │ └── vite.config.js ├── deployment/ │ └── docker-compose.mariadb-redis.yml ├── docker-compose.dev.yml ├── docker-compose.prod.yml ├── docker-compose.yml ├── docs/ │ ├── REVERSE_PROXY.md │ ├── SETUP.md │ └── UPGRADE_GUIDE.md └── k6/ ├── README.md ├── load-test.js └── tags.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/docker-build.yml ================================================ name: Build and Push Docker Image on: push: branches: - main - dev paths: - 'backend/**' - '.github/workflows/docker-build.yml' workflow_dispatch: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: driver: docker-container - name: Log in to the Container registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | # For main branch type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' }} # For dev branch type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} # Common tags type=ref,event=branch type=sha,format=short type=raw,value=${{ github.ref_name }}-${{ github.sha }},enable=${{ github.ref != 'refs/heads/main' }} - name: Get git tag id: git_tag run: | GIT_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "") echo "tag=${GIT_TAG}" >> $GITHUB_OUTPUT echo "Found tag: ${GIT_TAG}" - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: ./backend platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | GIT_TAG=${{ steps.git_tag.outputs.tag }} GIT_COMMIT=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: push: branches: - main - dev paths: - 'backend/**' - '.github/workflows/tests.yml' pull_request: branches: - main - dev paths: - 'backend/**' workflow_dispatch: jobs: test: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.4' extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, opentelemetry, protobuf coverage: xdebug - name: Copy .env working-directory: backend run: php -r "file_exists('.env') || copy('.env.example', '.env');" - name: Get Composer Cache Directory working-directory: backend id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache Composer dependencies uses: actions/cache@v3 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('backend/composer.lock') }} restore-keys: ${{ runner.os }}-composer- - name: Install Dependencies working-directory: backend run: | composer self-update composer install --prefer-dist --no-progress --no-scripts composer dump-autoload - name: Generate key working-directory: backend run: php artisan key:generate - name: Directory Permissions working-directory: backend run: chmod -R 777 storage bootstrap/cache - name: Execute tests (via Pest) working-directory: backend run: vendor/bin/pest --coverage-text --coverage-clover=coverage.xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} file: backend/coverage.xml fail_ci_if_error: true ================================================ FILE: .gitignore ================================================ .env database.sqlite .claude/ ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview Spacepad is a privacy-focused room display application that shows real-time room availability, synced with calendars from Google, Microsoft, and CalDAV providers. The project consists of: - **Frontend (Flutter app)**: Cross-platform mobile app for room displays - **Backend (Laravel API)**: RESTful API handling authentication, calendar integration, and webhook processing ## Architecture ### Flutter App (`/app/`) - **MVC Pattern**: Controllers handle business logic, Services manage API communication - **State Management**: GetX for dependency injection and state management - **Main Components**: - `controllers/`: Business logic controllers (DashboardController, DisplayController, LoginController) - `services/`: API communication services (ApiService, AuthService, EventService, DisplayService) - `models/`: Data models (EventModel, DisplayModel, DeviceModel, UserModel) - `pages/`: UI screens (LoginPage, DashboardPage, DisplayPage, SplashPage) - `components/`: Reusable UI components (ActionButton, EventLine, Spinner, Toast) ### Laravel Backend (`/backend/`) - **Clean Architecture**: Controllers, Services, Models, and Data classes - **Authentication**: Laravel Sanctum for API authentication - **Calendar Integration**: Google Calendar API, Microsoft Graph API, CalDAV - **Main Components**: - `app/Http/Controllers/API/`: API controllers for mobile app - `app/Services/`: Business logic services (EventService, GoogleService, OutlookService, CalDAVService) - `app/Models/`: Eloquent models (User, Display, Event, GoogleAccount, OutlookAccount) - `app/Data/`: Data transfer objects using Spatie Laravel Data ## Common Development Commands ### Flutter App ```bash # Navigate to app directory cd app # Install dependencies flutter pub get # Run the app in development flutter run # Build for Android flutter build apk # Build for iOS flutter build ios # Run tests flutter test # Generate launcher icons flutter pub run flutter_launcher_icons:main ``` ### Laravel Backend ```bash # Navigate to backend directory cd backend # Install PHP dependencies composer install # Install Node.js dependencies npm install # Run development server (with queue, logs, and vite) composer dev # Run individual services php artisan serve # Web server php artisan queue:listen --tries=1 # Queue worker php artisan pail --timeout=0 # Log viewer npm run dev # Vite asset bundler # Database operations php artisan migrate # Run migrations php artisan db:seed # Seed database php artisan migrate:fresh --seed # Fresh migration with seeding # Clear caches php artisan config:clear php artisan cache:clear php artisan route:clear # Run tests php artisan test ./vendor/bin/pest # Code formatting ./vendor/bin/pint ``` ## Key Architecture Patterns ### Flutter App Patterns - **GetX Controllers**: Handle state management and business logic - **Service Layer**: Abstracts API calls and external dependencies - **Repository Pattern**: Services act as repositories for data access - **Translations**: Internationalization support with GetX translations ### Laravel Backend Patterns - **API Resources**: Transform model data for API responses - **Service Classes**: Encapsulate business logic and external API interactions - **Data Classes**: Type-safe data transfer objects - **Middleware**: Authentication and request processing - **Webhooks**: Handle real-time calendar updates from external providers ## Environment Configuration ### Flutter App - Uses `.env` file for environment variables - Key variables: API endpoints, environment settings ### Laravel Backend - Uses `.env` file for configuration - Key variables: Database, cache, queue, calendar API credentials, webhook URLs ## Testing ### Flutter - Widget tests in `/app/test/` - Run with `flutter test` ### Laravel - Feature and Unit tests in `/backend/tests/` - Uses Pest PHP testing framework - Run with `php artisan test` or `./vendor/bin/pest` ## External Integrations ### Calendar Providers - **Google Calendar**: Uses Google Calendar API v3 - **Microsoft 365**: Uses Microsoft Graph API - **CalDAV**: Generic CalDAV protocol support ### Licensing - **LemonSqueezy**: Handles subscription billing for Pro features - **License validation**: Cloud-based instance validation system ## Development Notes - **PHP Requirements**: Backend requires PHP 8.4+ (composer.json specifies ^8.4) - **Flutter Version**: Uses Flutter 3.29.0+ with Dart 3.7.0+ - **Database**: SQLite for local development, supports other databases for production - **Queue System**: Laravel queues for background job processing - **Real-time Updates**: Webhook handlers for calendar change notifications - **Cross-platform**: Flutter app supports iOS and Android - **Internationalization**: Support for English, Dutch, French, Spanish, and German ## Security Considerations - API authentication via Laravel Sanctum tokens - Device-specific authentication and display assignment - User activity tracking and session management - Webhook signature validation for external calendar providers - Environment-based configuration for sensitive data ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Spacepad Thank you for your interest in contributing to Spacepad! This document provides guidelines and instructions for contributing to our project. ## How to Contribute ### Reporting Bugs - Check if the bug has already been reported in the [Issues](https://github.com/magweter/spacepad/issues) section - If not, create a new issue with a clear title and description - Include as much relevant information as possible (steps to reproduce, expected behavior, actual behavior, screenshots, etc.) - Use the bug report template if available ### Suggesting Enhancements - Check if the enhancement has already been suggested in the [Issues](https://github.com/magweter/spacepad/issues) section - If not, create a new issue with a clear title and description - Explain why this enhancement would be useful to most users - Use the feature request template if available ### Pull Requests 1. Fork the repository 2. Create a new branch for your feature or bugfix (`git checkout -b feature/amazing-feature`) 3. Make your changes 4. Run tests to ensure your changes don't break existing functionality 5. Commit your changes (`git commit -m 'Add some amazing feature'`) 6. Push to the branch (`git push origin feature/amazing-feature`) 7. Open a Pull Request ### Development Setup 1. Clone your fork of the repository 2. Install dependencies: ```bash composer install pnpm install ``` 3. Set up your environment: ```bash cp .env.example .env php artisan key:generate ``` 4. Start the development server: ```bash docker-compose up -d ``` ### Coding Standards - Follow the [PSR-12](https://www.php-fig.org/psr/psr-12/) coding style guide for PHP - Always use import statements instead of inline fully qualified class names - See [Coding Standards](backend/docs/CODING_STANDARDS.md) for details - Use ESLint and Prettier for JavaScript/TypeScript - Write meaningful commit messages - Add comments for complex logic - Update documentation as needed ### Testing - Write tests for new features - Ensure all tests pass before submitting a pull request - Run the test suite: ```bash php artisan test ``` ## Documentation - Update the README.md if needed - Add inline documentation for complex functions - Update API documentation if you change endpoints - Add examples for new features ## Release Process 1. Update version numbers in relevant files 2. Update the CHANGELOG.md 3. Create a new release on GitHub 4. Tag the release with the version number ## Questions? If you have any questions, please open an issue or contact the maintainers. Thank you for contributing to Spacepad! ================================================ FILE: LICENSE.md ================================================ Spacepad Community License (Sustainable Use License) ---------------------------------------------------- Copyright (c) 2025 Spacepad.io Permission is hereby granted, free of charge, to any individual or organization (the "User") to use, copy, modify, and self-host this software (the “Software”) under the following conditions: 1. **Permitted Use** - You may use and modify the Software for **personal**, **educational**, or **non-commercial personal** purposes. - You may also use the Software in a **limited commercial** or **non-commercial organizational** setting under the following condition: - The deployment includes **no more than 1 active room display** at any time. - The deployment is **self-hosted**. - Use beyond this limit requires a paid Pro license, regardless of commercial or non-commercial status. 2. **Non-Commercial Organizations (e.g. nonprofits, schools)** - Organizations using the Software non-commercially (such as nonprofits, educational institutions, or NGOs) **must obtain a Pro license** for use beyond 1 display. - Eligible non-commercial organizations are entitled to a **50% discount** on licensing fees. 3. **Commercial Use Restrictions** - Use of the Software with **more than 1 display**, or to access premium features, requires a valid **Spacepad Pro License**. - You may not offer the Software as a hosted service (SaaS) or embed it in commercial products without written permission. 4. **Attribution** - You may remove or modify Spacepad branding only with a valid Pro license. - Attribution in the form of a visible link or credit is appreciated but not required for personal use. 5. **No Warranty** - This Software is provided "as is", without warranty of any kind. 6. **Termination** - This license is automatically terminated if these terms are violated. - Continued use beyond these terms requires a Pro license or separate written agreement. To purchase a license or request a nonprofit discount, visit: https://spacepad.io/pricing Contact: support@spacepad.io ================================================ FILE: LICENSE_PRO.md ================================================ Spacepad Pro License (Commercial Use) -------------------------------------- Copyright (c) 2025 Spacepad.io This license grants you (the "Licensee") the right to use Spacepad in commercial deployments beyond what is permitted in the Community License. 1. **Scope of License** - You may deploy Spacepad in a commercial environment with the amount of displays specified by your purchased plan. For details of these plans see [pricing](https://spacepad.io/pricing). - You may use all included Pro features. 2. **Conditions** - This license is **non-transferable** and limited to your organization or client. - You may not sublicense, sell, or resell Spacepad or host it as a service to third parties without a separate agreement. 3. **Delivery and Activation** - A valid license key may be required to unlock Pro features. - Use of license keys is subject to monitoring and rate-limiting. 4. **Support** - Commercial licenses include email support and update access during the active term of the license. 5. **Term** - Your license is valid for the subscription period purchased (monthly or yearly). - Renewal is required to continue using Pro features after expiration. 6. **Termination** - This license is revoked if the terms are violated. - License keys may be deactivated in the event of abuse, fraud, or breach of terms. For questions or volume licensing, contact: support@spacepad.io ================================================ FILE: README.md ================================================

Spacepad

Simple room displays for every workplace. Display room availability in real-time,
synced with your rooms and calendars — ideal for tablets outside meeting spaces or home-offices.
Suitable for both small offices and larger deployments.

Website · Report Issue · Suggest Feature

![Product Overview](art/overview.png) ## Our Mission We’re building focused, fun tools for modern offices — tools that just work, without enterprise BS. Spacepad strives to be the perfect all-encompassing room display solution for SMB's.

✅ Simple: Easy to deploy and use
🔐 Privacy-first: Self hosted and open source auditable
💸 Fair and sustainable: We offer paid features to keep development active
❤️ Designed with care: Beautiful on tablets, easy on the eye
## Features Spacepad offers a comprehensive suite of features to make managing and viewing room availability effortless. ### Core Features - **Real-time room availability** - Events sync instantly and display current room status - **Multi-room overview boards** - Create beautiful dashboards showing multiple rooms at once with customizable layouts (card, table, or grid view) - **On-device room booking** - Book rooms directly from the display with preset durations (15/30/60 min) or custom time slots - **Room check-in** - Check in to reserved meetings with configurable grace periods - **Event cancellation** - Cancel current meetings directly from the display - **Full day schedule** - View all upcoming events for the day on each display ### Calendar Integrations - **Google Calendar** - Full integration with Google Workspace calendars - **Microsoft 365** - Seamless sync with Outlook calendars via Microsoft Graph API - **CalDAV** - Support for any CalDAV-compatible provider (Nextcloud, iCloud, etc.) - **Real-time webhooks** - Instant updates when calendar events change ### Customization & Branding - **Custom themes** - Dark, light, or system theme support - **Custom logos** - Upload your organization's logo to displays and boards - **Font selection** - Choose from multiple Google Fonts (Inter, Roboto, Open Sans, Lato, Poppins, Montserrat) - **Multi-language support** - Available in English, Dutch, French, German, Spanish, and Swedish - **Privacy controls** - Hide meeting titles for privacy-sensitive environments - **Display settings** - Configure what information to show (booker name, next event, transitioning status) ### Workspace & Collaboration - **Workspaces** - Organize displays, calendars, and boards by workspace - **Team collaboration** - Share workspaces with team members with role-based access - **Workspace-scoped resources** - All displays, calendars, and boards are organized per workspace ### Deployment Options - **Cloud Hosted** - Get started in minutes with zero maintenance - **Self Hosted** - Full control over your data with Docker deployment - **Cross-platform** - Native iOS and Android apps built with Flutter > [!TIP] > The product is developing rapidly and we're happily accepting feedback and suggestions. Have a look at our [roadmap](#roadmap) on the implementation of new features or open a new [discussion](https://github.com/magweter/spacepad/discussions) to share ideas. ## 🔧 Get Started ### ☁️ Cloud Hosted (Easiest) Looking to get started quickly? Get started in minutes using our cloud. 1. Visit [spacepad.io](https://spacepad.io) 2. Create a free account 3. Set up your first display — the first one is free forever 4. Add more displays at $6/month each Great for fast deployments with zero maintenance. ### 🏗️ Self Hosted Self hosting Spacepad is the perfect solution for businesses or enthousiasts who want control over their data. As we believe in open source and personal tinkering, we want to support these communities. 🙎‍♂️ If you’re a hobbyist or home user, enjoy Spacepad self hosted without limits — completely for free. 🏢 If you're a business using Spacepad, we ask you to purchase a self-hosted license. We offer simple, sustainable and affordable flat-tiered pricing. Have a look at [Spacepad Pricing](https://spacepad.io/pricing). For full setup instructions, see [Setup Guide](docs/SETUP.md). ## 🛠 Licensing Spacepad is dual-licensed: - 🧑‍💻 **Community License** ([LICENSE.md](LICENSE.md)) For personal use and self-hosted commercial use with up to 1 display. - 🏢 **Pro License** ([LICENSE_PRO.md](LICENSE_PRO.md)) Required for commercial use with multiple displays or Pro features. Purchasing a license helps support continued development. ## 🤝 Contributing We love open source and welcome your contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) to get started. ## 📅 Roadmap Highlights - [x] Microsoft 365 / Outlook integration - [x] Self-hosted deployment with Docker - [x] Google Workspace / Google Calendar integration - [x] CalDAV support (Nextcloud, iCloud, etc.) - [x] On-device room booking with custom time slots - [x] Room check-in and release functionality - [x] Full day event schedule display - [x] Custom themes, logos, and fonts - [x] Multi-room overview boards (Pro feature) - [x] Workspace management and collaboration - [x] Multi-language support (6 languages) - [x] Privacy controls for meeting titles - [x] Multiple board view modes (card, table, grid) Feature requests? We're all ears! Please open a new [discussion](https://github.com/magweter/spacepad/discussions). ================================================ FILE: app/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .build/ .buildlog/ .history .svn/ .swiftpm/ migrate_working_dir/ .env # IntelliJ related *.iml *.ipr *.iws .idea/ # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line # is commented out by default. #.vscode/ # Flutter/Dart/Pub related **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ .flutter-plugins .flutter-plugins-dependencies .pub-cache/ .pub/ /build/ # Symbolication related app.*.symbols # Obfuscation related app.*.map.json # Android Studio will place build artifacts here /android/app/debug /android/app/profile /android/app/release # FVM Version Cache .fvm/ ================================================ FILE: app/.metadata ================================================ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # # This file should be version controlled and should not be manually edited. version: revision: "2d17299f20f3eb164ef21bc80b8079ba293e5985" channel: "master" project_type: app # Tracks metadata for the flutter migrate command migration: platforms: - platform: root create_revision: 2d17299f20f3eb164ef21bc80b8079ba293e5985 base_revision: 2d17299f20f3eb164ef21bc80b8079ba293e5985 - platform: android create_revision: 2d17299f20f3eb164ef21bc80b8079ba293e5985 base_revision: 2d17299f20f3eb164ef21bc80b8079ba293e5985 - platform: ios create_revision: 2d17299f20f3eb164ef21bc80b8079ba293e5985 base_revision: 2d17299f20f3eb164ef21bc80b8079ba293e5985 # User provided section # List of Local paths (relative to this file) that should be # ignored by the migrate tool. # # Files that are not part of the templates will be ignored by default. unmanaged_files: - 'lib/main.dart' - 'ios/Runner.xcodeproj/project.pbxproj' ================================================ FILE: app/README.md ================================================ # spacepad A simple and fun meeting room occupancy display. ## Getting Started This project is a starting point for a Flutter application. A few resources to get you started if this is your first Flutter project: - [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) - [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) For help getting started with Flutter development, view the [online documentation](https://docs.flutter.dev/), which offers tutorials, samples, guidance on mobile development, and a full API reference. ================================================ FILE: app/analysis_options.yaml ================================================ # This file configures the analyzer, which statically analyzes Dart code to # check for errors, warnings, and lints. # # The issues identified by the analyzer are surfaced in the UI of Dart-enabled # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be # invoked from the command line by running `flutter analyze`. # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` # included above or to enable additional rules. A list of all available lints # and their documentation is published at https://dart.dev/lints. # # Instead of disabling a lint rule for the entire project in the # section below, it can also be suppressed for a single line of code # or a specific dart file by using the `// ignore: name_of_lint` and # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options ================================================ FILE: app/android/.gitignore ================================================ gradle-wrapper.jar /.gradle /captures/ /gradlew /gradlew.bat /local.properties GeneratedPluginRegistrant.java # Remember to never publicly share your keystore. # See https://flutter.dev/to/reference-keystore key.properties **/*.keystore **/*.jks ================================================ FILE: app/android/app/.gitignore ================================================ .cxx/ ================================================ FILE: app/android/app/build.gradle.kts ================================================ import java.util.Properties import java.io.FileInputStream plugins { id("com.android.application") id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } // Load keystore properties from key.properties val keystoreProperties = Properties() val keystorePropertiesFile = rootProject.file("key.properties") if (keystorePropertiesFile.exists()) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } android { namespace = "com.magweter.spacepad" compileSdk = flutter.compileSdkVersion ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() } defaultConfig { applicationId = "com.magweter.spacepad" minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } signingConfigs { create("release") { keyAlias = keystoreProperties["keyAlias"] as String keyPassword = keystoreProperties["keyPassword"] as String storeFile = file(keystoreProperties["storeFile"] as String) storePassword = keystoreProperties["storePassword"] as String } } buildTypes { getByName("release") { isMinifyEnabled = true isShrinkResources = true signingConfig = signingConfigs.getByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } } flutter { source = "../.." } ================================================ FILE: app/android/app/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: app/android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/android/app/src/main/kotlin/com/magweter/spacepad/MainActivity.kt ================================================ package com.magweter.spacepad import io.flutter.embedding.android.FlutterActivity class MainActivity : FlutterActivity() ================================================ FILE: app/android/app/src/main/res/drawable/launch_background.xml ================================================ ================================================ FILE: app/android/app/src/main/res/drawable-v21/launch_background.xml ================================================ ================================================ FILE: app/android/app/src/main/res/values/styles.xml ================================================ ================================================ FILE: app/android/app/src/main/res/values-night/styles.xml ================================================ ================================================ FILE: app/android/app/src/profile/AndroidManifest.xml ================================================ ================================================ FILE: app/android/build.gradle.kts ================================================ allprojects { repositories { google() mavenCentral() } } val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() rootProject.layout.buildDirectory.value(newBuildDir) subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) project.layout.buildDirectory.value(newSubprojectBuildDir) } subprojects { project.evaluationDependsOn(":app") } subprojects { plugins.withType { extensions.findByType()?.apply { if (namespace == null) { namespace = group.toString() } } } } tasks.register("clean") { delete(rootProject.layout.buildDirectory) } ================================================ FILE: app/android/gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip ================================================ FILE: app/android/gradle.properties ================================================ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true ================================================ FILE: app/android/settings.gradle.kts ================================================ pluginManagement { val flutterSdkPath = run { val properties = java.util.Properties() file("local.properties").inputStream().use { properties.load(it) } val flutterSdkPath = properties.getProperty("flutter.sdk") require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } flutterSdkPath } includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { google() mavenCentral() gradlePluginPortal() } } plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.7.0" apply false id("org.jetbrains.kotlin.android") version "2.2.21" apply false } include(":app") ================================================ FILE: app/ios/.gitignore ================================================ **/dgph *.mode1v3 *.mode2v3 *.moved-aside *.pbxuser *.perspectivev3 **/*sync/ .sconsign.dblite .tags* **/.vagrant/ **/DerivedData/ Icon? **/Pods/ **/.symlinks/ profile xcuserdata **/.generated/ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig Flutter/ephemeral/ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ Flutter/flutter_export_environment.sh ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !default.mode1v3 !default.mode2v3 !default.pbxuser !default.perspectivev3 ================================================ FILE: app/ios/Flutter/AppFrameworkInfo.plist ================================================ CFBundleDevelopmentRegion en CFBundleExecutable App CFBundleIdentifier io.flutter.flutter.app CFBundleInfoDictionaryVersion 6.0 CFBundleName App CFBundlePackageType FMWK CFBundleShortVersionString 1.0 CFBundleSignature ???? CFBundleVersion 1.0 MinimumOSVersion 12.0 ================================================ FILE: app/ios/Flutter/Debug.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" ================================================ FILE: app/ios/Flutter/Release.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" ================================================ FILE: app/ios/Podfile ================================================ # Uncomment this line to define a global platform for your project # platform :ios, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' project 'Runner', { 'Debug' => :debug, 'Profile' => :release, 'Release' => :release, } def flutter_root generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) unless File.exist?(generated_xcode_build_settings_path) raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" end File.foreach(generated_xcode_build_settings_path) do |line| matches = line.match(/FLUTTER_ROOT\=(.*)/) return matches[1].strip if matches end raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" end require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) flutter_ios_podfile_setup target 'Runner' do use_frameworks! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do inherit! :search_paths end end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) end end ================================================ FILE: app/ios/Runner/AppDelegate.swift ================================================ import Flutter import UIKit @main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } ================================================ FILE: app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} ================================================ FILE: app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "LaunchImage.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "LaunchImage@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "LaunchImage@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md ================================================ # Launch Screen Assets You can customize the launch screen with your own desired assets by replacing the image files in this directory. You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. ================================================ FILE: app/ios/Runner/Base.lproj/LaunchScreen.storyboard ================================================ ================================================ FILE: app/ios/Runner/Base.lproj/Main.storyboard ================================================ ================================================ FILE: app/ios/Runner/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName Spacepad CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName spacepad CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) CFBundleSignature ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight CADisableMinimumFrameDurationOnPhone UIApplicationSupportsIndirectInputEvents ================================================ FILE: app/ios/Runner/Runner-Bridging-Header.h ================================================ #import "GeneratedPluginRegistrant.h" ================================================ FILE: app/ios/Runner.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 04CBFBCE232CB3029E7ED08A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EA6696D39F3FA1106D16702B /* Pods_Runner.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3670718E77EE3226CF664312 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 03B40A5A39F58BC328EECDE1 /* Pods_RunnerTests.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; proxyType = 1; remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 03B40A5A39F58BC328EECDE1 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0861BD94EAEC750534439CD7 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 288B2822D826A92AAEFB3A7A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 62CD59CF29A95104CB65D9E7 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 8993A6110D12561E9348200F /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; EA6696D39F3FA1106D16702B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EDAA5666919238FE907B7EE2 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; FE7BEC1B12808A29558CCC47 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 64E37CD377E15AE198178E4F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 3670718E77EE3226CF664312 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 04CBFBCE232CB3029E7ED08A /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, ); path = RunnerTests; sourceTree = ""; }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, ); name = Flutter; sourceTree = ""; }; 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, C0DBB1C66027CD5924273FC7 /* Pods */, 9C087B018664F9F7D6AF9B81 /* Frameworks */, ); sourceTree = ""; }; 97C146EF1CF9000F007C117D /* Products */ = { isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; }; 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; 9C087B018664F9F7D6AF9B81 /* Frameworks */ = { isa = PBXGroup; children = ( EA6696D39F3FA1106D16702B /* Pods_Runner.framework */, 03B40A5A39F58BC328EECDE1 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; }; C0DBB1C66027CD5924273FC7 /* Pods */ = { isa = PBXGroup; children = ( 0861BD94EAEC750534439CD7 /* Pods-Runner.debug.xcconfig */, 62CD59CF29A95104CB65D9E7 /* Pods-Runner.release.xcconfig */, 288B2822D826A92AAEFB3A7A /* Pods-Runner.profile.xcconfig */, EDAA5666919238FE907B7EE2 /* Pods-RunnerTests.debug.xcconfig */, 8993A6110D12561E9348200F /* Pods-RunnerTests.release.xcconfig */, FE7BEC1B12808A29558CCC47 /* Pods-RunnerTests.profile.xcconfig */, ); path = Pods; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 331C8080294A63A400263BE5 /* RunnerTests */ = { isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( 0FCF9A8EB03F9B4634484BD6 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, 64E37CD377E15AE198178E4F /* Frameworks */, ); buildRules = ( ); dependencies = ( 331C8086294A63A400263BE5 /* PBXTargetDependency */, ); name = RunnerTests; productName = RunnerTests; productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( CE1F428B58F0533B9F926766 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, AAA2B55502893C358B6E22B5 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 331C8080294A63A400263BE5 = { CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 97C146E51CF9000F007C117D; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 331C807F294A63A400263BE5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 0FCF9A8EB03F9B4634484BD6 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( "${PODS_PODFILE_DIR_PATH}/Podfile.lock", "${PODS_ROOT}/Manifest.lock", ); name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( ); outputPaths = ( "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = "Run Script"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; AAA2B55502893C358B6E22B5 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; CE1F428B58F0533B9F926766 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( "${PODS_PODFILE_DIR_PATH}/Podfile.lock", "${PODS_ROOT}/Manifest.lock", ); name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( ); outputPaths = ( "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 331C807D294A63A400263BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( 97C146FB1CF9000F007C117D /* Base */, ); name = Main.storyboard; sourceTree = ""; }; 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( 97C147001CF9000F007C117D /* Base */, ); name = LaunchScreen.storyboard; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Profile; }; 249021D4217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = DGZY9K7USV; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Spacepad; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.magweter.spacepad; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = EDAA5666919238FE907B7EE2 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.magweter.spacepad.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Debug; }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 8993A6110D12561E9348200F /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.magweter.spacepad.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Release; }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = FE7BEC1B12808A29558CCC47 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.magweter.spacepad.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Profile; }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; }; 97C147061CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = DGZY9K7USV; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Spacepad; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.magweter.spacepad; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; 97C147071CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = DGZY9K7USV; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Spacepad; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.magweter.spacepad; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 331C8088294A63A400263BE5 /* Debug */, 331C8089294A63A400263BE5 /* Release */, 331C808A294A63A400263BE5 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147031CF9000F007C117D /* Debug */, 97C147041CF9000F007C117D /* Release */, 249021D3217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147061CF9000F007C117D /* Debug */, 97C147071CF9000F007C117D /* Release */, 249021D4217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } ================================================ FILE: app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: app/ios/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: app/ios/RunnerTests/RunnerTests.swift ================================================ import Flutter import UIKit import XCTest class RunnerTests: XCTestCase { func testExample() { // If you add code to the Runner application, consider adding tests here. // See https://developer.apple.com/documentation/xctest for more information about using XCTest. } } ================================================ FILE: app/lib/components/action_button.dart ================================================ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:tailwind_components/tailwind_components.dart'; import 'package:spacepad/components/frosted_panel.dart'; class ActionButton extends StatelessWidget { final String text; final VoidCallback? onPressed; final Color? borderColor; final Color? textColor; final bool isPhone; final double cornerRadius; final bool disabled; final bool isLoading; const ActionButton({ super.key, required this.text, required this.onPressed, required this.isPhone, required this.cornerRadius, this.borderColor, this.textColor, this.disabled = false, this.isLoading = false, }); @override Widget build(BuildContext context) { final Color effectiveBorderColor = borderColor ?? TWColors.gray_500.withAlpha(160); final bool isDisabled = disabled || isLoading; return Opacity( opacity: isDisabled ? 0.5 : 1.0, child: Container( margin: EdgeInsets.only(top: isPhone ? 10 : 20, bottom: isPhone ? 10 : 20), decoration: BoxDecoration( borderRadius: BorderRadius.circular(cornerRadius), ), child: FrostedPanel( borderRadius: cornerRadius, blurIntensity: 18, child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(cornerRadius), onTap: isDisabled ? null : onPressed, child: Stack( children: [ Padding( padding: EdgeInsets.symmetric( vertical: isPhone ? 12 : 16, horizontal: isPhone ? 20 : 28, ), child: SizedBox( height: isPhone ? 22 : 26, // Fixed height to match text line height child: Stack( alignment: Alignment.center, children: [ // Keep text in layout to maintain button width, but make it invisible when loading Opacity( opacity: isLoading ? 0 : 1, child: Text( text.tr, style: TextStyle( color: textColor ?? TWColors.white, fontSize: isPhone ? 16 : 20, fontWeight: FontWeight.w700, height: 1.0, // Ensure consistent line height ), textAlign: TextAlign.center, ), ), // Show loading indicator on top when loading if (isLoading) SizedBox( width: isPhone ? 20 : 24, height: isPhone ? 20 : 24, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( textColor ?? TWColors.white, ), ), ), ], ), ), ), if (disabled && !isLoading) Positioned.fill( child: CustomPaint( painter: _DiagonalStrikethroughPainter( color: effectiveBorderColor, ), ), ), ], ), ), ), ), ), ); } } class _DiagonalStrikethroughPainter extends CustomPainter { final Color color; static const double borderWidth = 2; _DiagonalStrikethroughPainter({required this.color}); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..strokeWidth = borderWidth; // Draw from the middle of the border on each corner canvas.drawLine( Offset(1, size.height - 1), Offset(size.width - 1, 1), paint, ); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } ================================================ FILE: app/lib/components/action_panel.dart ================================================ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:spacepad/components/action_button.dart'; import 'package:tailwind_components/tailwind_components.dart'; class ActionPanel extends StatelessWidget { final dynamic controller; final bool isPhone; final double cornerRadius; const ActionPanel({ super.key, required this.controller, required this.isPhone, required this.cornerRadius, }); @override Widget build(BuildContext context) { final isPortrait = MediaQuery.of(context).orientation == Orientation.portrait; return SpaceRow( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Show booking options when not reserved Obx(() { final showBooking = !controller.isReserved && controller.bookingEnabled; final showCheckIn = controller.isCheckInActive && controller.checkInEnabled; if (showBooking) { final isBooking = controller.isBooking.value; final bookingDuration = controller.bookingDuration.value; // If both booking and check-in are visible, combine them if (showCheckIn && !controller.showBookingOptions.value) { // Show "book_now" button and check-in button together return SpaceRow( mainAxisSize: MainAxisSize.min, spaceBetween: isPhone ? 12 : 16, mainAxisAlignment: MainAxisAlignment.start, children: [ ActionButton( text: 'book_now', onPressed: isBooking ? null : () => controller.toggleBookingOptions(), isPhone: isPhone, cornerRadius: cornerRadius, isLoading: isBooking && bookingDuration == null, ), ActionButton( text: 'check_in', onPressed: () => controller.checkIn(), isPhone: isPhone, cornerRadius: cornerRadius, ), ], ); } return controller.showBookingOptions.value ? (isPortrait ? // Portrait: Horizontally scrollable container wrapped in Flexible Flexible( child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( mainAxisSize: MainAxisSize.min, children: [ // Show all options, but disable and strikethrough if not available for (var min in [15, 30, 60]) Padding( padding: EdgeInsets.only(right: min == 60 ? 0 : (isPhone ? 12 : 16)), child: ActionButton( text: '$min min', onPressed: (controller.availableBookingDurations.contains(min) && !isBooking) ? () => controller.bookRoom(min) : null, isPhone: isPhone, cornerRadius: cornerRadius, disabled: !controller.availableBookingDurations.contains(min) || (isBooking && bookingDuration != min), isLoading: isBooking && bookingDuration == min, // Only show loading on the clicked button ), ), if (controller.hasCustomBooking) ...[ SizedBox(width: isPhone ? 12 : 16), ActionButton( text: 'custom', onPressed: isBooking ? null : () => controller.showCustomBookingModal(context, isPhone, cornerRadius), isPhone: isPhone, cornerRadius: cornerRadius, disabled: isBooking, isLoading: isBooking && bookingDuration == null, // Show loading if custom booking is in progress ), ], SizedBox(width: isPhone ? 16 : 24), ActionButton( text: 'cancel', onPressed: isBooking ? null : () => controller.hideBookingOptions(), isPhone: isPhone, cornerRadius: cornerRadius, disabled: isBooking, ), ], ), ), ) : // Landscape: Keep buttons in a single row Row( mainAxisSize: MainAxisSize.min, children: [ // Show all options, but disable and strikethrough if not available for (var min in [15, 30, 60]) Padding( padding: EdgeInsets.only(right: min == 60 ? 0 : (isPhone ? 12 : 16)), child: ActionButton( text: '$min min', onPressed: (controller.availableBookingDurations.contains(min) && !isBooking) ? () => controller.bookRoom(min) : null, isPhone: isPhone, cornerRadius: cornerRadius, disabled: !controller.availableBookingDurations.contains(min) || (isBooking && bookingDuration != min), isLoading: isBooking && bookingDuration == min, // Only show loading on the clicked button ), ), if (controller.hasCustomBooking) ...[ SizedBox(width: isPhone ? 12 : 16), ActionButton( text: 'custom', onPressed: isBooking ? null : () => controller.showCustomBookingModal(context, isPhone, cornerRadius), isPhone: isPhone, cornerRadius: cornerRadius, disabled: isBooking, isLoading: isBooking && bookingDuration == null, // Show loading if custom booking is in progress ), ], SizedBox(width: isPhone ? 16 : 24), ActionButton( text: 'cancel', onPressed: isBooking ? null : () => controller.hideBookingOptions(), isPhone: isPhone, cornerRadius: cornerRadius, disabled: isBooking, ), ], ) ) : ActionButton( text: 'book_now', onPressed: isBooking ? null : () => controller.toggleBookingOptions(), isPhone: isPhone, cornerRadius: cornerRadius, isLoading: isBooking && bookingDuration == null, // Only show loading if no specific duration button was clicked ); } return SizedBox.shrink(); }), // Show cancel button and custom booking when reserved (meeting is active) Obx(() { if (controller.isReserved && !controller.isCheckInActive && controller.bookingEnabled) { final isBooking = controller.isBooking.value; return SpaceRow( mainAxisSize: MainAxisSize.min, spaceBetween: isPhone ? 12 : 16, mainAxisAlignment: MainAxisAlignment.start, children: [ if (controller.canCancelCurrentEvent) ActionButton( text: 'cancel_event', onPressed: controller.isCancelling.value ? null : () => controller.cancelCurrentEvent(), textColor: Colors.white, isPhone: isPhone, cornerRadius: cornerRadius, isLoading: controller.isCancelling.value, ), if (controller.hasCustomBooking) ActionButton( text: 'reserve', onPressed: isBooking ? null : () => controller.showCustomBookingModal(context, isPhone, cornerRadius), isPhone: isPhone, cornerRadius: cornerRadius, disabled: isBooking, isLoading: isBooking && controller.bookingDuration.value == null, ), ], ); } return SizedBox.shrink(); }), Obx(() { // Show check-in button separately when booking is not enabled or reserved // Hide check-in button when booking options are expanded final showBooking = !controller.isReserved && controller.bookingEnabled; final showCheckIn = controller.isCheckInActive && controller.checkInEnabled; // Don't show check-in button when booking options are expanded if (showBooking && controller.showBookingOptions.value) { return SizedBox.shrink(); } if (showCheckIn && (controller.isReserved || !controller.bookingEnabled)) { return ActionButton( text: 'check_in', onPressed: () => controller.checkIn(), isPhone: isPhone, cornerRadius: cornerRadius, ); } return SizedBox.shrink(); }), ] ); } } ================================================ FILE: app/lib/components/admin_actions.dart ================================================ import 'package:flutter/material.dart'; import 'package:get/get.dart'; class AdminActions extends StatelessWidget { final dynamic controller; final bool isPhone; const AdminActions({ super.key, required this.controller, required this.isPhone, }); @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ // Refresh button Obx(() { final iconSize = isPhone ? 20.0 : 28.0; return Opacity( opacity: 0.6, child: SizedBox( width: 24, height: iconSize, child: IconButton( icon: controller.isRefreshing.value ? SizedBox( width: iconSize - 8, height: iconSize - 8, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white), ), ) : Icon(Icons.refresh, size: iconSize, color: Colors.white), onPressed: controller.isRefreshing.value ? null : () { controller.refreshDisplayData(); }, tooltip: 'refresh_data'.tr, padding: EdgeInsets.zero, alignment: Alignment.center, ), ), ); }), SizedBox(width: 15), // Logout/Switch room button Opacity( opacity: 0.6, child: SizedBox( width: 24, height: isPhone ? 20 : 28, child: IconButton( icon: const Icon(Icons.logout, size: 24, color: Colors.white), onPressed: () { controller.switchRoom(); }, tooltip: 'switch_room'.tr, padding: EdgeInsets.zero, alignment: Alignment.center, ), ), ), ], ); } } ================================================ FILE: app/lib/components/authenticated_background.dart ================================================ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:spacepad/services/auth_service.dart'; import 'package:get/get.dart'; class AuthenticatedBackground extends StatefulWidget { final String? imageUrl; final Widget child; final BorderRadius? borderRadius; const AuthenticatedBackground({ Key? key, this.imageUrl, required this.child, this.borderRadius, }) : super(key: key); @override State createState() => _AuthenticatedBackgroundState(); } class _AuthenticatedBackgroundState extends State { ImageProvider? _imageProvider; bool _hasError = false; @override void initState() { super.initState(); if (widget.imageUrl != null) { _loadImage(); } } @override void didUpdateWidget(AuthenticatedBackground oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.imageUrl != widget.imageUrl) { if (widget.imageUrl != null) { _loadImage(); } else { setState(() { _hasError = false; _imageProvider = null; }); } } } Future _loadImage() async { if (!mounted || widget.imageUrl == null) return; setState(() { _hasError = false; }); try { // Get authentication headers final headers = { 'Accept': 'application/json', 'Accept-Language': Get.locale?.languageCode ?? 'en', }; if (AuthService.instance.getAuthToken() != null) { headers['Authorization'] = 'Bearer ${AuthService.instance.getAuthToken()}'; } // Make authenticated request with timeout final response = await http.get( Uri.parse(widget.imageUrl!), headers: headers, ).timeout( const Duration(seconds: 15), onTimeout: () { throw TimeoutException('Image load timeout after 15 seconds'); }, ); if (response.statusCode == 200) { // Create image provider from bytes _imageProvider = MemoryImage(response.bodyBytes); if (mounted) { setState(() { _hasError = false; }); } } else { throw Exception('Failed to load image: ${response.statusCode}'); } } on TimeoutException { if (mounted) { setState(() { _hasError = true; }); } return; } catch (e) { if (mounted) { setState(() { _hasError = true; }); } } } @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( borderRadius: widget.borderRadius, color: Colors.black, image: _imageProvider != null && !_hasError ? DecorationImage( image: _imageProvider!, fit: BoxFit.cover, colorFilter: ColorFilter.mode( Colors.black.withValues(alpha: 0.3), BlendMode.srcOver, ), ) : null, ), child: widget.child, ); } } ================================================ FILE: app/lib/components/authenticated_image.dart ================================================ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:spacepad/services/auth_service.dart'; import 'package:get/get.dart'; class AuthenticatedImage extends StatefulWidget { final String imageUrl; final double? width; final double? height; final BoxFit fit; final Widget? placeholder; final Widget? errorWidget; const AuthenticatedImage({ Key? key, required this.imageUrl, this.width, this.height, this.fit = BoxFit.cover, this.placeholder, this.errorWidget, }) : super(key: key); @override State createState() => _AuthenticatedImageState(); } class _AuthenticatedImageState extends State { ImageProvider? _imageProvider; bool _isLoading = true; bool _hasError = false; @override void initState() { super.initState(); _loadImage(); } @override void didUpdateWidget(AuthenticatedImage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.imageUrl != widget.imageUrl) { _loadImage(); } } Future _loadImage() async { if (!mounted) return; setState(() { _isLoading = true; _hasError = false; }); try { // Get authentication headers final headers = { 'Accept': 'application/json', 'Accept-Language': Get.locale?.languageCode ?? 'en', }; if (AuthService.instance.getAuthToken() != null) { headers['Authorization'] = 'Bearer ${AuthService.instance.getAuthToken()}'; } // Make authenticated request with timeout final response = await http.get( Uri.parse(widget.imageUrl), headers: headers, ).timeout( const Duration(seconds: 10), onTimeout: () { throw TimeoutException('Image load timeout after 10 seconds', const Duration(seconds: 10)); }, ); if (response.statusCode == 200) { // Create image provider from bytes _imageProvider = MemoryImage(response.bodyBytes); if (mounted) { setState(() { _isLoading = false; _hasError = false; }); } } else { throw Exception('Failed to load image: ${response.statusCode}'); } } on TimeoutException { if (mounted) { setState(() { _isLoading = false; _hasError = true; }); } } catch (e) { if (mounted) { setState(() { _isLoading = false; _hasError = true; }); } } } @override Widget build(BuildContext context) { if (_isLoading) { return widget.placeholder ?? Container( width: widget.width, height: widget.height, child: Center( child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.grey), ), ), ); } if (_hasError || _imageProvider == null) { return widget.errorWidget ?? SizedBox.shrink(); } return Image( image: _imageProvider!, width: widget.width, height: widget.height, fit: widget.fit, ); } } ================================================ FILE: app/lib/components/calendar_modal.dart ================================================ import 'package:flutter/material.dart'; import 'package:spacepad/models/event_model.dart'; import 'package:spacepad/theme.dart'; import 'package:get/get.dart'; import 'package:spacepad/date_format_helper.dart'; import 'package:tailwind_components/tailwind_components.dart'; import 'package:spacepad/components/frosted_panel.dart'; class CalendarModal extends StatelessWidget { final List events; final DateTime selectedDate; const CalendarModal({ super.key, required this.events, required this.selectedDate, }); @override Widget build(BuildContext context) { return Dialog( backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), child: Center( child: SizedBox( width: 800, // Make modal narrower child: FrostedPanel( borderRadius: 20, blurIntensity: 18, padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Title and close icon Padding( padding: const EdgeInsets.fromLTRB(26, 20, 26, 20), child: Row( children: [ Expanded( child: Text( 'todays_schedule'.tr, style: TextStyle( color: AppTheme.platinum, fontSize: 22, fontWeight: FontWeight.bold, ), ), ), IconButton( onPressed: () => Navigator.of(context).pop(), icon: Icon( Icons.close, color: AppTheme.platinum, size: 28, ), splashRadius: 22, ), ], ), ), // Removed date header // Events list Flexible( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: events.isEmpty ? SizedBox( height: 200, child: Center( child: Text( 'no_events_today'.tr, style: TextStyle( color: AppTheme.platinum, fontSize: 16, ), ), ), ) : ListView.builder( shrinkWrap: true, itemCount: events.length, itemBuilder: (context, index) { final event = events[index]; return Container( margin: const EdgeInsets.only(bottom: 18), decoration: BoxDecoration( color: TWColors.gray_800, borderRadius: BorderRadius.circular(14), boxShadow: [ BoxShadow( color: Colors.black .withAlpha((0.1 * 255).toInt()), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 18, vertical: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.schedule, color: AppTheme.orange, size: 18, ), const SizedBox(width: 8), Text( '${formatTime(context, event.start)} - ${formatTime(context, event.end)}', style: TextStyle( color: AppTheme.platinum, fontSize: 15, fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 8), Text( event.summary, style: TextStyle( color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold, ), ), if ((event.location ?? '') .trim() .isNotEmpty) ...[ const SizedBox(height: 4), Text( event.location!, style: TextStyle( color: AppTheme.platinum, fontSize: 13, fontWeight: FontWeight.w400, ), overflow: TextOverflow.ellipsis, ), ], ], ), ), ); }, ), ), ), ], ), ), ), ), ); } } ================================================ FILE: app/lib/components/custom_booking_modal.dart ================================================ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:spacepad/components/frosted_panel.dart'; import 'package:spacepad/components/solid_button.dart'; import 'package:spacepad/date_format_helper.dart'; import 'package:spacepad/models/event_model.dart'; import 'package:spacepad/theme.dart'; import 'package:tailwind_components/tailwind_components.dart'; class CustomBookingModal extends StatefulWidget { final dynamic controller; final bool isPhone; final double cornerRadius; const CustomBookingModal({ super.key, required this.controller, required this.isPhone, required this.cornerRadius, }); @override State createState() => _CustomBookingModalState(); } class _CustomBookingModalState extends State { late TextEditingController _titleController; late DateTime _startTime; late DateTime _endTime; DateTime? _nextMeetingStart; @override void initState() { super.initState(); final now = DateTime.now(); // Find the first available time slot // If there's a current event, start time should be when it ends // Otherwise, start from now final currentEvent = widget.controller.currentEvent; if (currentEvent != null && currentEvent.end.isAfter(now)) { _startTime = currentEvent.end; } else { _startTime = now; } // Calculate default end time: 1 hour from start, or until next meeting if available final upcomingEvents = widget.controller.upcomingEvents as List; if (upcomingEvents.isNotEmpty) { // Find the next meeting that starts after our start time final nextMeetingAfterStart = upcomingEvents.where((event) => event.start.isAfter(_startTime)).firstOrNull; if (nextMeetingAfterStart != null) { _nextMeetingStart = nextMeetingAfterStart.start; final oneHourFromStart = _startTime.add(const Duration(hours: 1)); _endTime = _nextMeetingStart!.isBefore(oneHourFromStart) ? _nextMeetingStart! : oneHourFromStart; } else { // No meetings after start time, use 1 hour default _nextMeetingStart = null; _endTime = _startTime.add(const Duration(hours: 1)); } } else { _nextMeetingStart = null; _endTime = _startTime.add(const Duration(hours: 1)); } // Ensure end time is strictly after start time _endTime = _endTime.isBefore(_startTime) || _endTime.isAtSameMomentAs(_startTime) ? _startTime.add(const Duration(minutes: 1)) : _endTime; _titleController = TextEditingController(text: 'reserved'.tr); } @override void dispose() { _titleController.dispose(); super.dispose(); } Future _selectStartTime() async { final now = DateTime.now(); // Ensure initial time is not in the past final initialTime = _startTime.isBefore(now) ? TimeOfDay.fromDateTime(now) : TimeOfDay.fromDateTime(_startTime); final TimeOfDay? picked = await showTimePicker( context: context, initialTime: initialTime, ); if (picked != null) { final selectedDateTime = DateTime( now.year, now.month, now.day, picked.hour, picked.minute, ); // Prevent selecting time in the past final validStartTime = selectedDateTime.isBefore(now) ? now : selectedDateTime; setState(() { _startTime = validStartTime; // Ensure end time is after start time if (_endTime.isBefore(_startTime) || _endTime.isAtSameMomentAs(_startTime)) { _endTime = _startTime.add(const Duration(hours: 1)); } }); } } Future _selectEndTime() async { final now = DateTime.now(); // Ensure initial time is not in the past and is after start time final minEndTime = _startTime.add(const Duration(minutes: 1)); final initialEndTime = _endTime.isBefore(minEndTime) ? TimeOfDay.fromDateTime(minEndTime) : TimeOfDay.fromDateTime(_endTime); final TimeOfDay? picked = await showTimePicker( context: context, initialTime: initialEndTime, ); if (picked != null) { final selectedDateTime = DateTime( now.year, now.month, now.day, picked.hour, picked.minute, ); // Prevent selecting time in the past or before start time final minValidTime = minEndTime.isAfter(now) ? minEndTime : now.add(const Duration(minutes: 1)); final validEndTime = selectedDateTime.isBefore(minValidTime) ? minValidTime : (selectedDateTime.isAfter(_startTime) ? selectedDateTime : minValidTime); setState(() { _endTime = validEndTime; }); } } void _setStartTimeToNow() { setState(() { final now = DateTime.now(); // Clamp start time to now if in the past _startTime = now; // Ensure end time is strictly after start time final oneHourFromStart = _startTime.add(const Duration(hours: 1)); if (_nextMeetingStart != null && _nextMeetingStart!.isBefore(oneHourFromStart)) { _endTime = _nextMeetingStart!.isAfter(_startTime) ? _nextMeetingStart! : _startTime.add(const Duration(minutes: 1)); } else { _endTime = _endTime.isBefore(_startTime) || _endTime.isAtSameMomentAs(_startTime) ? oneHourFromStart : _endTime; } // Final check: ensure end time is strictly after start time _endTime = _endTime.isBefore(_startTime) || _endTime.isAtSameMomentAs(_startTime) ? _startTime.add(const Duration(minutes: 1)) : _endTime; }); } void _setEndTimeToMax() { if (_nextMeetingStart != null) { setState(() { final now = DateTime.now(); // Clamp start time to now if in the past _startTime = _startTime.isBefore(now) ? now : _startTime; // Set end time to next meeting start, but ensure it's strictly after start time final oneHourFromStart = _startTime.add(const Duration(hours: 1)); _endTime = (_nextMeetingStart != null && _nextMeetingStart!.isBefore(oneHourFromStart)) ? _nextMeetingStart! : oneHourFromStart; // Ensure end time is strictly after start time _endTime = _endTime.isBefore(_startTime) || _endTime.isAtSameMomentAs(_startTime) ? _startTime.add(const Duration(minutes: 1)) : _endTime; }); } } void _bookCustom() { if (_titleController.text.trim().isEmpty) { return; } // Clamp and normalize times before sending to backend final now = DateTime.now(); // Clamp start time to now if in the past final clampedStartTime = _startTime.isBefore(now) ? now : _startTime; // Preserve next meeting clipping behavior if applicable final oneHourFromStart = clampedStartTime.add(const Duration(hours: 1)); final endTimeWithClipping = (_nextMeetingStart != null && _nextMeetingStart!.isBefore(oneHourFromStart)) ? _nextMeetingStart! : oneHourFromStart; // Ensure end time is strictly after start time (at least 1 minute after) // Use max of endTimeWithClipping or minimum valid end time final minValidEndTime = clampedStartTime.add(const Duration(minutes: 1)); final finalEndTime = endTimeWithClipping.isBefore(minValidEndTime) || endTimeWithClipping.isAtSameMomentAs(minValidEndTime) ? minValidEndTime : endTimeWithClipping; widget.controller.bookCustom( _titleController.text.trim(), clampedStartTime, finalEndTime, ); Navigator.of(context).pop(); } @override Widget build(BuildContext context) { return Dialog( backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), child: Center( child: SizedBox( width: 800, child: FrostedPanel( borderRadius: 20, blurIntensity: 18, padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Title and close button Padding( padding: const EdgeInsets.fromLTRB(26, 20, 26, 20), child: Row( children: [ Expanded( child: Text( 'custom_booking'.tr, style: TextStyle( color: AppTheme.platinum, fontSize: 22, fontWeight: FontWeight.bold, ), ), ), IconButton( onPressed: () => Navigator.of(context).pop(), icon: Icon( Icons.close, color: AppTheme.platinum, size: 28, ), splashRadius: 22, ), ], ), ), // Meeting title input Padding( padding: const EdgeInsets.fromLTRB(26, 0, 26, 20), child: SpaceCol( spaceBetween: 8, children: [ Text( 'meeting_title'.tr, style: TextStyle( color: AppTheme.platinum, fontSize: 15, fontWeight: FontWeight.w600, ), ), TextField( controller: _titleController, style: TextStyle( color: Colors.white, fontSize: 17, ), decoration: InputDecoration( enabledBorder: OutlineInputBorder( borderSide: BorderSide(color: TWColors.gray_500), borderRadius: BorderRadius.circular(8), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: Colors.white), borderRadius: BorderRadius.circular(8), ), filled: true, fillColor: TWColors.gray_800.withOpacity(0.5), ), ), ], ), ), // Start and End time side by side Padding( padding: const EdgeInsets.fromLTRB(26, 0, 26, 20), child: Row( children: [ // Start time Expanded( child: SpaceCol( spaceBetween: 8, children: [ Text( 'start_time'.tr, style: TextStyle( color: AppTheme.platinum, fontSize: 15, fontWeight: FontWeight.w600, ), ), Row( children: [ Expanded( child: GestureDetector( onTap: _selectStartTime, child: Container( padding: EdgeInsets.symmetric( horizontal: 12, vertical: 12, ), decoration: BoxDecoration( color: TWColors.gray_800.withOpacity(0.5), borderRadius: BorderRadius.circular(8), border: Border.all(color: TWColors.gray_500), ), child: Text( formatTime(context, _startTime), style: TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, ), ), ), ), ), SizedBox(width: 8), SolidButton( text: 'now', onPressed: _setStartTimeToNow, fontSize: 15, ), ], ), ], ), ), SizedBox(width: 16), // End time Expanded( child: SpaceCol( spaceBetween: 8, children: [ Text( 'end_time'.tr, style: TextStyle( color: AppTheme.platinum, fontSize: 15, fontWeight: FontWeight.w600, ), ), Row( children: [ Expanded( child: GestureDetector( onTap: _selectEndTime, child: Container( padding: EdgeInsets.symmetric( horizontal: 12, vertical: 12, ), decoration: BoxDecoration( color: TWColors.gray_800.withAlpha(128), borderRadius: BorderRadius.circular(8), border: Border.all(color: TWColors.gray_500), ), child: Text( formatTime(context, _endTime), style: TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, ), ), ), ), ), SizedBox(width: 8), SolidButton( text: 'max', onPressed: _nextMeetingStart != null ? _setEndTimeToMax : null, fontSize: 15, ), ], ), ], ), ), ], ), ), // Action buttons Padding( padding: const EdgeInsets.fromLTRB(26, 16, 26, 20), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ SolidButton( text: 'close', onPressed: () => Navigator.of(context).pop(), fontSize: 17, padding: EdgeInsets.symmetric(horizontal: 20, vertical: 12), ), SizedBox(width: 12), SolidButton( text: 'book', onPressed: _bookCustom, fontSize: 17, padding: EdgeInsets.symmetric(horizontal: 20, vertical: 12), ), ], ), ), ], ), ), ), ), ); } } ================================================ FILE: app/lib/components/event_line.dart ================================================ import 'package:get/get.dart'; import 'package:spacepad/models/event_model.dart'; import 'package:flutter/material.dart'; import 'package:spacepad/date_format_helper.dart'; import 'package:tailwind_components/tailwind_components.dart'; class EventLine extends StatelessWidget { const EventLine({super.key, required this.event}); final EventModel event; bool _isPhone(BuildContext context) { final shortestSide = MediaQuery.of(context).size.shortestSide; return shortestSide < 600; } @override Widget build(BuildContext context) { final isPhone = _isPhone(context); return SizedBox( width: double.infinity, child: SpaceRow( spaceBetween: isPhone ? 5 : 10, mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( '${'next'.tr}:', style: TextStyle( fontSize: isPhone ? 16 : 18, fontWeight: FontWeight.bold, color: Colors.white ) ), Expanded( child: Text( 'next_event_title'.trParams({ 'start': formatTime(context, event.start), 'end': formatTime(context, event.end), 'summary': event.summary, }), style: TextStyle( fontSize: isPhone ? 16 : 18, fontWeight: FontWeight.w400, color: Colors.white ), overflow: TextOverflow.ellipsis, maxLines: 1, ), ), ], ), ); } } ================================================ FILE: app/lib/components/frosted_panel.dart ================================================ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:tailwind_components/tailwind_components.dart'; class FrostedPanel extends StatelessWidget { final Widget child; final double borderRadius; final double blurIntensity; final Color backgroundColor; final EdgeInsetsGeometry? padding; const FrostedPanel({ super.key, required this.child, this.borderRadius = 20, this.blurIntensity = 18, this.backgroundColor = const Color(0x14FFFFFF), // Colors.white.withAlpha((0.08 * 255).toInt()) this.padding, }); /// Creates a frosted panel with gray background (for use with background images) factory FrostedPanel.gray({ required Widget child, double borderRadius = 20, bool hasBackgroundImage = false, EdgeInsetsGeometry? padding, }) { return FrostedPanel( borderRadius: borderRadius, blurIntensity: 0, // No blur for gray panels backgroundColor: hasBackgroundImage ? TWColors.black.withValues(alpha: 0.8) : TWColors.black.withValues(alpha: 0.1), padding: padding, child: child, ); } @override Widget build(BuildContext context) { final panel = Container( decoration: BoxDecoration( color: Colors.white.withAlpha((0.1 * 255).toInt()), borderRadius: BorderRadius.circular(borderRadius), ), padding: padding, child: child, ); // Only apply backdrop filter if blur intensity is greater than 0 if (blurIntensity > 0) { return ClipRRect( borderRadius: BorderRadius.circular(borderRadius), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: blurIntensity, sigmaY: blurIntensity), child: panel, ), ); } return ClipRRect( borderRadius: BorderRadius.circular(borderRadius), child: panel, ); } } ================================================ FILE: app/lib/components/solid_button.dart ================================================ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:tailwind_components/tailwind_components.dart'; class SolidButton extends StatelessWidget { final String text; final VoidCallback? onPressed; final double? fontSize; final EdgeInsets? padding; final double borderRadius; const SolidButton({ super.key, required this.text, this.onPressed, this.fontSize, this.padding, this.borderRadius = 8, }); @override Widget build(BuildContext context) { return Opacity( opacity: onPressed != null ? 1.0 : 0.5, child: GestureDetector( onTap: onPressed, child: Container( padding: padding ?? EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( color: TWColors.gray_800.withAlpha(128), borderRadius: BorderRadius.circular(borderRadius), border: Border.all(color: TWColors.gray_500), ), child: Text( text.tr, style: TextStyle( color: Colors.white, fontSize: fontSize ?? 15, fontWeight: FontWeight.w600, ), ), ), ), ); } } ================================================ FILE: app/lib/components/spinner.dart ================================================ import 'package:flutter/material.dart'; import 'package:spacepad/theme.dart'; class Spinner extends StatelessWidget { final double size; final EdgeInsets? padding; final double? thickness; final Color? color; const Spinner({super.key, required this.size, this.padding, this.thickness, this.color = AppTheme.oxford}); @override Widget build(BuildContext context) { return Container( height: size, width: size, margin: padding, child: CircularProgressIndicator(strokeWidth: thickness ?? 3, color: color), ); } } ================================================ FILE: app/lib/components/toast.dart ================================================ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:heroicons/heroicons.dart'; class Toast { Toast._(); static void showSuccess(String message) { _showSnackBar(message, HeroIcons.check, Colors.green); } static void showError(String message) { _showSnackBar(message, HeroIcons.exclamationCircle, Colors.red); } static void _showSnackBar(String message, HeroIcons icon, Color color) async { /// Small delay to make sure widget tree is built. await Future.delayed(const Duration(milliseconds: 100)); Get.showSnackbar( GetSnackBar( messageText: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded(child: Padding( padding: const EdgeInsets.only(top: 3), child: Text(message, style: const TextStyle( color: Colors.black, fontSize: 14, height: 1.2, fontWeight: FontWeight.w600, )), )), const SizedBox(width: 2), IconButton( onPressed: () => Get.closeCurrentSnackbar(), icon: const HeroIcon(HeroIcons.xMark, size: 20), ) ], ), margin: const EdgeInsets.symmetric(horizontal: 35, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 7), backgroundColor: Colors.white, boxShadows: const [ BoxShadow( color: Colors.black12, spreadRadius: 1, blurRadius: 2, offset: Offset(0, 0), // changes position of shadow ), ], icon: Padding( padding: const EdgeInsets.only(left: 12, right: 8), child: HeroIcon(icon, color: color, style: HeroIconStyle.solid, size: 26) ), borderRadius: 10, duration: const Duration(seconds: 4), animationDuration: const Duration(milliseconds: 350), forwardAnimationCurve: Curves.fastEaseInToSlowEaseOut, reverseAnimationCurve: Curves.fastOutSlowIn, borderWidth: 1, borderColor: Colors.grey[300], ) ); } } ================================================ FILE: app/lib/controllers/dashboard_controller.dart ================================================ import 'dart:async'; import 'package:get/get.dart'; import 'package:spacepad/components/toast.dart'; import 'package:spacepad/models/event_model.dart'; import 'package:spacepad/models/display_data_model.dart'; import 'package:spacepad/models/event_status.dart'; import 'package:spacepad/services/display_service.dart'; import 'package:spacepad/services/auth_service.dart'; import 'package:spacepad/pages/display_page.dart'; import 'package:spacepad/models/device_model.dart'; import 'package:spacepad/models/display_model.dart'; import 'package:spacepad/models/display_settings_model.dart'; import 'package:spacepad/services/font_service.dart'; import 'package:flutter/material.dart'; import 'package:spacepad/components/custom_booking_modal.dart'; class DashboardController extends GetxController { final RxBool loading = RxBool(true); final RxList events = RxList(); final Rx time = Rx(DateTime.now()); final RxString displayId = RxString(''); // Global variables for device, display, and settings DeviceModel? globalCurrentDevice; DisplayModel? globalDisplay; final Rx globalSettings = Rx(null); // Reactive font family for UI updates final RxString currentFontFamily = RxString('Inter'); Timer? _clock; Timer? _dataTimer; // Track refresh state to prevent spamming final RxBool isRefreshing = RxBool(false); DateTime? _lastRefreshTime; static const int _refreshCooldownSeconds = 3; @override void onInit() async { super.onInit(); updateTime(); // Check if display ID is set, redirect to display page if not final displayIdResult = AuthService.instance.getCurrentDisplayId(); if (displayIdResult == null) { Get.offAll(() => const DisplayPage()); return; } else { displayId.value = displayIdResult; } initializeTimers(); await fetchDisplayData(); // Preload fonts for better performance await FontService.instance.preloadFonts(); loading.value = false; } void initializeTimers() { final int millisecondsToNextSecond = DateTime.now().millisecond; // Start a timer that aligns with the next second for data refresh (every 60 seconds) Future.delayed(Duration(milliseconds: millisecondsToNextSecond), () { _dataTimer = Timer.periodic(const Duration(seconds: 60), (timer) => fetchDisplayData()); }); _clock = Timer.periodic(const Duration(seconds: 1), (timer) => updateTime()); } void updateTime() { time.value = DateTime.now(); } String get roomName { return globalDisplay?.name ?? 'meeting_room'.tr; } String get title { if (isReserved) { return currentEvent!.summary; } if (isCheckInActive) { return globalSettings.value?.textCheckin ?? 'check_in_now'.tr; } if (isTransitioning && !isReserved) { return globalSettings.value?.textTransitioning ?? 'to_be_reserved'.tr; } return globalSettings.value?.textAvailable ?? 'available'.tr; } /// Returns the start and end DateTime of the current event, or null if not reserved. Map? get meetingInfoTimes { if (!isReserved) { return null; } return { 'start': currentEvent!.start, 'end': currentEvent!.end, }; } String get subtitle { if (isReserved && !isCheckInActive) { final currentEventEnd = currentEvent!.end; final totalMinutesLeft = currentEventEnd.difference(DateTime.now()).inMinutes; final hoursLeft = (totalMinutesLeft / 60).floor(); final minutesLeft = (totalMinutesLeft - (hoursLeft * 60)).floor() + 1; return totalMinutesLeft < 60 ? 'x_minutes_left'.trParams({'minutes': minutesLeft.toString()}) : 'x_hours_x_minutes_left'.trParams({'hours': hoursLeft.toString(), 'minutes': minutesLeft.toString()}); } if (isCheckInActive && currentEvent != null) { final totalMinutesLeft = currentEvent!.start.add(Duration(minutes: checkInGracePeriod)).difference(DateTime.now()).inMinutes; final hoursLeft = (totalMinutesLeft / 60).floor(); final minutesLeft = (totalMinutesLeft - (hoursLeft * 60)).floor() + 1; return 'check_in_within_x_minutes'.trParams({'minutes': minutesLeft.toString()}); } if (isCheckInActive && upcomingEvents.isNotEmpty) { final upcomingMeeting = upcomingEvents.first; final totalMinutesLeft = upcomingMeeting.start.difference(DateTime.now()).inMinutes; final hoursLeft = (totalMinutesLeft / 60).floor(); final minutesLeft = (totalMinutesLeft - (hoursLeft * 60)).floor() + 1; return 'x_starts_in_x_minutes'.trParams({'meeting': upcomingMeeting.summary, 'minutes': minutesLeft.toString()}); } if (upcomingEvents.isNotEmpty) { final upcomingStart = upcomingEvents.first.start; final totalMinutesLeft = upcomingStart.difference(DateTime.now()).inMinutes; final hoursLeft = (totalMinutesLeft / 60).floor(); final minutesLeft = (totalMinutesLeft - (hoursLeft * 60)).floor() + 1; return totalMinutesLeft < 60 ? 'for_x_minutes'.trParams({'minutes': minutesLeft.toString()}) : 'for_x_hours_x_minutes'.trParams({'hours': hoursLeft.toString(), 'minutes': minutesLeft.toString()}); } return 'till_end_of_day'.tr; } bool get isReserved { return currentEvent != null; } bool get isTransitioning { if (checkInEnabled) { return false; } if (isReserved) { final currentEventEnd = currentEvent!.end; final minutesLeft = currentEventEnd.difference(DateTime.now()).inMinutes; return minutesLeft < 10; } if (upcomingEvents.isNotEmpty) { final upcomingStart = upcomingEvents.first.start; final minutesLeft = upcomingStart.difference(DateTime.now()).inMinutes; return minutesLeft < 10; } return false; } // Returns true if there is an event with checkInRequired and we are within its check-in window (before/after start) bool get isCheckInActive { if (!checkInEnabled) { return false; } return checkInEvent != null; } EventModel? get checkInEvent { final now = DateTime.now(); return events.firstWhereOrNull((e) { if (e.checkInRequired != true) return false; final start = e.start; final windowStart = start.subtract(Duration(minutes: checkInMinutes)); final windowEnd = start.add(Duration(minutes: checkInGracePeriod)); return now.isAfter(windowStart) && now.isBefore(windowEnd); }); } EventModel? get currentEvent { DateTime now = DateTime.now(); return events.where((e) => now.isAfter(e.start) && now.isBefore(e.end)).firstOrNull; } List get upcomingEvents { List nextEvents = events.where((e) => e.start.isAfter(DateTime.now())).toList(); nextEvents.sort((a, b) => a.start.compareTo(b.start)); return nextEvents; } Future fetchDisplayData() async { try { DisplayDataModel displayData = await DisplayService.instance.getDisplayData(displayId.value); // Update global device, display, and settings if (AuthService.instance.currentDevice.value != null) { globalCurrentDevice = AuthService.instance.currentDevice.value; globalCurrentDevice!.display = displayData.display; globalDisplay = globalCurrentDevice!.display; globalSettings.value = globalDisplay?.settings; // Update reactive font family to trigger UI rebuild final newFontFamily = globalSettings.value?.fontFamily ?? 'Inter'; if (currentFontFamily.value != newFontFamily) { currentFontFamily.value = newFontFamily; // Reload the font when settings change await FontService.instance.reloadFont(newFontFamily); } AuthService.instance.currentDevice.refresh(); } // Update events events.value = displayData.events .where((e) => e.status != EventStatus.cancelled) .map((e) { e.summary = getDisplayableSummary(e); return e; }) .toList(); } catch (e) { Toast.showError('could_not_load_data'.tr); } } void switchRoom() { _clock?.cancel(); _dataTimer?.cancel(); Get.offAll(() => const DisplayPage()); } // Manually refresh display data with cooldown to prevent spamming Future refreshDisplayData() async { // Check if we're already refreshing if (isRefreshing.value) { return; } // Check cooldown period if (_lastRefreshTime != null) { final secondsSinceLastRefresh = DateTime.now().difference(_lastRefreshTime!).inSeconds; if (secondsSinceLastRefresh < _refreshCooldownSeconds) { return; } } isRefreshing.value = true; _lastRefreshTime = DateTime.now(); try { await fetchDisplayData(); Toast.showSuccess('display_data_refreshed'.tr); } finally { isRefreshing.value = false; } } Future bookRoom(int duration) async { if (isBooking.value) return; // Prevent multiple simultaneous bookings try { isBooking.value = true; bookingDuration.value = duration; // Track which button was clicked final summary = 'reserved'.tr; await DisplayService.instance.book(displayId.value, duration, summary: summary); await fetchDisplayData(); Toast.showSuccess('room_booked'.tr); // Cancel the booking options timer since user took action _bookingOptionsTimer?.cancel(); showBookingOptions.value = false; } catch (e) { Toast.showError('could_not_book_room'.tr); } finally { isBooking.value = false; bookingDuration.value = null; // Clear the tracked duration } } void showCustomBookingModal(BuildContext context, bool isPhone, double cornerRadius) { showDialog( context: context, builder: (context) => CustomBookingModal( controller: this, isPhone: isPhone, cornerRadius: cornerRadius, ), ); } Future bookCustom(String title, DateTime startTime, DateTime endTime) async { isBooking.value = true; try { await DisplayService.instance.bookCustom(displayId.value, title, startTime, endTime); await fetchDisplayData(); Toast.showSuccess('room_booked'.tr); // Cancel the booking options timer since user took action _bookingOptionsTimer?.cancel(); showBookingOptions.value = false; } catch (e) { Toast.showError('could_not_book_room'.tr); } finally { isBooking.value = false; bookingDuration.value = null; // Clear the tracked duration } } Future cancelCurrentEvent() async { if (isCancelling.value) return; // Prevent multiple simultaneous cancellations try { isCancelling.value = true; if (currentEvent != null) { await DisplayService.instance.cancelEvent(displayId.value, currentEvent!.id); await fetchDisplayData(); Toast.showSuccess('event_cancelled'.tr); } } catch (e) { Toast.showError('could_not_cancel_event'.tr); } finally { isCancelling.value = false; } } // Check if booking should be displayed based on display settings bool get bookingEnabled { return globalSettings.value?.bookingEnabled ?? false; } // Check if custom booking is available (server capability) bool get hasCustomBooking { return globalSettings.value?.hasCustomBooking ?? false; } // Check if current event can be cancelled based on cancel permission setting bool get canCancelCurrentEvent { // Early return: cannot cancel if there's no current event if (currentEvent == null) { return false; } final cancelPermission = globalSettings.value?.cancelPermission ?? 'all'; if (cancelPermission == 'none') { return false; } if (cancelPermission == 'tablet_only') { // Only allow cancelling if the event was booked via tablet // currentEvent is guaranteed to be non-null at this point return currentEvent!.isTabletBooking; } // Default: 'all' - allow cancelling any event // currentEvent is guaranteed to be non-null at this point return true; } // Get border width based on border thickness setting double getBorderWidth() { final borderThickness = globalSettings.value?.borderThickness ?? 'medium'; switch (borderThickness) { case 'small': return 1.33; case 'large': return 2.67; case 'medium': default: return 2.0; } } bool get calendarEnabled { return globalSettings.value?.calendarEnabled ?? false; } // Track if booking options are shown final RxBool showBookingOptions = RxBool(false); // Loading states for actions final RxBool isBooking = RxBool(false); final Rx bookingDuration = Rx(null); // Track which duration button was clicked final RxBool isCancelling = RxBool(false); // Timer for booking options timeout Timer? _bookingOptionsTimer; // Track if admin actions are temporarily visible final RxBool showAdminActionsTemporarily = RxBool(false); // Timer for admin actions timeout Timer? _adminActionsTimer; // Timer for long press detection (3 seconds) Timer? _longPressTimer; // Show booking options with 30-second timeout void toggleBookingOptions() { showBookingOptions.value = true; // Cancel any existing timer _bookingOptionsTimer?.cancel(); // Set a 30-second timeout to automatically hide booking options _bookingOptionsTimer = Timer(const Duration(seconds: 30), () { showBookingOptions.value = false; }); } // Hide booking options void hideBookingOptions() { showBookingOptions.value = false; _bookingOptionsTimer?.cancel(); } // Start long press timer (3 seconds) void startLongPressTimer() { // Cancel any existing timer _longPressTimer?.cancel(); // Set a 3-second timer to trigger reveal _longPressTimer = Timer(const Duration(seconds: 3), () { revealAdminActionsTemporarily(); }); } // Cancel long press timer void cancelLongPressTimer() { _longPressTimer?.cancel(); } // Show admin actions temporarily (30 seconds) void revealAdminActionsTemporarily() { showAdminActionsTemporarily.value = true; // Show notification with duration Toast.showSuccess('admin_actions_enabled'.trParams({'seconds': '30'})); // Cancel any existing timer _adminActionsTimer?.cancel(); // Set a 30-second timeout to automatically hide admin actions _adminActionsTimer = Timer(const Duration(seconds: 30), () { showAdminActionsTemporarily.value = false; }); } int get checkInGracePeriod { return globalSettings.value?.checkInGracePeriod ?? 5; } bool get checkInEnabled { return globalSettings.value?.checkInEnabled ?? false; } int get checkInMinutes { return globalSettings.value?.checkInMinutes ?? 15; } void checkIn() async { try { await DisplayService.instance.checkInToEvent(displayId.value, checkInEvent!.id); await fetchDisplayData(); Toast.showSuccess('checked_in'.tr); } catch (e) { Toast.showError('could_not_check_in'.tr); } } List get availableBookingDurations { final base = [15, 30, 60]; if (isCheckInActive) { return base.where((min) => min <= checkInGracePeriod).toList(); } if (upcomingEvents.isNotEmpty) { final nextEvent = upcomingEvents.first; final minutesUntilNext = nextEvent.start.difference(DateTime.now()).inMinutes; return base.where((min) => min <= minutesUntilNext).toList(); } return base; } /// Returns the summary to display for an event, respecting showMeetingTitle String getDisplayableSummary(EventModel event) { if (globalSettings.value?.showMeetingTitle == false) { return getReservedText(); } return event.summary; } String getReservedText() { return globalSettings.value?.textReserved ?? 'reserved'.tr; } @override void dispose() { _clock?.cancel(); _dataTimer?.cancel(); _bookingOptionsTimer?.cancel(); _adminActionsTimer?.cancel(); _longPressTimer?.cancel(); super.dispose(); } } ================================================ FILE: app/lib/controllers/display_controller.dart ================================================ import 'package:get/get.dart'; import 'package:spacepad/models/display_model.dart'; import 'package:spacepad/components/toast.dart'; import 'package:spacepad/services/device_service.dart'; import 'package:spacepad/services/display_service.dart'; import 'package:spacepad/services/auth_service.dart'; class DisplayController extends GetxController { final RxBool loading = RxBool(false); final RxList displays = RxList(); final Rx selectedDisplay = Rx(null); @override void onInit() { super.onInit(); getDisplays(); } void onSelect(val) { selectedDisplay.value = val; } bool get submitActive { return selectedDisplay.value != null; } Future getDisplays() async { if (loading.value) return; loading.value = true; try { displays.value = await DisplayService.instance.getDisplays(); } catch (e) { Toast.showError('could_not_load_displays'.tr); } loading.value = false; } Future submit() async { if (loading.value) return; loading.value = true; try { await DeviceService.instance.changeDisplay(selectedDisplay.value!.id); // Save the selected display ID to local storage await AuthService.instance.setCurrentDisplayId(selectedDisplay.value!.id); await AuthService.instance.verify(); } catch (e) { Toast.showError('check_connection'.tr); } loading.value = false; } } ================================================ FILE: app/lib/controllers/login_controller.dart ================================================ import 'dart:io'; import 'dart:core'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter_udid/flutter_udid.dart'; import 'package:get/get.dart'; import 'package:spacepad/services/auth_service.dart'; import 'package:spacepad/components/toast.dart'; import 'package:spacepad/services/server_service.dart'; import 'package:spacepad/services/api_service.dart'; class LoginController extends GetxController { final AuthService _authService = AuthService.instance; final ServerService _serverService = ServerService(); final RxBool loading = false.obs; final RxBool isSelfHosted = false.obs; final RxString url = ''.obs; final RxString code = ''.obs; final RxBool submitActive = false.obs; final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); void toggleSelfHosted(bool value) { isSelfHosted.value = value; _updateSubmitActive(); } void urlChanged(String value) { url.value = value; _updateSubmitActive(); } void codeChanged(String value) { code.value = value; _updateSubmitActive(); } void _updateSubmitActive() { if (isSelfHosted.value) { submitActive.value = url.value.isNotEmpty && code.value.length == 6; } else { submitActive.value = code.value.length == 6; } } bool _isValidUrl(String url) { try { final uri = Uri.parse(url); return uri.isAbsolute && (uri.scheme == 'http' || uri.scheme == 'https'); } catch (e) { return false; } } Future getDeviceId() async { return await FlutterUdid.udid; } Future getDeviceName() async { if (Platform.isAndroid) { AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo; return androidInfo.model; } if (Platform.isIOS) { IosDeviceInfo iosInfo = await deviceInfoPlugin.iosInfo; return iosInfo.utsname.machine; } return null; } Future submit() async { if (loading.value) return; loading.value = true; try { if (isSelfHosted.value) { if (!_isValidUrl(url.value)) { Toast.showError('invalid_url'.tr); return; } var trimmedUrl = url.value.endsWith('/') ? url.value.substring(0, url.value.length - 1) : url.value; if (!await _serverService.isServerReachable(trimmedUrl)) { Toast.showError('server_unreachable'.tr); return; } // Set the custom base URL for the API service await ApiService.setBaseUrl(trimmedUrl); } else { await ApiService.resetToServerBaseUrl(); } final deviceId = await getDeviceId() ?? 'Unknown device'; final deviceName = await getDeviceName() ?? 'Unknown model'; await _authService.login(code.value, deviceId, deviceName); } catch (e) { Toast.showError('login_failed'.tr); } finally { loading.value = false; } } } ================================================ FILE: app/lib/date_format_helper.dart ================================================ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; String formatTime(BuildContext context, DateTime time) { final use24Hour = MediaQuery.of(context).alwaysUse24HourFormat; return use24Hour ? DateFormat.Hm().format(time) // 24-hour format : DateFormat.jm().format(time); // 12-hour format } ================================================ FILE: app/lib/exceptions/api_exception.dart ================================================ import 'dart:convert'; import 'package:http/http.dart'; class ApiException implements Exception { final int code; final String? message; final Map? errors; ApiException({required this.code, this.message, this.errors}); static ApiException fromResponse(Response response) { return ApiException( code: response.statusCode, message: jsonDecode(response.body)['message'], errors: _mapErrors(jsonDecode(response.body)['errors']) ); } static Map? _mapErrors(Map? errors) { return errors?.map((key, value) { return MapEntry(key, value?.isNotEmpty ? value.first : ''); }); } @override String toString() => 'ApiException: $code - $message'; } ================================================ FILE: app/lib/main.dart ================================================ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:get/get.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:spacepad/pages/login_page.dart'; import 'package:spacepad/pages/splash_page.dart'; import 'package:spacepad/theme.dart'; import 'package:spacepad/services/auth_service.dart'; import 'package:spacepad/translations/translations.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:timezone/data/latest.dart' as tz; // Supported locales list const List supportedLocales = [ Locale('en'), Locale('nl'), Locale('fr'), Locale('es'), Locale('de'), Locale('sv'), ]; // Helper function to validate if a locale is exactly supported bool isLocaleSupported(Locale locale) { return supportedLocales.any((supportedLocale) => supportedLocale.languageCode == locale.languageCode && supportedLocale.countryCode == locale.countryCode); } // Helper function to get the best matching locale Locale getBestMatchingLocale(Locale? requestedLocale) { if (requestedLocale == null) { return const Locale('en'); } // First try exact match (both language and country code match) for (final supportedLocale in supportedLocales) { if (supportedLocale.languageCode == requestedLocale.languageCode && supportedLocale.countryCode == requestedLocale.countryCode) { return supportedLocale; } } // Try to find a locale with the same language code (return supported locale, not original) for (final supportedLocale in supportedLocales) { if (supportedLocale.languageCode == requestedLocale.languageCode) { return supportedLocale; } } // Fallback to English return const Locale('en'); } Future main() async { WidgetsFlutterBinding.ensureInitialized(); await dotenv.load(fileName: ".env"); await AuthService.instance.initialise(); tz.initializeTimeZones(); WakelockPlus.enable(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); // Set a valid locale based on device locale or fallback to English final deviceLocale = Get.deviceLocale; final validLocale = getBestMatchingLocale(deviceLocale); // Debug information (remove in production) if (deviceLocale != null) { print('Device locale: ${deviceLocale.languageCode}_${deviceLocale.countryCode}'); print('Selected locale: ${validLocale.languageCode}'); print('Is supported: ${isLocaleSupported(deviceLocale)}'); } Get.updateLocale(validLocale); runApp(const App()); } class App extends StatelessWidget { const App({super.key}); @override Widget build(BuildContext context) { // Resolve locale consistently using the same logic as in main() final resolvedLocale = getBestMatchingLocale(Get.locale); return GetMaterialApp( themeMode: ThemeMode.light, theme: AppTheme.data, initialRoute: '/', transitionDuration: Duration.zero, translations: AppTranslations(), locale: resolvedLocale, fallbackLocale: const Locale('en'), supportedLocales: supportedLocales, localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], debugShowCheckedModeBanner: false, getPages: [ GetPage(name: '/', page: () { if (AuthService.instance.getAuthToken() != null) { return const SplashPage(); } return const LoginPage(); }) ], ); } } ================================================ FILE: app/lib/models/device_model.dart ================================================ import 'package:spacepad/models/user_model.dart'; import 'package:spacepad/models/display_model.dart'; class DeviceModel { final String id; final String name; UserModel? user; DisplayModel? display; DeviceModel({required this.id, required this.name, required this.user, this.display}); factory DeviceModel.fromJson(Map data) { return DeviceModel( id: data['id'], name: data['name'], user: data['user'] != null ? UserModel.fromJson(data['user']) : null, display: data['display'] != null ? DisplayModel.fromJson(data['display']) : null, ); } } ================================================ FILE: app/lib/models/display_data_model.dart ================================================ import 'display_model.dart'; import 'event_model.dart'; class DisplayDataModel { DisplayModel? display; List events; DisplayDataModel({ required this.display, required this.events, }); factory DisplayDataModel.fromJson(Map data) { return DisplayDataModel( display: DisplayModel.fromJson(data['display']), events: (data['events'] as List) .map((e) => EventModel.fromJson(e)) .toList(), ); } factory DisplayDataModel.fromEventsJson(List data) { return DisplayDataModel( display: null, events: data .map((e) => EventModel.fromJson(e)) .toList(), ); } Map toJson() { return { 'display': display?.toJson(), 'events': events.map((e) => e.toJson()).toList(), }; } } ================================================ FILE: app/lib/models/display_model.dart ================================================ import 'display_settings_model.dart'; class DisplayModel { String id; String name; DisplaySettingsModel settings; DisplayModel({ required this.id, required this.name, required this.settings, }); factory DisplayModel.fromJson(Map data) { return DisplayModel( id: data['id'], name: data['name'], settings: DisplaySettingsModel.fromJson(data['settings'] ?? {}), ); } Map toJson() { return { 'id': id, 'name': name, 'settings': settings.toJson(), }; } } ================================================ FILE: app/lib/models/display_settings_model.dart ================================================ class DisplaySettingsModel { bool checkInEnabled; bool bookingEnabled; bool hasCustomBooking; int checkInGracePeriod; int checkInMinutes; bool calendarEnabled; bool hideAdminActions; String? textAvailable; String? textTransitioning; String? textReserved; String? textCheckin; bool showMeetingTitle; String? logoUrl; String? backgroundImageUrl; String fontFamily; String cancelPermission; String borderThickness; DisplaySettingsModel({ required this.checkInEnabled, required this.bookingEnabled, required this.hasCustomBooking, required this.checkInGracePeriod, required this.checkInMinutes, required this.calendarEnabled, required this.hideAdminActions, this.textAvailable, this.textTransitioning, this.textReserved, this.textCheckin, required this.showMeetingTitle, this.logoUrl, this.backgroundImageUrl, required this.fontFamily, this.cancelPermission = 'all', this.borderThickness = 'medium', }); factory DisplaySettingsModel.fromJson(Map data) { return DisplaySettingsModel( checkInEnabled: data['check_in_enabled'] ?? false, bookingEnabled: data['booking_enabled'] ?? false, hasCustomBooking: data['has_custom_booking'] ?? false, checkInGracePeriod: data['check_in_grace_period'] ?? 5, checkInMinutes: data['check_in_minutes'] ?? 15, calendarEnabled: data['calendar_enabled'] ?? false, hideAdminActions: data['hide_admin_actions'] ?? false, textAvailable: data['text_available'], textTransitioning: data['text_transitioning'], textReserved: data['text_reserved'], textCheckin: data['text_checkin'], showMeetingTitle: data['show_meeting_title'] ?? true, logoUrl: data['logo_url'], backgroundImageUrl: data['background_image_url'], fontFamily: data['font_family'] ?? 'Inter', cancelPermission: data['cancel_permission'] ?? 'all', borderThickness: data['border_thickness'] ?? 'medium', ); } Map? toJson() { return { 'check_in_enabled': checkInEnabled, 'booking_enabled': bookingEnabled, 'has_custom_booking': hasCustomBooking, 'check_in_grace_period': checkInGracePeriod, 'check_in_minutes': checkInMinutes, 'calendar_enabled': calendarEnabled, 'hide_admin_actions': hideAdminActions, 'text_available': textAvailable, 'text_transitioning': textTransitioning, 'text_reserved': textReserved, 'text_checkin': textCheckin, 'show_meeting_title': showMeetingTitle, 'logo_url': logoUrl, 'background_image_url': backgroundImageUrl, 'font_family': fontFamily, }; } } ================================================ FILE: app/lib/models/event_model.dart ================================================ import 'event_status.dart'; class EventModel { String id; EventStatus status; String summary; String? location; String? description; DateTime start; DateTime end; String? timezone; bool isCheckedIn; bool checkInRequired; String? source; bool isTabletBooking; EventModel({ required this.id, required this.status, required this.summary, this.location, this.description, required this.start, required this.end, this.timezone, this.isCheckedIn = false, this.checkInRequired = false, this.source, this.isTabletBooking = false, }); factory EventModel.fromJson(Map data) { return EventModel( id: data['id'], status: eventStatusFromString(data['status']), summary: data['summary'], location: data['location'], description: data['description'], start: DateTime.parse(data['start']).toLocal(), end: DateTime.parse(data['end']).toLocal(), timezone: data['timezone'], isCheckedIn: data['checkedInAt'] != null, checkInRequired: data['checkInRequired'] ?? false, source: data['source'], isTabletBooking: data['isTabletBooking'] ?? false, ); } Map toJson() { return { 'id': id, 'status': eventStatusToString(status), 'summary': summary, 'location': location, 'description': description, 'start': start.toIso8601String(), 'end': end.toIso8601String(), 'timezone': timezone, 'isCheckedIn': isCheckedIn, 'checkInRequired': checkInRequired, }; } } ================================================ FILE: app/lib/models/event_status.dart ================================================ enum EventStatus { confirmed, tentative, cancelled } EventStatus eventStatusFromString(String? value) { switch (value) { case 'tentative': return EventStatus.tentative; case 'cancelled': return EventStatus.cancelled; case 'confirmed': default: return EventStatus.confirmed; } } String eventStatusToString(EventStatus status) { switch (status) { case EventStatus.tentative: return 'tentative'; case EventStatus.cancelled: return 'cancelled'; case EventStatus.confirmed: default: return 'confirmed'; } } ================================================ FILE: app/lib/models/user_model.dart ================================================ class UserModel { String id; String name; String email; UserModel({ required this.id, required this.name, required this.email, }); factory UserModel.fromJson(Map data) { return UserModel( id: data['id'], name: data['name'], email: data['email'], ); } Map toJson() { return { 'id': id, 'name': name, 'email': email, }; } } ================================================ FILE: app/lib/pages/dashboard_page.dart ================================================ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:spacepad/components/event_line.dart'; import 'package:spacepad/components/spinner.dart'; import 'package:spacepad/controllers/dashboard_controller.dart'; import 'package:spacepad/date_format_helper.dart'; import 'package:spacepad/models/event_model.dart'; import 'package:get/get.dart'; import 'package:spacepad/theme.dart'; import 'package:tailwind_components/tailwind_components.dart'; import 'dart:math' show max; import 'package:spacepad/components/action_panel.dart'; import 'package:spacepad/components/calendar_modal.dart'; import 'package:spacepad/components/authenticated_image.dart'; import 'package:spacepad/components/authenticated_background.dart'; import 'package:spacepad/services/font_service.dart'; import 'package:spacepad/components/frosted_panel.dart'; import 'package:spacepad/components/admin_actions.dart'; class DashboardPage extends StatefulWidget { const DashboardPage({super.key}); @override State createState() => _DashboardPageState(); } class _DashboardPageState extends State { bool _isPhone(BuildContext context) { final shortestSide = MediaQuery.of(context).size.shortestSide; // Consider devices with shortestSide < 600 as phones only return shortestSide < 600; } bool _isPortrait(BuildContext context) { final size = MediaQuery.of(context).size; return size.height > size.width; } double _getCornerRadius(BuildContext context) { // Get the top padding which includes the notch area final topPadding = MediaQuery.of(context).padding.top; // The corner radius is typically around 40-50% of the top padding // We'll use 45% as a good middle ground final cornerRadius = max(topPadding * 0.45, 10.0); return cornerRadius; } double _getContainerPadding(BuildContext context, DashboardController controller) { final size = MediaQuery.of(context).size; final shortestSide = size.shortestSide; final isPortrait = size.height > size.width; // Base padding on shortest side, increase for portrait final basePadding = shortestSide * 0.02; // 2% of shortest side final portraitMultiplier = isPortrait ? 1.2 : 1.1; // Adjust padding based on border thickness setting // Border thickness affects the visual border created by padding final borderThickness = controller.getBorderWidth(); final borderMultiplier = borderThickness / 2.0; // Normalize to 2.0 (medium) as baseline return basePadding * portraitMultiplier * borderMultiplier; } EdgeInsets _getInnerPadding(BuildContext context) { final size = MediaQuery.of(context).size; final shortestSide = size.shortestSide; final isPortrait = size.height > size.width; // Base padding on shortest side, increase for portrait final horizontalBase = shortestSide * 0.033; // ~3.3% of shortest side final verticalBase = shortestSide * 0.025; // ~2.5% of shortest side final portraitMultiplier = isPortrait ? 1.2 : 1.4; return EdgeInsets.fromLTRB( horizontalBase * portraitMultiplier, verticalBase * portraitMultiplier, horizontalBase * portraitMultiplier, verticalBase * portraitMultiplier, ); } @override Widget build(BuildContext context) { DashboardController controller = Get.put(DashboardController()); final isPhone = _isPhone(context); final isPortrait = _isPortrait(context); final cornerRadius = _getCornerRadius(context); if (kDebugMode) print('isPhone: $isPhone'); if (kDebugMode) print('isPortrait: $isPortrait'); if (kDebugMode) print('cornerRadius: $cornerRadius'); return Scaffold( backgroundColor: AppTheme.black, body: Obx(() => controller.loading.value ? Center( child: Spinner(size: 40, thickness: 4, color: AppTheme.platinum), ) : Container( height: double.infinity, width: double.infinity, color: controller.isTransitioning || controller.isCheckInActive ? TWColors.amber_500 : (controller.isReserved ? TWColors.rose_600 : TWColors.green_600), padding: EdgeInsets.all(_getContainerPadding(context, controller)), child: AuthenticatedBackground( imageUrl: controller.globalSettings.value?.backgroundImageUrl, borderRadius: BorderRadius.circular(cornerRadius), child: Padding( padding: _getInnerPadding(context), child: Stack( children: [ Align( alignment: Alignment.topLeft, child: Obx(() => Text( formatTime(context, controller.time.value), style: FontService.instance.getTextStyle( fontFamily: controller.currentFontFamily.value, fontSize: isPhone ? 20 : 28, fontWeight: FontWeight.w500, color: TWColors.white, ) )) ), Align( alignment: Alignment.topRight, child: Obx(() { final hideAdminActions = controller.globalSettings.value?.hideAdminActions ?? false; final showTemporarily = controller.showAdminActionsTemporarily.value; final shouldShowAdminActions = !hideAdminActions || showTemporarily; return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ // Admin actions component (refresh and logout buttons) if (shouldShowAdminActions) AdminActions( controller: controller, isPhone: isPhone, ), if (shouldShowAdminActions) SizedBox(width: 15), GestureDetector( onLongPressStart: (details) { if (hideAdminActions) { controller.startLongPressTimer(); } }, onLongPressEnd: (details) { if (hideAdminActions) { controller.cancelLongPressTimer(); } }, child: Text( controller.roomName, style: FontService.instance.getTextStyle( fontFamily: controller.currentFontFamily.value, fontSize: isPhone ? 20 : 28, fontWeight: FontWeight.w500, color: TWColors.white, ) ), ), ], ); }), ), SpaceCol( spaceBetween: _getContainerPadding(context, controller) * 1.75, // Proportional to container padding mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ SpaceCol( spaceBetween: controller.meetingInfoTimes != null ? (isPhone ? 5 : 10) : 0, children: [ Obx(() { final logoUrl = controller.globalSettings.value?.logoUrl; if (logoUrl != null) { return Container( margin: EdgeInsets.only(bottom: isPhone ? 20 : 10), child: AuthenticatedImage( imageUrl: logoUrl, height: isPhone ? 24 : 36, fit: BoxFit.contain, placeholder: Container( height: isPhone ? 24 : 36, child: Center( child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(TWColors.gray_300), ), ), ), errorWidget: SizedBox.shrink(), // Hide logo if it fails to load ), ); } return SizedBox.shrink(); }), Obx(() => Text( controller.title, style: FontService.instance.getTextStyle( fontFamily: controller.currentFontFamily.value, fontSize: isPhone ? 30 : 50, fontWeight: FontWeight.w700, color: Colors.white, ) )), SpaceRow( spaceBetween: isPhone ? 10 : 20, children: [ if (controller.meetingInfoTimes != null) FrostedPanel( borderRadius: cornerRadius, blurIntensity: 18, padding: EdgeInsets.fromLTRB( isPhone ? 10 : 15, isPhone ? 5 : 8, isPhone ? 10 : 15, isPhone ? 5 : 8, ), child: Obx(() => Text( 'meeting_info_title'.trParams({ 'start': formatTime(context, controller.meetingInfoTimes?['start'] ?? DateTime.now()), 'end': formatTime(context, controller.meetingInfoTimes?['end'] ?? DateTime.now()), }), style: FontService.instance.getTextStyle( fontFamily: controller.currentFontFamily.value, fontSize: isPhone ? 24 : 32, fontWeight: FontWeight.w400, color: TWColors.white, ) )), ), Flexible( child: Obx(() => Text( controller.subtitle, style: FontService.instance.getTextStyle( fontFamily: controller.currentFontFamily.value, fontSize: isPhone ? 28 : 36, fontWeight: FontWeight.w400, color: TWColors.gray_300, ), softWrap: true, overflow: TextOverflow.visible, )), ), ] ), if (controller.meetingInfoTimes == null) SizedBox(height: isPhone ? 5 : 10), if (controller.bookingEnabled || controller.checkInEnabled) ActionPanel( controller: controller, isPhone: isPhone, cornerRadius: cornerRadius, ), ], ), ], ), // Fixed Action Bar at Bottom Align( alignment: Alignment.bottomCenter, child: FrostedPanel( borderRadius: cornerRadius, blurIntensity: 18, padding: EdgeInsets.all(isPhone ? 12 : 20), child: SpaceRow( spaceBetween: isPhone ? 10 : 20, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Upcoming Events Section Expanded( child: controller.upcomingEvents.isNotEmpty ? SpaceCol( spaceBetween: isPhone ? 8 : 12, children: [ for (EventModel event in controller.upcomingEvents.take(1)) EventLine(event: event), ], ) : Text( 'no_upcoming_events'.tr, style: TextStyle( color: TWColors.white, fontSize: isPhone ? 16 : 18, fontWeight: FontWeight.w500, ), ), ), // Action Buttons Section if (controller.calendarEnabled) Material( color: Colors.transparent, child: InkWell( hoverColor: Colors.transparent, splashColor: Colors.transparent, highlightColor: Colors.transparent, borderRadius: BorderRadius.circular(8), onTap: () { showDialog( context: context, builder: (context) => CalendarModal( events: controller.events, selectedDate: DateTime.now(), ), ); }, child: Padding( padding: EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.calendar_today_outlined, size: 24, color: Colors.white, ), SizedBox(width: 12), Text( 'view_schedule'.tr, style: TextStyle( color: Colors.white, fontSize: isPhone ? 16 : 18, fontWeight: FontWeight.w500, ), ), ], ), ), ), ), ], ), ), ), ], ), ), ) ), ), ); } } ================================================ FILE: app/lib/pages/display_page.dart ================================================ import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:spacepad/components/spinner.dart'; import 'package:spacepad/controllers/display_controller.dart'; import 'package:spacepad/models/display_model.dart'; import 'package:spacepad/services/auth_service.dart'; import 'package:spacepad/theme.dart'; import 'package:tailwind_components/tailwind_components.dart'; class DisplayPage extends StatelessWidget { const DisplayPage({super.key}); @override Widget build(BuildContext context) { DisplayController controller = Get.put(DisplayController()); return Scaffold( resizeToAvoidBottomInset: true, body: SingleChildScrollView( child: SafeArea( child: Stack( children: [ // Logout button at top right Positioned( top: 0, right: 0, child: Padding( padding: const EdgeInsets.all(16.0), child: Container( decoration: BoxDecoration( color: TWColors.red_500.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), border: Border.all(color: TWColors.red_500.withValues(alpha: 0.3)), ), child: TextButton( onPressed: () { AuthService.instance.signOut(); }, child: Text( 'logout'.tr, style: const TextStyle( color: TWColors.red_500, fontSize: 14, fontWeight: FontWeight.w500, ), ), ), ), ), ), Container( padding: const EdgeInsets.fromLTRB(20, 20, 20, 60), alignment: Alignment.center, height: MediaQuery.sizeOf(context).height, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Column( children: [ Padding( padding: const EdgeInsets.only(right: 10), child: Text('choose_display'.tr, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w600, height: 1.2 )), ), const SizedBox(height: 15), SizedBox( width: 350, child: Text('choose_room_display'.tr, textAlign: TextAlign.center), ), ], ), const SizedBox(height: 40), SizedBox( width: 400, child: Obx(() => DropdownButtonFormField2( isExpanded: true, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(vertical: 16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.oxford), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.oxford), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.oxford), ), ), hint: Text( 'select_display'.tr, style: const TextStyle(fontSize: 14), ), items: controller.displays .map((item) => DropdownMenuItem( value: item, child: Text( item.name, style: const TextStyle( fontSize: 14, ), ), )) .toList(), validator: (value) { if (value == null) { return 'please_select_display'.tr; } return null; }, onChanged: (value) { controller.onSelect(value); }, buttonStyleData: ButtonStyleData( padding: EdgeInsets.only(right: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), ), ), iconStyleData: const IconStyleData( icon: Icon( Icons.arrow_drop_down, color: Colors.black45, ), iconSize: 24, ), dropdownStyleData: DropdownStyleData( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), ), ), menuItemStyleData: const MenuItemStyleData( padding: EdgeInsets.symmetric(horizontal: 16), ), ), ), ), const SizedBox(height: 60), SizedBox( width: 400, child: Obx(() => ElevatedButton( onPressed: controller.submitActive ? controller.submit : null, child: controller.loading.value ? const Spinner(size: 20) : Text('continue'.tr), )), ), ] ), ), ], ), ) ), ); } } ================================================ FILE: app/lib/pages/login_page.dart ================================================ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:spacepad/components/spinner.dart'; import 'package:spacepad/controllers/login_controller.dart'; import 'package:pinput/pinput.dart'; import 'package:spacepad/theme.dart'; import 'package:tailwind_components/tailwind_components.dart'; class LoginPage extends StatelessWidget { const LoginPage({super.key}); @override Widget build(BuildContext context) { LoginController controller = Get.put(LoginController()); final defaultPinTheme = PinTheme( width: 60, height: 60, textStyle: const TextStyle( fontSize: 22, color: Color.fromRGBO(30, 60, 87, 1), ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), border: Border.all(color: AppTheme.oxford), ), ); return Scaffold( resizeToAvoidBottomInset: true, body: SingleChildScrollView( child: SafeArea( child: Container( padding: const EdgeInsets.fromLTRB(20, 20, 20, 60), alignment: Alignment.center, height: MediaQuery.sizeOf(context).height, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Column( children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.asset('assets/logo.png', width: 80), ), const SizedBox(height: 40), Padding( padding: const EdgeInsets.only(right: 10), child: Text('introduction_title'.tr, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w600, height: 1.2 )), ), const SizedBox(height: 10), SizedBox( width: 350, child: Text('introduction_text'.tr, textAlign: TextAlign.center), ), ], ), const SizedBox(height: 30), // Self-hosting button group SizedBox( width: 400, child: Obx(() => Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), border: Border.all(color: AppTheme.oxford), ), child: Row( children: [ Expanded( child: InkWell( onTap: () => controller.toggleSelfHosted(false), child: Container( decoration: BoxDecoration( color: !controller.isSelfHosted.value ? AppTheme.oxford : Colors.white, borderRadius: const BorderRadius.only( topLeft: Radius.circular(9), bottomLeft: Radius.circular(9), ), ), padding: const EdgeInsets.symmetric(vertical: 12), child: Center( child: Text( 'cloud_hosted'.tr, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: !controller.isSelfHosted.value ? Colors.white : AppTheme.oxford, ), ), ), ), ), ), Container( width: 1, color: AppTheme.oxford, ), Expanded( child: InkWell( onTap: () => controller.toggleSelfHosted(true), child: Container( decoration: BoxDecoration( color: controller.isSelfHosted.value ? AppTheme.oxford : Colors.white, borderRadius: const BorderRadius.only( topRight: Radius.circular(9), bottomRight: Radius.circular(9), ), ), padding: const EdgeInsets.symmetric(vertical: 12), child: Center( child: Text( 'self_hosted'.tr, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: controller.isSelfHosted.value ? Colors.white : AppTheme.oxford, ), ), ), ), ), ), ], ), )), ), const SizedBox(height: 15), // Self-hosted URL input Obx(() => controller.isSelfHosted.value ? SizedBox( width: 400, child: TextField( decoration: InputDecoration( labelText: 'self_hosted_url'.tr, hintText: 'url_hint'.tr, border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.oxford), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.oxford), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.oxford), ), ), onChanged: controller.urlChanged, ), ) : const SizedBox.shrink()), const SizedBox(height: 20), SizedBox( width: 400, child: Text('enter_connect_code'.tr, textAlign: TextAlign.start), ), const SizedBox(height: 15), SizedBox( width: 400, child: Directionality( textDirection: TextDirection.ltr, child: Pinput( length: 6, onChanged: controller.codeChanged, defaultPinTheme: defaultPinTheme, mainAxisAlignment: MainAxisAlignment.center, separatorBuilder: (index) => const SizedBox(width: 8), hapticFeedbackType: HapticFeedbackType.lightImpact, ), ), ), const SizedBox(height: 30), SizedBox( width: 400, child: Obx(() => ElevatedButton( onPressed: controller.submitActive.value ? controller.submit : null, child: controller.loading.value ? const Spinner(size: 20) : Text('connect_to_account'.tr), )), ), const SizedBox(height: 40), // Connect code explanation SizedBox( width: 350, child: Text( 'connect_code_explanation'.tr, textAlign: TextAlign.center, style: const TextStyle( fontSize: 12, color: Colors.grey, ), ), ), ] ), ), ) ), ); } } ================================================ FILE: app/lib/pages/splash_page.dart ================================================ import 'package:flutter/material.dart'; import 'package:spacepad/components/spinner.dart'; import 'package:spacepad/services/auth_service.dart'; import 'package:spacepad/theme.dart'; class SplashPage extends StatefulWidget { const SplashPage({super.key}); @override State createState() => _SplashPageState(); } class _SplashPageState extends State { @override void initState() { AuthService.instance.verify(); super.initState(); } @override Widget build(BuildContext context) { return const Scaffold( body: Center( child: Spinner(size: 40, thickness: 4, color: AppTheme.platinum), ), ); } } ================================================ FILE: app/lib/services/api_service.dart ================================================ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart'; import 'package:get/get.dart' as GetX; import 'package:http/http.dart' as http; import 'package:spacepad/exceptions/api_exception.dart'; import 'package:spacepad/services/auth_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; class ApiService { ApiService._(); static Future setBaseUrl(String apiUrl) async { var sharedPrefs = await SharedPreferences.getInstance(); return sharedPrefs.setString('api_url', apiUrl); } static Future resetToServerBaseUrl() async { var apiUrl = dotenv.env['API_URL'] ?? 'https://app.spacepad.io'; return await setBaseUrl(apiUrl); } static Future getBaseUrl() async { var sharedPrefs = await SharedPreferences.getInstance(); var apiUrl = sharedPrefs.getString('api_url'); return '$apiUrl/api/'; } static Future get(String endpoint) async { var baseUrl = await getBaseUrl(); if (kDebugMode) print('GET: $baseUrl$endpoint'); try { Response response = await http.get(Uri.parse('$baseUrl$endpoint'), headers: _getHeaders()); if (response.statusCode == 200) { return jsonDecode(response.body); } throw ApiException.fromResponse(response); } on ApiException catch (e) { if (kDebugMode) print('${e.code}: ${e.message}'); if (e.code == 401) { AuthService.instance.signOut(); return; } rethrow; } } static Future post(String endpoint, Map body) async { var baseUrl = await getBaseUrl(); if (kDebugMode) print('POST: $baseUrl$endpoint'); try { Response response = await http.post( Uri.parse('$baseUrl$endpoint'), headers: _getHeaders(), body: jsonEncode(body) ); if ([200, 201, 202, 204].contains(response.statusCode)) { return jsonDecode(response.body); } throw ApiException.fromResponse(response); } on ApiException catch (e) { if (kDebugMode) print('${e.code}: ${e.message}'); rethrow; } } static Future put(String endpoint, Map body) async { var baseUrl = await getBaseUrl(); if (kDebugMode) print('PUT: $baseUrl$endpoint'); try { Response response = await http.put( Uri.parse('$baseUrl$endpoint'), headers: _getHeaders(), body: jsonEncode(body) ); if ([200, 201, 202, 204].contains(response.statusCode)) { return jsonDecode(response.body); } throw ApiException.fromResponse(response); } on ApiException catch (e) { if (kDebugMode) print('${e.code}: ${e.message}'); rethrow; } } static Future delete(String endpoint, [Map? body]) async { var baseUrl = await getBaseUrl(); if (kDebugMode) print('DELETE: $baseUrl$endpoint'); try { Response response = await http.delete( Uri.parse('$baseUrl$endpoint'), headers: _getHeaders(), body: jsonEncode(body) ); if (response.statusCode == 204) { return; } if ([200, 201, 202].contains(response.statusCode)) { return jsonDecode(response.body); } throw ApiException.fromResponse(response); } on ApiException catch (e) { if (kDebugMode) print('${e.code}: ${e.message}'); rethrow; } } static Map? _getHeaders() { Map headers = { 'Content-Type' : 'application/json', 'Accept' : 'application/json', 'Accept-Language' : GetX.Get.locale?.languageCode ?? 'en' }; if (AuthService.instance.getAuthToken() != null) { headers['Authorization'] = 'Bearer ${AuthService.instance.getAuthToken()}'; } return headers; } } ================================================ FILE: app/lib/services/auth_service.dart ================================================ import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spacepad/models/device_model.dart'; import 'package:spacepad/pages/dashboard_page.dart'; import 'package:spacepad/pages/display_page.dart'; import 'package:spacepad/pages/login_page.dart'; import 'package:spacepad/services/api_service.dart'; class AuthService { AuthService._(); static final AuthService instance = AuthService._(); late SharedPreferences _sharedPrefs; Rxn currentDevice = Rxn(); Future initialise() async { _sharedPrefs = await SharedPreferences.getInstance(); } Future setBaseUrl(String url) async { await ApiService.setBaseUrl(url); } Future login(String code, String uid, String name) async { Map result = await ApiService.post('auth/login', { "code" : code, "uid" : uid, "name" : name, }); Map data = result['data']; await setAuthToken(data['token']); currentDevice.value = DeviceModel.fromJson(data['device']); await Get.offAll(const DisplayPage()); } Future verify() async { try { Map result = await ApiService.get('devices/me'); Map data = result['data']; currentDevice.value = DeviceModel.fromJson(data); final displayId = getCurrentDisplayId(); await Get.offAll(() => displayId != null ? const DashboardPage() : const DisplayPage() ); } catch(e) { if (kDebugMode) print(e); } signOut(); } Future changeDisplay(Map body) async { Map result = await ApiService.put('devices/display', body); Map data = result['data']; currentDevice.value = DeviceModel.fromJson(data); } Future signOut() async { currentDevice.value = null; await deleteAuthToken(); await removeCurrentDisplayId(); await Get.offAll(() => const LoginPage()); } String? getAuthToken() { return _sharedPrefs.getString('token'); } Future setAuthToken(String token) { return _sharedPrefs.setString('token', token); } Future deleteAuthToken() { return _sharedPrefs.remove('token'); } String? getCurrentDisplayId() { return _sharedPrefs.getString('display_id'); } Future setCurrentDisplayId(String displayId) { return _sharedPrefs.setString('display_id', displayId); } Future removeCurrentDisplayId() { return _sharedPrefs.remove('display_id'); } } ================================================ FILE: app/lib/services/device_service.dart ================================================ import 'package:spacepad/services/api_service.dart'; class DeviceService { DeviceService._(); static final DeviceService instance = DeviceService._(); Future changeDisplay(String displayId) async { await ApiService.put('devices/display', { "display_id" : displayId, }); } } ================================================ FILE: app/lib/services/display_service.dart ================================================ import 'package:spacepad/models/display_model.dart'; import 'package:spacepad/models/display_data_model.dart'; import 'package:spacepad/services/api_service.dart'; class DisplayService { DisplayService._(); static final DisplayService instance = DisplayService._(); Future> getDisplays() async { Map body = await ApiService.get('displays'); List data = body['data'] as List; return data.map((e) => DisplayModel.fromJson(e)).toList(); } Future book(String displayId, int duration, {String? summary}) async { await ApiService.post('displays/$displayId/book', { 'duration': duration, if (summary != null) 'summary': summary, }); } Future bookCustom(String displayId, String title, DateTime startTime, DateTime endTime) async { // Convert local DateTime to UTC before sending to backend await ApiService.post('displays/$displayId/book', { 'start': startTime.toUtc().toIso8601String(), 'end': endTime.toUtc().toIso8601String(), 'summary': title, }); } Future getDisplayData(String displayId) async { try { return await _getDisplayDataNew(displayId); } catch (e) { if (_isRouteNotFoundError(e)) { return _getDisplayDataOld(displayId); } rethrow; } } Future _getDisplayDataNew(String displayId) async { Map body = await ApiService.get('displays/$displayId/data'); Map data = Map.from(body['data']); return DisplayDataModel.fromJson(data); } Future _getDisplayDataOld(String displayId) async { Map body = await ApiService.get('events'); List data = body['data'] as List; return DisplayDataModel.fromEventsJson(data); } bool _isRouteNotFoundError(dynamic e) { // Check if the error is a 404 or similar return e.toString().contains('404'); } Future cancelEvent(String displayId, String eventId) async { await ApiService.delete('displays/$displayId/events/$eventId'); } Future checkInToEvent(String displayId, String eventId) async { await ApiService.post('displays/$displayId/events/$eventId/check-in', {}); } } ================================================ FILE: app/lib/services/font_service.dart ================================================ import 'package:google_fonts/google_fonts.dart'; import 'package:flutter/material.dart'; class FontService { FontService._(); static final FontService instance = FontService._(); // Available fonts static const List availableFonts = [ 'Inter', 'Roboto', 'Open Sans', 'Lato', 'Poppins', 'Montserrat', ]; // Font family mapping static const Map fontFamilyMapping = { 'Inter': 'Inter', 'Roboto': 'Roboto', 'Open Sans': 'OpenSans', 'Lato': 'Lato', 'Poppins': 'Poppins', 'Montserrat': 'Montserrat', }; /// Get TextStyle for a specific font family TextStyle getTextStyle({ required String fontFamily, double? fontSize, FontWeight? fontWeight, Color? color, double? letterSpacing, double? height, }) { final googleFontFamily = fontFamilyMapping[fontFamily] ?? 'Inter'; switch (googleFontFamily) { case 'Inter': return GoogleFonts.inter( fontSize: fontSize, fontWeight: fontWeight, color: color, letterSpacing: letterSpacing, height: height, ); case 'Roboto': return GoogleFonts.roboto( fontSize: fontSize, fontWeight: fontWeight, color: color, letterSpacing: letterSpacing, height: height, ); case 'OpenSans': return GoogleFonts.openSans( fontSize: fontSize, fontWeight: fontWeight, color: color, letterSpacing: letterSpacing, height: height, ); case 'Lato': return GoogleFonts.lato( fontSize: fontSize, fontWeight: fontWeight, color: color, letterSpacing: letterSpacing, height: height, ); case 'Poppins': return GoogleFonts.poppins( fontSize: fontSize, fontWeight: fontWeight, color: color, letterSpacing: letterSpacing, height: height, ); case 'Montserrat': return GoogleFonts.montserrat( fontSize: fontSize, fontWeight: fontWeight, color: color, letterSpacing: letterSpacing, height: height, ); default: return GoogleFonts.inter( fontSize: fontSize, fontWeight: fontWeight, color: color, letterSpacing: letterSpacing, height: height, ); } } /// Preload fonts to avoid loading delays Future preloadFonts() async { for (final fontFamily in availableFonts) { final googleFontFamily = fontFamilyMapping[fontFamily] ?? 'Inter'; try { switch (googleFontFamily) { case 'Inter': await GoogleFonts.pendingFonts([GoogleFonts.inter()]); break; case 'Roboto': await GoogleFonts.pendingFonts([GoogleFonts.roboto()]); break; case 'OpenSans': await GoogleFonts.pendingFonts([GoogleFonts.openSans()]); break; case 'Lato': await GoogleFonts.pendingFonts([GoogleFonts.lato()]); break; case 'Poppins': await GoogleFonts.pendingFonts([GoogleFonts.poppins()]); break; case 'Montserrat': await GoogleFonts.pendingFonts([GoogleFonts.montserrat()]); break; } } catch (e) { // Font loading failed, continue with others print('Failed to load font $fontFamily: $e'); } } } /// Force reload a specific font Future reloadFont(String fontFamily) async { final googleFontFamily = fontFamilyMapping[fontFamily] ?? 'Inter'; try { print('FontService: Reloading font: $fontFamily (mapped to: $googleFontFamily)'); switch (googleFontFamily) { case 'Inter': await GoogleFonts.pendingFonts([GoogleFonts.inter()]); break; case 'Roboto': await GoogleFonts.pendingFonts([GoogleFonts.roboto()]); break; case 'OpenSans': await GoogleFonts.pendingFonts([GoogleFonts.openSans()]); break; case 'Lato': await GoogleFonts.pendingFonts([GoogleFonts.lato()]); break; case 'Poppins': await GoogleFonts.pendingFonts([GoogleFonts.poppins()]); break; case 'Montserrat': await GoogleFonts.pendingFonts([GoogleFonts.montserrat()]); break; } } catch (e) { print('Failed to reload font $fontFamily: $e'); } } /// Get font display name for UI String getFontDisplayName(String fontFamily) { return fontFamily; } /// Validate if font is available bool isFontAvailable(String fontFamily) { return availableFonts.contains(fontFamily); } } ================================================ FILE: app/lib/services/server_service.dart ================================================ import 'package:http/http.dart' as http; class ServerService { static final ServerService _instance = ServerService._internal(); factory ServerService() => _instance; ServerService._internal(); /// Checks if a server is reachable by making a GET request to its health endpoint /// Returns true if the server responds with a 200 status code within 5 seconds Future isServerReachable(String url) async { try { final response = await http.get( Uri.parse('$url/health'), headers: {'Accept': 'application/json'}, ).timeout(const Duration(seconds: 5)); return response.statusCode == 200; } catch (e) { return false; } } } ================================================ FILE: app/lib/theme.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; class AppTheme { AppTheme._(); static const Color black = Color(0xFF000000); static const Color oxford = Color(0xFF14213D); static const Color orange = Color(0xFFFCA311); static const Color platinum = Color(0xFFE5E5E5); static const Color white = Color(0xFFFFFFFF); static ThemeData get data { return ThemeData( textTheme: GoogleFonts.interTextTheme(), scaffoldBackgroundColor: Colors.white, colorScheme: ColorScheme.fromSeed(seedColor: oxford), inputDecorationTheme: InputDecorationTheme( hintStyle: const TextStyle(fontSize: 14.5), filled: true, fillColor: Colors.white, isDense: true, isCollapsed: true, contentPadding: const EdgeInsets.only( top: 14, bottom: 10, left: 20, right: 20 ), outlineBorder: const BorderSide(color: oxford, width: 1), border: OutlineInputBorder( borderSide: const BorderSide(color: oxford, width: 1), borderRadius: BorderRadius.circular(99), ), errorBorder: OutlineInputBorder( borderSide: const BorderSide(color: oxford, width: 1), borderRadius: BorderRadius.circular(99), ), focusedBorder: OutlineInputBorder( borderSide: const BorderSide(color: oxford, width: 1), borderRadius: BorderRadius.circular(99), ), focusedErrorBorder: OutlineInputBorder( borderSide: const BorderSide(color: oxford, width: 1), borderRadius: BorderRadius.circular(99), ), disabledBorder: OutlineInputBorder( borderSide: const BorderSide(color: oxford, width: 1), borderRadius: BorderRadius.circular(99), ), enabledBorder: OutlineInputBorder( borderSide: const BorderSide(color: oxford, width: 1), borderRadius: BorderRadius.circular(99), ), ), dividerColor: Colors.grey.shade300, dividerTheme: DividerThemeData( color: Colors.grey.shade300, thickness: .5, ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( foregroundColor: Colors.black, backgroundColor: orange, textStyle: const TextStyle( fontSize: 15, fontWeight: FontWeight.w500, ), elevation: 0, shadowColor: Colors.transparent, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15) ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( foregroundColor: Colors.black, textStyle: const TextStyle( fontSize: 15, fontWeight: FontWeight.w500, ), elevation: 0, shadowColor: Colors.transparent, padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), side: const BorderSide(color: oxford, width: 1.5), // Border color and width ), ), appBarTheme: const AppBarTheme( systemOverlayStyle: SystemUiOverlayStyle.light, foregroundColor: Colors.white, titleTextStyle: TextStyle( fontWeight: FontWeight.w600, fontSize: 24, height: 0.5 ) ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( foregroundColor: Colors.black, textStyle: const TextStyle( fontSize: 14, fontWeight: FontWeight.w400, ), ) ), floatingActionButtonTheme: FloatingActionButtonThemeData( extendedPadding: const EdgeInsets.symmetric(horizontal: 25, vertical: 0), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(100) ), ), ); } } ================================================ FILE: app/lib/translations/translations.dart ================================================ import 'package:get/get.dart'; class AppTranslations extends Translations { @override Map> get keys => { 'en_US': { 'introduction_title': 'Welcome to Spacepad!', 'introduction_text': 'Get started by connecting your device with your room.', 'connect_to_account': 'Connect to your account', 'enter_connect_code': 'Please enter the connect code', 'choose_display': 'Choose display', 'choose_room_display': 'What room would you like to display on this device?', 'select_display': 'Select a display', 'please_select_display': 'Please select a display.', 'continue': 'Continue', 'next': 'Next', 'next_event_title': '@start - @end, @summary', 'meeting_room': 'Meeting room', 'meeting_info_title': '@start - @end', 'available': 'All yours!', 'to_be_reserved': 'Make it quick!', 'till_end_of_day': 'Till end of day', 'x_minutes_left': '@minutes min left', 'x_hours_x_minutes_left': '@hours h @minutes min left', 'for_x_minutes': 'for @minutes min', 'for_x_hours_x_minutes': 'for @hours h @minutes min', 'could_not_load_events': 'Could not load events', 'could_not_load_displays': 'Could not load displays', 'could_not_load_data': 'Could not load data', 'book_now': 'Book now', 'custom': 'Custom', 'cancel': 'Cancel', 'close': 'Close', 'room_booked': 'Room booked!', 'could_not_book_room': 'Could not book room', 'logout': 'Log out', 'switch_room': 'Switch room', 'cancel_event': 'Cancel', 'event_cancelled': 'Event cancelled', 'could_not_cancel_event': 'Could not cancel event', 'url_hint': 'https://your-instance.com', 'check_connection': 'An unexpected error arisen. Please check if you have an internet connection', 'code_incorrect': 'Your code is incorrect. Please refresh your dashboard to acquire the most recent code', 'self_hosted': 'Self-hosted', 'cloud_hosted': 'Cloud-hosted', 'self_hosted_url': 'Self-hosted instance URL', 'connect_code_explanation': 'To get a connect code, you need to:\n1. Sign up at app.spacepad.io/register, or\n2. Set up your own cloud-hosted environment, read: spacepad.io\nThen go to your dashboard to get the connect code.', 'invalid_url': 'Please enter a valid URL (e.g., https://your-instance.com)', 'server_unreachable': 'Could not connect to the server. Please check if the URL is correct and the server is running.', 'login_failed': 'Your connect code is incorrect. Please try again.', 'reserved': 'Reserved', 'reserve': 'Reserve', 'check_in_now': 'Check-in for meeting', 'check_in': 'Check-in', 'x_starts_in_x_minutes': '@meeting starts in @minutes min', 'check_in_within_x_minutes': 'Check in within @minutes min', 'checked_in': 'Successfully checked in!', 'could_not_check_in': 'Could not check in', 'view_schedule': 'View schedule', 'todays_schedule': 'Today\'s schedule', 'no_upcoming_events': 'No upcoming events', 'no_events_today': 'No events scheduled for today', 'meeting': 'Meeting', 'admin_actions_enabled': 'Admin actions enabled for @seconds seconds', 'refresh_data': 'Refresh data', 'display_data_refreshed': 'Display data successfully refreshed. The display should now use the most recent settings and an updated view of the events', 'refresh_cooldown': 'Please wait @seconds seconds before refreshing again', 'custom_booking': 'Custom booking', 'meeting_title': 'Meeting title', 'start_time': 'Start time', 'end_time': 'End time', 'now': 'Now', 'max': 'Max', 'book': 'Book' }, 'nl_NL': { 'introduction_title': 'Welkom bij Spacepad!', 'introduction_text': 'Begin met het verbinden van je scherm met een ruimte.', 'connect_to_account': 'Verbind met je account', 'enter_connect_code': 'Voer de connect code in', 'choose_display': 'Kies scherm', 'choose_room_display': 'Welke ruimte wil je weergeven op dit apparaat?', 'select_display': 'Selecteer een scherm', 'please_select_display': 'Selecteer a.u.b. een scherm.', 'continue': 'Ga door', 'next': 'Volgende', 'next_event_title': '@start - @end uur, @summary', 'meeting_room': 'Meetingruimte', 'meeting_info_title': '@start - @end uur', 'available': 'Helemaal van jou!', 'to_be_reserved': 'Houd het kort!', 'till_end_of_day': 'Tot het einde van de dag', 'x_minutes_left': 'nog @minutes min', 'x_hours_x_minutes_left': 'nog @hours uur @minutes min', 'for_x_minutes': 'voor @minutes min', 'for_x_hours_x_minutes': 'voor @hours uur @minutes min', 'could_not_load_events': 'Het laden van afspraken is niet gelukt', 'could_not_load_displays': 'Het laden van schermen is niet gelukt', 'could_not_load_data': 'Het laden van gegevens is niet gelukt', 'book_now': 'Nu boeken', 'custom': 'Aangepast', 'cancel': 'Annuleren', 'close': 'Sluiten', 'room_booked': 'Ruimte geboekt!', 'could_not_book_room': 'Kon ruimte niet boeken', 'logout': 'Uitloggen', 'switch_room': 'Wissel ruimte', 'cancel_event': 'Annuleren', 'event_cancelled': 'Afspraak geannuleerd', 'could_not_cancel_event': 'Kon afspraak niet annuleren', 'url_hint': 'https://jouw-instance.nl', 'check_connection': 'Er is een onbekende fout opgetreden. Check of je een actieve internetverbinding hebt.', 'code_incorrect': 'Je code is incorrect. Refresh je dashboard om de nieuwste code op te halen', 'self_hosted': 'Eigen hosting', 'cloud_hosted': 'Cloud hosting', 'self_hosted_url': 'URL van eigen instance', 'connect_code_explanation': 'Om een connect code te krijgen moet je:\n1. Je registreren op app.spacepad.io/register, of\n2. Je eigen cloud-hosted omgeving opzetten, zie: spacepad.io\nGa daarna naar je dashboard om de connect code te krijgen.', 'invalid_url': 'Voer een geldige URL in (bijvoorbeeld https://jouw-instance.nl)', 'server_unreachable': 'Kon geen verbinding maken met de server. Controleer of de URL correct is en de server draait.', 'login_failed': 'Je connect code is incorrect. Probeer het opnieuw.', 'reserved': 'Gereserveerd', 'reserve': 'Reserveer', 'check_in_now': 'Check in voor meeting', 'check_in': 'Inchecken', 'x_starts_in_x_minutes': '@meeting start in @minutes min', 'check_in_within_x_minutes': 'Check in binnen @minutes min', 'checked_in': 'Succesvol ingecheckt!', 'could_not_check_in': 'Kon niet inchecken', 'view_schedule': 'Bekijk dagplanning', 'todays_schedule': 'Dagplanning', 'no_upcoming_events': 'Geen toekomstige afspraken', 'no_events_today': 'Geen afspraken gepland voor vandaag', 'meeting': 'Vergadering', 'admin_actions_enabled': 'Beheeracties ingeschakeld voor @seconds seconden', 'refresh_data': 'Gegevens vernieuwen', 'display_data_refreshed': 'Schermgegevens succesvol vernieuwd. Het scherm zou nu de meest recente instellingen en een bijgewerkt overzicht van de gebeurtenissen moeten gebruiken', 'refresh_cooldown': 'Wacht a.u.b. @seconds seconden voordat u opnieuw vernieuwt', 'custom_booking': 'Aangepaste boeking', 'meeting_title': 'Vergaderingstitel', 'start_time': 'Starttijd', 'end_time': 'Eindtijd', 'now': 'Nu', 'max': 'Max', 'book': 'Boek' }, 'es_ES': { 'introduction_title': '¡Bienvenido a Spacepad!', 'introduction_text': 'Comienza conectando tu dispositivo con tu sala.', 'connect_to_account': 'Conéctate a tu cuenta', 'enter_connect_code': 'Por favor, introduce el código de conexión', 'choose_display': 'Elegir pantalla', 'choose_room_display': '¿Qué sala te gustaría mostrar en este dispositivo?', 'select_display': 'Seleccionar una pantalla', 'please_select_display': 'Por favor, selecciona una pantalla.', 'continue': 'Continuar', 'next': 'Siguiente', 'next_event_title': '@start - @end, @summary', 'meeting_room': 'Sala de reuniones', 'meeting_info_title': '@start - @end', 'available': 'Todo tuyo!', 'to_be_reserved': 'Hazlo rápido!', 'till_end_of_day': 'Hasta el final del día', 'x_minutes_left': 'quedan @minutes min', 'x_hours_x_minutes_left': 'quedan @hours h @minutes min', 'for_x_minutes': 'por @minutes min', 'for_x_hours_x_minutes': 'por @hours h @minutes min', 'could_not_load_events': 'No se pudieron cargar los eventos', 'could_not_load_displays': 'No se pudieron cargar las pantallas', 'could_not_load_data': 'No se pudieron cargar los datos', 'book_now': 'Reservar ahora', 'custom': 'Personalizado', 'cancel': 'Cancelar', 'close': 'Cerrar', 'room_booked': '¡Sala reservada!', 'could_not_book_room': 'No se pudo reservar la sala', 'logout': 'Cerrar sesión', 'switch_room': 'Cambiar sala', 'cancel_event': 'Cancelar', 'event_cancelled': 'Evento cancelado', 'could_not_cancel_event': 'No se pudo cancelar el evento', 'url_hint': 'https://tu-instancia.com', 'check_connection': 'Ha surgido un error inesperado. Por favor, verifica tu conexión a internet', 'code_incorrect': 'Tu código es incorrecto. Por favor, actualiza tu panel para obtener el código más reciente', 'self_hosted': 'Autoalojado', 'cloud_hosted': 'Alojado en la nube', 'self_hosted_url': 'URL de la instancia autoalojada', 'connect_code_explanation': 'Para obtener un código de conexión, necesitas:\n1. Registrarte en app.spacepad.io/register, o\n2. Configurar tu propio entorno en la nube, lee: spacepad.io\nLuego ve a tu panel para obtener el código de conexión.', 'invalid_url': 'Por favor, introduce una URL válida (por ejemplo, https://tu-instancia.com)', 'server_unreachable': 'No se pudo conectar con el servidor. Por favor, verifica que la URL sea correcta y que el servidor esté en funcionamiento.', 'login_failed': 'Tu código de conexión es incorrecto. Por favor, inténtalo de nuevo.', 'reserved': 'Reservado', 'reserve': 'Reservar', 'check_in_now': 'Check in para la reunión', 'check_in': 'Registrar', 'x_starts_in_x_minutes': '@meeting comienza en @minutes min', 'check_in_within_x_minutes': 'Registrar en @minutes min', 'checked_in': '¡Registrado!', 'could_not_check_in': 'No se pudo registrar', 'view_schedule': 'Ver horario', 'todays_schedule': 'Horario de hoy', 'no_upcoming_events': 'No hay eventos próximos', 'no_events_today': 'No hay eventos programados para hoy', 'meeting': 'Reunión', 'admin_actions_enabled': 'Acciones de administrador habilitadas por @seconds segundos', 'refresh_data': 'Actualizar datos', 'display_data_refreshed': 'Datos de la pantalla actualizados correctamente. La pantalla ahora debería usar la configuración más reciente y una vista actualizada de los eventos', 'refresh_cooldown': 'Espere @seconds segundos antes de actualizar nuevamente', 'custom_booking': 'Reserva personalizada', 'meeting_title': 'Título de la reunión', 'start_time': 'Hora de inicio', 'end_time': 'Hora de finalización', 'now': 'Ahora', 'max': 'Máx', 'book': 'Reservar' }, 'fr_FR': { 'introduction_title': 'Bienvenue sur Spacepad !', 'introduction_text': 'Commencez par connecter votre appareil à votre salle.', 'connect_to_account': 'Connectez-vous à votre compte', 'enter_connect_code': 'Veuillez entrer le code de connexion', 'choose_display': 'Choisir un écran', 'choose_room_display': 'Quelle salle souhaitez-vous afficher sur cet appareil ?', 'select_display': 'Sélectionner un écran', 'please_select_display': 'Veuillez sélectionner un écran.', 'continue': 'Continuer', 'next': 'Suivant', 'next_event_title': '@start - @end, @summary', 'meeting_room': 'Salle de réunion', 'meeting_info_title': '@start - @end', 'available': 'À vous!', 'to_be_reserved': 'Faites vite!', 'till_end_of_day': 'Jusqu\'à la fin de la journée', 'x_minutes_left': 'il reste @minutes min', 'x_hours_x_minutes_left': 'il reste @hours h @minutes min', 'for_x_minutes': 'pour @minutes min', 'for_x_hours_x_minutes': 'pour @hours h @minutes min', 'could_not_load_events': 'Impossible de charger les événements', 'could_not_load_displays': 'Impossible de charger les écrans', 'could_not_load_data': 'Impossible de charger les données', 'book_now': 'Réserver maintenant', 'custom': 'Personnalisé', 'cancel': 'Annuler', 'close': 'Fermer', 'room_booked': 'Salle réservée !', 'could_not_book_room': 'Impossible de réserver la salle', 'logout': 'Se déconnecter', 'switch_room': 'Changer de salle', 'cancel_event': 'Annuler', 'event_cancelled': 'Événement annulé', 'could_not_cancel_event': 'Impossible d\'annuler l\'événement', 'url_hint': 'https://votre-instance.com', 'check_connection': 'Une erreur inattendue est survenue. Veuillez vérifier votre connexion internet', 'code_incorrect': 'Votre code est incorrect. Veuillez actualiser votre tableau de bord pour obtenir le code le plus récent', 'self_hosted': 'Auto-hébergé', 'cloud_hosted': 'Hébergé sur le cloud', 'self_hosted_url': 'URL de l\'instance auto-hébergée', 'connect_code_explanation': 'Pour obtenir un code de connexion, vous devez :\n1. Vous inscrire sur app.spacepad.io/register, ou\n2. Configurer votre propre environnement cloud, voir : spacepad.io\nEnsuite, allez sur votre tableau de bord pour obtenir le code de connexion.', 'invalid_url': 'Veuillez entrer une URL valide (par exemple, https://votre-instance.com)', 'server_unreachable': 'Impossible de se connecter au serveur. Veuillez vérifier que l\'URL est correcte et que le serveur fonctionne.', 'login_failed': 'Votre code de connexion est incorrect. Veuillez réessayer.', 'reserved': 'Réservé', 'reserve': 'Réserver', 'check_in_now': 'Enregistrez-vous pour la réunion', 'check_in': 'Enregistrer', 'x_starts_in_x_minutes': '@meeting commence dans @minutes min', 'check_in_within_x_minutes': 'Enregistrez-vous dans @minutes min', 'checked_in': 'Enregistré !', 'could_not_check_in': 'Impossible de s\'enregistrer', 'view_schedule': 'Voir l\'horaire', 'todays_schedule': 'Horaire d\'aujourd\'hui', 'no_upcoming_events': 'Aucun événement à venir', 'no_events_today': 'Aucun événement programmé pour aujourd\'hui', 'meeting': 'Réunion', 'admin_actions_enabled': 'Actions d\'administration activées pendant @seconds secondes', 'refresh_data': 'Actualiser les données', 'display_data_refreshed': 'Données de l\'écran actualisées avec succès. L\'écran devrait maintenant utiliser les paramètres les plus récents et une vue mise à jour des événements', 'refresh_cooldown': 'Veuillez attendre @seconds secondes avant d\'actualiser à nouveau', 'custom_booking': 'Réservation personnalisée', 'meeting_title': 'Titre de la réunion', 'start_time': 'Heure de début', 'end_time': 'Heure de fin', 'now': 'Maintenant', 'max': 'Max', 'book': 'Réserver' }, 'de_DE': { 'introduction_title': 'Willkommen bei Spacepad!', 'introduction_text': 'Beginnen sie, indem sie ihr gerät mit ihrem raum verbinden.', 'connect_to_account': 'Mit ihrem konto verbinden', 'enter_connect_code': 'Bitte geben sie den verbindungscode ein', 'choose_display': 'Anzeige auswählen', 'choose_room_display': 'Welchen raum möchten sie auf diesem gerät anzeigen?', 'select_display': 'Anzeige auswählen', 'please_select_display': 'Bitte wählen sie eine anzeige aus.', 'continue': 'Weiter', 'next': 'Nächste', 'next_event_title': '@start - @end, @summary', 'meeting_room': 'Besprechungsraum', 'meeting_info_title': '@start - @end', 'available': 'Ganz für sie!', 'to_be_reserved': 'Bitte beeilen!', 'till_end_of_day': 'Bis zum ende des tages', 'x_minutes_left': 'noch @minutes min.', 'x_hours_x_minutes_left': 'noch @hours std. @minutes min.', 'for_x_minutes': 'für @minutes min.', 'for_x_hours_x_minutes': 'für @hours std. @minutes min.', 'could_not_load_events': 'Ereignisse konnten nicht geladen werden', 'could_not_load_displays': 'Anzeigen konnten nicht geladen werden', 'could_not_load_data': 'Daten konnten nicht geladen werden', 'book_now': 'Jetzt buchen', 'custom': 'Benutzerdefiniert', 'cancel': 'Abbrechen', 'close': 'Schließen', 'room_booked': 'Raum gebucht!', 'could_not_book_room': 'Raum konnte nicht gebucht werden', 'logout': 'Abmelden', 'switch_room': 'Raum wechseln', 'cancel_event': 'Stornieren', 'event_cancelled': 'Termin storniert', 'could_not_cancel_event': 'Termin konnte nicht storniert werden', 'url_hint': 'https://ihre-instanz.com', 'check_connection': 'Ein unerwarteter fehler ist aufgetreten. Bitte überprüfen sie ihre internetverbindung', 'code_incorrect': 'Ihr code ist falsch. Bitte aktualisieren sie ihre dashboard, um den neuesten code zu erhalten', 'self_hosted': 'Selbst gehostet', 'cloud_hosted': 'Cloud-gehostet', 'self_hosted_url': 'URL der selbst gehosteten instanz', 'connect_code_explanation': 'Um einen verbindungscode zu erhalten, müssen sie:\n1. Sich registrieren unter app.spacepad.io/register, oder\n2. Ihre eigene cloud-umgebung einrichten, siehe: spacepad.io\nGehen sie dann zu ihrem dashboard, um den verbindungscode zu erhalten.', 'invalid_url': 'Bitte geben sie eine gültige URL ein (z.B. https://ihre-instanz.com)', 'server_unreachable': 'Verbindung zum server konnte nicht hergestellt werden. Bitte überprüfen sie, ob die URL korrekt ist und der server läuft.', 'login_failed': 'Ihr verbindungscode ist falsch. Bitte versuchen sie es erneut.', 'reserved': 'Reserviert', 'reserve': 'Reservieren', 'check_in_now': 'Check-in für meeting', 'check_in': 'Einchecken', 'x_starts_in_x_minutes': '@meeting startet in @minutes min', 'check_in_within_x_minutes': 'Check-in in @minutes min', 'checked_in': 'Erfolgreich eingecheckt!', 'could_not_check_in': 'Konnte nicht einchecken', 'view_schedule': 'Zeitplan anzeigen', 'todays_schedule': 'Heutiger zeitplan', 'no_upcoming_events': 'Keine anstehenden termine', 'no_events_today': 'Keine termine für heute geplant', 'meeting': 'Besprechung', 'admin_actions_enabled': 'Administratoraktionen für @seconds Sekunden aktiviert', 'refresh_data': 'Daten aktualisieren', 'display_data_refreshed': 'Anzeigedaten erfolgreich aktualisiert. Die Anzeige sollte nun die neuesten Einstellungen und eine aktualisierte Ansicht der Ereignisse verwenden', 'refresh_cooldown': 'Bitte warten Sie @seconds Sekunden, bevor Sie erneut aktualisieren', 'custom_booking': 'Benutzerdefinierte Buchung', 'meeting_title': 'Besprechungstitel', 'start_time': 'Startzeit', 'end_time': 'Endzeit', 'now': 'Jetzt', 'max': 'Max', 'book': 'Buchen' }, 'sv_SE': { 'introduction_title': 'Välkommen till Spacepad!', 'introduction_text': 'Kom igång genom att ansluta din enhet med ditt rum.', 'connect_to_account': 'Anslut till ditt konto', 'enter_connect_code': 'Vänligen ange anslutningskoden', 'choose_display': 'Välj skärm', 'choose_room_display': 'Vilket rum skulle du vilja visa på denna enhet?', 'select_display': 'Välj en skärm', 'please_select_display': 'Vänligen välj en skärm.', 'continue': 'Fortsätt', 'next': 'Nästa', 'next_event_title': '@start - @end, @summary', 'meeting_room': 'Mötesrum', 'meeting_info_title': '@start - @end', 'available': 'Helt ditt!', 'to_be_reserved': 'Gör det snabbt!', 'till_end_of_day': 'Till slutet av dagen', 'x_minutes_left': '@minutes min kvar', 'x_hours_x_minutes_left': '@hours tim @minutes min kvar', 'for_x_minutes': 'i @minutes min', 'for_x_hours_x_minutes': 'i @hours tim @minutes min', 'could_not_load_events': 'Kunde inte ladda händelser', 'could_not_load_displays': 'Kunde inte ladda skärmar', 'could_not_load_data': 'Kunde inte ladda data', 'book_now': 'Boka nu', 'custom': 'Anpassad', 'cancel': 'Avbryt', 'close': 'Stäng', 'room_booked': 'Rum bokat!', 'could_not_book_room': 'Kunde inte boka rum', 'logout': 'Logga ut', 'switch_room': 'Byt rum', 'cancel_event': 'Avbryt', 'event_cancelled': 'Händelse avbruten', 'could_not_cancel_event': 'Kunde inte avbryta händelse', 'url_hint': 'https://din-instans.se', 'check_connection': 'Ett oväntat fel uppstod. Vänligen kontrollera att du har en internetanslutning', 'code_incorrect': 'Din kod är felaktig. Vänligen uppdatera din dashboard för att få den senaste koden', 'self_hosted': 'Självhostad', 'cloud_hosted': 'Molnhostad', 'self_hosted_url': 'URL för självhostad instans', 'connect_code_explanation': 'För att få en anslutningskod behöver du:\n1. Registrera dig på app.spacepad.io/register, eller\n2. Sätt upp din egen molnhostade miljö, läs: spacepad.io\nGå sedan till din dashboard för att få anslutningskoden.', 'invalid_url': 'Vänligen ange en giltig URL (t.ex. https://din-instans.se)', 'server_unreachable': 'Kunde inte ansluta till servern. Vänligen kontrollera att URL:en är korrekt och att servern körs.', 'login_failed': 'Din anslutningskod är felaktig. Vänligen försök igen.', 'reserved': 'Reserverad', 'reserve': 'Reservera', 'check_in_now': 'Checka in för möte', 'check_in': 'Checka in', 'x_starts_in_x_minutes': '@meeting börjar om @minutes min', 'check_in_within_x_minutes': 'Checka in inom @minutes min', 'checked_in': 'Framgångsrikt incheckad!', 'could_not_check_in': 'Kunde inte checka in', 'view_schedule': 'Visa schema', 'todays_schedule': 'Dagens schema', 'no_upcoming_events': 'Inga kommande händelser', 'no_events_today': 'Inga händelser schemalagda för idag', 'meeting': 'Möte', 'admin_actions_enabled': 'Administratörsåtgärder aktiverade i @seconds sekunder', 'refresh_data': 'Uppdatera data', 'display_data_refreshed': 'Skärmdata uppdaterades framgångsrikt. Skärmen bör nu använda de senaste inställningarna och en uppdaterad vy över händelserna', 'refresh_cooldown': 'Vänta @seconds sekunder innan du uppdaterar igen', 'custom_booking': 'Anpassad bokning', 'meeting_title': 'Mötetitel', 'start_time': 'Starttid', 'end_time': 'Sluttid', 'now': 'Nu', 'max': 'Max', 'book': 'Boka' }, }; } ================================================ FILE: app/pubspec.yaml ================================================ name: spacepad description: "A simple and privacy-focused meeting room display." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 # followed by an optional build number separated by a +. # Both the version and the builder number may be overridden in flutter # build by specifying --build-name and --build-number, respectively. # In Android, build-name is used as versionName while build-number used as versionCode. # Read more about Android versioning at https://developer.android.com/studio/publish/versioning # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. version: 1.4.0+14 environment: sdk: ^3.6.0 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions # consider running `flutter pub upgrade --major-versions`. Alternatively, # dependencies can be manually updated by changing the version numbers below to # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 intl: ^0.19.0 tailwind_components: ^0.0.50 get: ^4.6.6 http: ^1.2.2 shared_preferences: ^2.3.5 flutter_dotenv: ^5.2.1 pinput: ^5.0.0 heroicons: ^0.11.0 device_info_plus: ^11.2.0 flutter_svg: ^2.0.17 wakelock_plus: ^1.2.10 google_fonts: ^6.2.1 dropdown_button2: ^2.3.9 marquee: ^2.3.0 timezone: ^0.10.0 flutter_udid: ^4.0.0 calendar_view: ^1.4.0 dev_dependencies: flutter_test: sdk: flutter # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^5.0.0 flutter_launcher_icons: ^0.14.2 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec # The following section is specific to Flutter packages. flutter: # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true # To add assets to your application, add an assets section, like this: assets: - .env - assets/ - assets/fonts/ flutter_launcher_icons: android: "launcher_icon" ios: true image_path: "assets/appicon.jpg" ================================================ FILE: app/test/widget_test.dart ================================================ // This is a basic Flutter widget test. // // To perform an interaction with a widget in your test, use the WidgetTester // utility in the flutter_test package. For example, you can send tap and scroll // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spacepad/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. await tester.pumpWidget(const App()); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget); expect(find.text('1'), findsNothing); // Tap the '+' icon and trigger a frame. await tester.tap(find.byIcon(Icons.add)); await tester.pump(); // Verify that our counter has incremented. expect(find.text('0'), findsNothing); expect(find.text('1'), findsOneWidget); }); } ================================================ FILE: backend/.editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false [*.{yml,yaml}] indent_size = 2 [docker-compose.yml] indent_size = 4 ================================================ FILE: backend/.gitattributes ================================================ * text=auto eol=lf *.blade.php diff=html *.css diff=css *.html diff=html *.md diff=markdown *.php diff=php /.github export-ignore CHANGELOG.md export-ignore .styleci.yml export-ignore ================================================ FILE: backend/.gitignore ================================================ /.phpunit.cache /node_modules /public/build /public/hot /public/storage /storage/*.key /storage/pail /vendor .env .env.backup .env.production .phpactor.json .phpunit.result.cache Homestead.json Homestead.yaml auth.json npm-debug.log yarn-error.log /.fleet /.idea /.nova /.vscode /.zed ================================================ FILE: backend/Dockerfile ================================================ FROM composer:lts AS php_builder WORKDIR /app COPY composer.json composer.lock ./ COPY ./ /app RUN composer install --no-cache --no-dev --no-scripts --no-autoloader --ansi --no-interaction --ignore-platform-reqs \ && composer dump-autoload -o --ignore-platform-reqs --no-scripts # Node.js builder stage FROM node:20-alpine AS node_builder WORKDIR /app # Install pnpm RUN corepack enable && corepack prepare pnpm@latest --activate COPY package.json pnpm-lock.yaml ./ COPY vite.config.js ./ COPY resources ./resources RUN pnpm install && pnpm run build # Set our base image FROM serversideup/php:8.4-unit AS base # Switch to root before installing our PHP extensions USER root RUN install-php-extensions opentelemetry protobuf FROM base AS production # Add build arguments for git information ARG GIT_TAG ARG GIT_COMMIT # Set environment variable with tag or commit hash ENV SPACEPAD_VERSION=${GIT_TAG:-${GIT_COMMIT}} COPY --chown=www-data:www-data . /var/www/html COPY --chown=www-data:www-data --from=php_builder /app/vendor /var/www/html/vendor COPY --chown=www-data:www-data --from=node_builder /app/public/build /var/www/html/public/build USER www-data RUN mkdir -p storage && touch storage/database.sqlite ================================================ FILE: backend/FARO_SETUP.md ================================================ # Grafana Faro Frontend Monitoring Setup This document explains how to configure Grafana Faro Real User Monitoring (RUM) for the Spacepad frontend. ## Overview Grafana Faro collects frontend telemetry data including: - **Web Vitals** (LCP, FID, CLS, FCP, TTFB) - **Page load timing** (DOMContentLoaded, Load, etc.) - **User interactions** (clicks, form submissions) - **All fetch/XHR requests** with full trace context - **Frontend → Backend trace correlation** - **Long tasks** (performance monitoring) - **Errors and exceptions** (sent to Loki) - **Console logs** (errors/warnings sent to Loki) - **Session tracking** ## Configuration ### 1. Enable Faro Add to your `.env` file: ```env FARO_ENABLED=true FARO_COLLECTOR_URL=http://localhost:12347/collect FARO_API_KEY=faro-secret-key FARO_APP_NAME=spacepad FARO_APP_VERSION=1.0.0 FARO_APP_ENV=local ``` ### 2. Grafana Alloy Configuration Ensure Grafana Alloy is running with a FARO receiver configured. The receiver should: - Listen on port `12347` at `/collect` endpoint - Use the same `api_key` as configured in `FARO_API_KEY` - Forward logs to Loki - Forward traces to Tempo - Forward metrics to Prometheus Example Alloy configuration: ```river faro.receiver "faro_receiver" { server { listen_address = "0.0.0.0" listen_port = 12347 cors_allowed_origins = ["*"] // Allow all origins for development api_key = "faro-secret-key" // Must match FARO_API_KEY max_allowed_payload_size = "10MiB" rate_limiting { rate = 100 } } sourcemaps { } output { logs = [loki.process.faro_logs.receiver] traces = [otelcol.processor.batch.batch_processor.input] } } ``` ### 3. Docker Environment If running in Docker, use `host.docker.internal` to reach Grafana Alloy on the host: ```env FARO_COLLECTOR_URL=http://host.docker.internal:12347/collect ``` ## How It Works 1. **Frontend Application** → Sends RUM data via FARO SDK → **Grafana Alloy** (port 12347 `/collect`) 2. **Grafana Alloy** → Processes FARO data → Forwards to: - **Prometheus** (metrics) - **Loki** (logs) - **Tempo** (traces) ## Features The Faro integration automatically captures: - ✅ **Web Vitals** (LCP, FID, CLS, FCP, TTFB) - ✅ **Page load timing** (DOMContentLoaded, Load, etc.) - ✅ **User interactions** (clicks, form submissions) - ✅ **All fetch/XHR requests** with full trace context - ✅ **Frontend → Backend trace correlation** - ✅ **Long tasks** (performance monitoring) - ✅ **Errors and exceptions** (sent to Loki) - ✅ **Console logs** (errors/warnings sent to Loki) - ✅ **Session tracking** ## Viewing Data ### Grafana Dashboard Import the Grafana Faro Frontend Monitoring dashboard (ID: `17766`): 1. Open Grafana at http://localhost:3000 2. Go to Dashboards → Import 3. Enter dashboard ID: `17766` 4. Select Prometheus as the datasource 5. Click "Import" ### Prometheus Queries Query FARO metrics in Prometheus: ```promql # Frontend errors faro_errors_total # Page load metrics faro_page_load_duration_seconds # Web Vitals faro_web_vitals_lcp_seconds faro_web_vitals_fid_seconds faro_web_vitals_cls ``` ### Loki Logs Search for frontend logs in Loki: ```logql {service_name="spacepad"} |= "error" ``` ### Tempo Traces View frontend traces in Tempo: - Search for traces from `spacepad` service - Filter by route or operation - View trace details and spans ## Troubleshooting ### Faro Not Initializing 1. **Check browser console** for initialization errors 2. **Verify configuration** in `.env` file 3. **Check network tab** for requests to `/collect` endpoint 4. **Verify CORS** settings in Grafana Alloy config ### No Data in Grafana 1. **Check Grafana Alloy logs:** ```bash docker logs grafana-alloy ``` 2. **Verify API key matches:** - `FARO_API_KEY` in `.env` must match `api_key` in Alloy config 3. **Check Prometheus targets:** - Visit http://localhost:9090/targets - Verify `grafana-alloy` target is UP 4. **Verify CORS:** - Ensure `cors_allowed_origins` in Alloy config includes your frontend origin ### CORS Errors If you see CORS errors in the browser console: 1. Add your frontend origin to `cors_allowed_origins` in Alloy config 2. For development: `cors_allowed_origins = ["*"]` 3. For production: `cors_allowed_origins = ["https://yourdomain.com"]` ## Security **Important:** The default API key `faro-secret-key` is for development only. In production: 1. Generate a secure random API key 2. Update `FARO_API_KEY` in `.env` 3. Update `api_key` in Grafana Alloy config 4. Consider using environment variables or secrets management ## Advanced Configuration ### Custom Instrumentations To add custom instrumentations, modify `resources/views/components/scripts/faro.blade.php`: ```javascript import { TracingInstrumentation } from '@grafana/faro-web-tracing'; const faroInstance = initializeFaro({ // ... existing config instrumentations: [ ...getWebInstrumentations(), new TracingInstrumentation(), ], }); ``` ### Disable Specific Features You can disable specific features via environment variables: ```env FARO_PERFORMANCE_ENABLED=false FARO_ERRORS_ENABLED=false FARO_CONSOLE_ENABLED=false FARO_INTERACTIONS_ENABLED=false FARO_SESSION_TRACKING=false ``` ## References - [Grafana Faro Documentation](https://github.com/grafana/faro-web-sdk) - [Grafana Faro Quick Start](https://github.com/grafana/faro-web-sdk/blob/main/docs/sources/tutorials/quick-start-browser.md) - [Grafana Alloy FARO Receiver](https://grafana.com/docs/alloy/latest/reference/components/faro.receiver/) ================================================ FILE: backend/README.md ================================================

Laravel Logo

Build Status Total Downloads Latest Stable Version License

## About Laravel Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: - [Simple, fast routing engine](https://laravel.com/docs/routing). - [Powerful dependency injection container](https://laravel.com/docs/container). - Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. - Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). - Database agnostic [schema migrations](https://laravel.com/docs/migrations). - [Robust background job processing](https://laravel.com/docs/queues). - [Real-time event broadcasting](https://laravel.com/docs/broadcasting). Laravel is accessible, powerful, and provides tools required for large, robust applications. ## Learning Laravel Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch. If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. ## Laravel Sponsors We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). ### Premium Partners - **[Vehikl](https://vehikl.com/)** - **[Tighten Co.](https://tighten.co)** - **[WebReinvent](https://webreinvent.com/)** - **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** - **[64 Robots](https://64robots.com)** - **[Curotec](https://www.curotec.com/services/technologies/laravel/)** - **[Cyber-Duck](https://cyber-duck.co.uk)** - **[DevSquad](https://devsquad.com/hire-laravel-developers)** - **[Jump24](https://jump24.co.uk)** - **[Redberry](https://redberry.international/laravel/)** - **[Active Logic](https://activelogic.com)** - **[byte5](https://byte5.de)** - **[OP.GG](https://op.gg)** ## Contributing Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). ## Code of Conduct In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). ## Security Vulnerabilities If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. ## License The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). ================================================ FILE: backend/WORKSPACE_SETUP.md ================================================ # Workspace System Documentation ## Overview The workspace system allows multiple users to collaborate on managing displays, devices, calendars, and rooms. Each user automatically gets their own workspace, and Pro users can invite colleagues to join their workspace. ## Architecture ### Models 1. **Workspace** - Represents a team/workspace - Has an `owner` (User) - Has many `members` (Users with roles) - Contains displays, devices, calendars, rooms 2. **WorkspaceMember** - Pivot table linking users to workspaces - Roles: `owner`, `admin`, `member` - `owner` role is implicit for the workspace owner ### Relationships - **User** → **Workspace** (one-to-many: owned workspaces) - **User** ↔ **Workspace** (many-to-many: member workspaces) - **Workspace** → **Display** (one-to-many) - **Workspace** → **Device** (one-to-many) - **Workspace** → **Calendar** (one-to-many) - **Workspace** → **Room** (one-to-many) ## Migration Strategy 1. **Existing Users**: Each user automatically gets a workspace created with their name 2. **Existing Data**: All displays, devices, calendars, and rooms are migrated to the user's workspace 3. **Backward Compatibility**: The `user_id` field is kept for backward compatibility ## Permissions ### Workspace Roles - **Owner**: Full control (can delete workspace, manage all members) - **Admin**: Can manage members and workspace settings - **Member**: Can view and use workspace resources ### Display Access - Users can access displays they own directly (`user_id`) - Users can access displays in workspaces they're members of (`workspace_id`) - Device authentication checks workspace membership ## Usage ### Adding a Colleague 1. Navigate to workspace settings (requires Pro) 2. Enter colleague's email address 3. Select role (admin or member) 4. Colleague receives access to all workspace resources ### Managing Members - **Add Member**: Only owners/admins can add members - **Update Role**: Change member role between admin/member - **Remove Member**: Remove access from workspace ## API Changes ### DisplayController - `index()` now returns displays from user's workspace(s) - Access checks include workspace membership ### DisplayService - `validateDisplayPermission()` checks workspace membership - Pro features check workspace owner's Pro status ## Frontend Changes Needed 1. **Workspace Management UI** - List workspaces - View workspace members - Add/remove members - Update member roles 2. **Display Creation** - Automatically assign to user's primary workspace - Allow selecting workspace (if user has multiple) 3. **Device Connection** - Connect code should work with workspace - Devices inherit workspace from user ## Migration Commands Run migrations in order: ```bash php artisan migrate ``` The migration `2025_12_30_000003_create_workspaces_for_existing_users.php` will: 1. Create a workspace for each existing user 2. Migrate all user's displays, devices, calendars, and rooms to their workspace 3. Add the user as an owner member ## Notes - Pro subscription is required to add team members - Workspace owner cannot be removed - All existing functionality remains backward compatible - `user_id` fields are kept for direct ownership tracking ================================================ FILE: backend/app/Console/Commands/CheckMarketingTriggers.php ================================================ info('Checking marketing triggers...'); // Check users not activated after 24h $this->checkUsersNotActivatedAfter24h(); // Check users activated after 24h $this->checkUsersActivatedAfter24h(); // Check passive users (14 days no activity) $this->checkPassiveUsers(); // Check inactive users (30 days no activity) $this->checkInactiveUsers(); // Check trial expired or cancelled $this->checkTrialExpiredOrCancelled(); $this->info('Marketing triggers check completed.'); return self::SUCCESS; } /** * Check users registered 24h ago but haven't created a display */ private function checkUsersNotActivatedAfter24h(): void { $users = User::whereNull('deleted_at') ->where('created_at', '<=', now()->subHours(24)) ->where('created_at', '>', now()->subHours(25)) ->whereDoesntHave('displays') ->get(); foreach ($users as $user) { $cacheKey = "marketing:user_not_activated_24h:{$user->id}"; if (!Cache::has($cacheKey)) { event(new UserNotActivatedAfter24h($user)); Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days $this->line("Fired UserNotActivatedAfter24h for user {$user->email}"); } } } /** * Check users who created their first display 24h ago */ private function checkUsersActivatedAfter24h(): void { // Get users whose first display was created 24h ago $users = User::whereNull('deleted_at') ->where('created_at', '<=', now()->subHours(24)) ->where('created_at', '>', now()->subHours(25)) ->whereHas('displays') ->get(); foreach ($users as $user) { $cacheKey = "marketing:user_activated_24h:{$user->id}"; if (!Cache::has($cacheKey)) { event(new UserActivatedAfter24h($user)); Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days $this->line("Fired UserActivatedAfter24h for user {$user->email}"); } } } /** * Check users with no activity for 14 days * Activity includes: user activity, device activity */ private function checkPassiveUsers(): void { $cutoffDate = now()->subDays(14); $previousCutoffDate = now()->subDays(15); $users = User::whereNull('deleted_at') ->where(function ($query) use ($cutoffDate, $previousCutoffDate) { // User's last activity is within the window (or null) $query->where(function ($q) use ($cutoffDate, $previousCutoffDate) { $q->whereNotNull('last_activity_at') ->where('last_activity_at', '<=', $cutoffDate) ->where('last_activity_at', '>', $previousCutoffDate); }); }) // And no devices with recent activity ->whereDoesntHave('devices', function ($q) use ($cutoffDate) { $q->whereNotNull('last_activity_at') ->where('last_activity_at', '>', $cutoffDate); }) ->get(); foreach ($users as $user) { $cacheKey = "marketing:user_passive:{$user->id}"; if (!Cache::has($cacheKey)) { event(new UserPassive($user)); Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days $this->line("Fired UserPassive for user {$user->email}"); } } } /** * Check users with no activity for 30 days * Activity includes: user activity, device activity */ private function checkInactiveUsers(): void { $cutoffDate = now()->subDays(30); $previousCutoffDate = now()->subDays(31); $users = User::whereNull('deleted_at') ->where(function ($query) use ($cutoffDate, $previousCutoffDate) { // User's last activity is within the window (or null) $query->where(function ($q) use ($cutoffDate, $previousCutoffDate) { $q->whereNotNull('last_activity_at') ->where('last_activity_at', '<=', $cutoffDate) ->where('last_activity_at', '>', $previousCutoffDate); }); }) // And no devices with recent activity ->whereDoesntHave('devices', function ($q) use ($cutoffDate) { $q->whereNotNull('last_activity_at') ->where('last_activity_at', '>', $cutoffDate); }) ->get(); foreach ($users as $user) { $cacheKey = "marketing:user_inactive:{$user->id}"; if (!Cache::has($cacheKey)) { event(new UserInactive($user)); Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days $this->line("Fired UserInactive for user {$user->email}"); } } } /** * Check users with expired or cancelled trials */ private function checkTrialExpiredOrCancelled(): void { if (config('settings.is_self_hosted')) { return; // Skip for self-hosted instances } // Get users whose subscriptions ended in the last 24 hours $users = User::whereNull('deleted_at') ->where('is_unlimited', false) ->whereHas('subscriptions', function ($query) { // Subscription ended in the last 24 hours $query->where('ends_at', '<=', now()) ->where('ends_at', '>', now()->subDay()); }) ->whereDoesntHave('subscriptions', function ($query) { // And they don't have any active subscriptions $query->where(function ($q) { $q->whereNull('ends_at') ->orWhere('ends_at', '>', now()); }); }) ->get(); foreach ($users as $user) { $cacheKey = "marketing:trial_expired:{$user->id}"; if (!Cache::has($cacheKey)) { event(new TrialExpiredOrCancelled($user)); Cache::put($cacheKey, true, now()->addDays(7)); // Prevent duplicate events for 7 days $this->line("Fired TrialExpiredOrCancelled for user {$user->email}"); } } } } ================================================ FILE: backend/app/Console/Commands/CleanupExpiredEvents.php ================================================ option('display'); $dryRun = $this->option('dry-run'); if ($displayId) { $displays = Display::where('id', $displayId)->get(); if ($displays->isEmpty()) { $this->error("Display with ID {$displayId} not found."); return; } } else { $displays = Display::all(); } $totalDeleted = 0; foreach ($displays as $display) { $deletedCount = $this->cleanupEventsForDisplay($display, $dryRun); $totalDeleted += $deletedCount; if ($deletedCount > 0) { $action = $dryRun ? 'would delete' : 'deleted'; $this->info("Display '{$display->name}' (ID: {$display->id}): {$action} {$deletedCount} expired events"); } } $action = $dryRun ? 'would be deleted' : 'deleted'; $this->info("Total: {$totalDeleted} events {$action}"); } /** * Cleanup expired events for a specific display */ private function cleanupEventsForDisplay(Display $display, bool $dryRun = false): int { $startTime = $display->getStartTime(); $query = Event::where('display_id', $display->id) ->where('end', '<', $startTime); if ($dryRun) { return $query->count(); } $deletedCount = $query->count(); $query->delete(); if ($deletedCount > 0) { logger()->info("Cleaned up {$deletedCount} expired events for display {$display->id} that ended before {$startTime->toDateTimeString()}"); } return $deletedCount; } } ================================================ FILE: backend/app/Console/Commands/RenewEventSubscriptions.php ================================================ where(function ($query) { $query->whereHas('outlookAccount', function ($query) { $query->where('status', AccountStatus::CONNECTED); })->orWhereHas('googleAccount', function ($query) { $query->where('status', AccountStatus::CONNECTED); }); }) ->expired() ->get(); logger()->info('Renewing ' . $expiredSubscriptions->count() . ' expired subscriptions'); foreach ($expiredSubscriptions as $expiredSubscription) { $display = $expiredSubscription->display; // Renew Outlook event subscription if ($expiredSubscription->outlookAccount) { $this->renewOutlookEventSubscription($expiredSubscription->outlookAccount, $display, $expiredSubscription, $outlookService); } // Renew Google event subscription if ($expiredSubscription->googleAccount) { $this->renewGoogleEventSubscription($expiredSubscription->googleAccount, $display, $expiredSubscription, $googleService); } } $newDisplays = Display::with(['calendar.room', 'calendar.outlookAccount', 'calendar.googleAccount']) ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]) ->doesntHave('eventSubscriptions') ->get(); logger()->info('Creating ' . $newDisplays->count() . ' new subscriptions'); foreach ($newDisplays as $newDisplay) { $calendar = $newDisplay->calendar; // Create new Outlook event subscription if ($calendar->outlookAccount) { $this->createOutlookEventSubscription($calendar->outlookAccount, $newDisplay, $outlookService); } // Create new Google event subscription if ($calendar->googleAccount) { $this->createGoogleEventSubscription($calendar->googleAccount, $newDisplay, $googleService); } } } /** * @param OutlookAccount $outlookAccount * @param Display $display * @param EventSubscription $eventSubscription * @param OutlookService $outlookService */ private function renewOutlookEventSubscription(OutlookAccount $outlookAccount, Display $display, EventSubscription $eventSubscription, OutlookService $outlookService): void { try { $outlookService->deleteEventSubscription($outlookAccount, $eventSubscription, false); } catch (\Exception $e) { $outlookAccount->update(['status' => AccountStatus::ERROR]); $display->update(['status' => DisplayStatus::ERROR]); report('Error deleting Outlook subscription for display ' . $display->id . ': ' . $e->getMessage()); return; } $this->createOutlookEventSubscription($outlookAccount, $display, $outlookService); } /** * @param GoogleAccount $googleAccount * @param Display $display * @param EventSubscription $eventSubscription * @param GoogleService $googleService */ private function renewGoogleEventSubscription(GoogleAccount $googleAccount, Display $display, EventSubscription $eventSubscription, GoogleService $googleService): void { try { $googleService->deleteEventSubscription($googleAccount, $eventSubscription, false); } catch (\Exception $e) { $googleAccount->update(['status' => AccountStatus::ERROR]); $display->update(['status' => DisplayStatus::ERROR]); report('Error deleting Google subscription for display ' . $display->id . ': ' . $e->getMessage()); return; } $this->createGoogleEventSubscription($googleAccount, $display, $googleService); } /** * @param OutlookAccount $outlookAccount * @param Display $display * @param OutlookService $outlookService * @return void */ private function createOutlookEventSubscription(OutlookAccount $outlookAccount, Display $display, OutlookService $outlookService): void { try { $calendar = $display->calendar; if ($calendar->room) { $outlookService->createEventSubscriptionByUser($outlookAccount, $display, $calendar->calendar_id); return; } $outlookService->createEventSubscriptionByCalendar($outlookAccount, $display, $calendar->calendar_id); } catch (\Exception $e) { $outlookAccount->update(['status' => AccountStatus::ERROR]); $display->update(['status' => DisplayStatus::ERROR]); report('Error creating Outlook subscription for display ' . $display->id . ': ' . $e->getMessage()); } } /** * @param GoogleAccount $googleAccount * @param Display $display * @param GoogleService $googleService * @return void */ private function createGoogleEventSubscription(GoogleAccount $googleAccount, Display $display, GoogleService $googleService): void { try { $calendar = $display->calendar; // Prevent resources and groups from creating a push notification, as it is not supported by Google (pushNotSupportedForRequestedResource) if ($calendar->room || Str::contains($calendar->calendar_id, ['group.calendar.google.com', 'resource.calendar.google.com'])) { return; } $googleService->createEventSubscription($googleAccount, $display, $calendar->calendar_id); } catch (\Exception $e) { $googleAccount->update(['status' => AccountStatus::ERROR]); $display->update(['status' => DisplayStatus::ERROR]); report('Error creating Google subscription for display ' . $display->id . ': ' . $e->getMessage()); } } } ================================================ FILE: backend/app/Console/Commands/SendHeartbeat.php ================================================ getInstanceData(); $response = Http::acceptJson()->post(config('settings.license_server') . '/api/v1/instances/heartbeat', $data); if ($response->successful()) { $this->info('Heartbeat sent successfully'); return self::SUCCESS; } $this->error('Failed to send heartbeat: ' . $response->body()); return self::FAILURE; } } ================================================ FILE: backend/app/Console/Commands/TriggerRegistrationWebhookForMissingNames.php ================================================ where(function ($query) { $query->whereNull('first_name') ->orWhereNull('last_name'); }) ->orderBy('created_at', 'asc') ->first(); if (!$user) { $this->info('No users found without first_name or last_name.'); return self::SUCCESS; } $this->info("Triggering registration webhook for user: {$user->email} (ID: {$user->id})"); event(new UserRegistered($user)); $this->info('Registration webhook triggered successfully.'); return self::SUCCESS; } } ================================================ FILE: backend/app/Console/Commands/UpdateLemonSqueezySubscriptions.php ================================================ info('Skipping subscription update - this is a self-hosted instance'); return self::SUCCESS; } $this->info('Starting Lemon Squeezy subscription updates...'); // Get all users with active subscriptions $usersWithSubscriptions = User::where(function ($query) { $query->where('is_unlimited', true) ->orWhereHas('subscriptions', function ($subQuery) { $subQuery->where('ends_at', null) // Active subscription ->orWhere('ends_at', '>', now()); // Not expired }); })->get(); $this->info("Found {$usersWithSubscriptions->count()} users with active subscriptions"); $successCount = 0; $errorCount = 0; foreach ($usersWithSubscriptions as $user) { try { $totalUsage = $this->getTotalUsageCount($user); if ($user->is_unlimited) { $this->line("Skipping unlimited user {$user->email} with {$totalUsage} total usage units"); $successCount++; } else { // Try both quantity-based and usage-based billing methods $this->updateQuantityBasedBilling($user, $totalUsage); $this->updateUsageBasedBilling($user, $totalUsage); $successCount++; $this->info("Updated subscription for user {$user->email} with {$totalUsage} total usage units (displays + boards*2)"); } } catch (\Exception $e) { $errorCount++; $this->error("Failed to update subscription for user {$user->email}: {$e->getMessage()}"); Log::error('Subscription update failed', [ 'user_id' => $user->id, 'user_email' => $user->email, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); } } $this->info("Subscription updates completed: {$successCount} successful, {$errorCount} errors"); return $errorCount === 0 ? self::SUCCESS : self::FAILURE; } /** * Get the count of active displays for a user */ private function getActiveDisplayCount(User $user): int { return $user->displays() ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]) ->count(); } /** * Get the total usage count for a user across all their workspaces * Displays count as 1x, Boards count as 2x */ private function getTotalUsageCount(User $user): int { $totalUsage = 0; foreach ($user->workspaces as $workspace) { $totalUsage += $workspace->getTotalUsageCount(); } return $totalUsage; } /** * Update subscription using quantity-based billing */ private function updateQuantityBasedBilling(User $user, int $displayCount): void { // Skip unlimited users as they don't need quantity updates if ($user->is_unlimited) { return; } // Get the user's active subscription $subscription = $user->subscriptions() ->where(function($query) { $query->whereNull('ends_at') ->orWhere('ends_at', '>', now()); }) ->first(); if (!$subscription) { return; // No subscription found, skip silently } $apiKey = config('lemon-squeezy.api_key'); if (!$apiKey) { return; // No API key, skip silently } try { // Get subscription details from Lemon Squeezy API to find subscription items $subscriptionResponse = Http::withToken($apiKey) ->withHeaders([ 'Accept' => 'application/vnd.api+json', ]) ->get('https://api.lemonsqueezy.com/v1/subscriptions/' . $subscription->lemon_squeezy_id); if (!$subscriptionResponse->successful()) { return; // Failed to fetch subscription, skip silently } $subscriptionData = $subscriptionResponse->json(); // Get subscription items from the response (handle different response structures) $subscriptionItems = $this->getSubscriptionItems($subscriptionData, $apiKey, $subscription->lemon_squeezy_id); if (empty($subscriptionItems)) { return; // No subscription items found, skip silently } // Find the first subscription item (assuming single item per subscription) $subscriptionItem = $subscriptionItems[0]; $subscriptionItemId = $this->getSubscriptionItemId($subscriptionItem); if (!$subscriptionItemId) { return; // Could not get subscription item ID, skip silently } // Update subscription item quantity using quantity-based billing $response = Http::withToken($apiKey) ->withHeaders([ 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json', ]) ->patch("https://api.lemonsqueezy.com/v1/subscription-items/{$subscriptionItemId}", [ 'data' => [ 'type' => 'subscription-items', 'id' => $subscriptionItemId, 'attributes' => [ 'quantity' => $displayCount ] ] ]); if ($response->successful()) { Log::info('Quantity-based billing updated successfully', [ 'user_id' => $user->id, 'user_email' => $user->email, 'subscription_item_id' => $subscriptionItemId, 'display_count' => $displayCount ]); } } catch (\Exception $e) { // Log but don't throw - let the usage-based billing method try Log::debug('Quantity-based billing update failed', [ 'user_id' => $user->id, 'error' => $e->getMessage() ]); } } /** * Update subscription using usage-based billing */ private function updateUsageBasedBilling(User $user, int $displayCount): void { // Skip unlimited users as they don't need usage reporting if ($user->is_unlimited) { return; } // Get the user's active subscription $subscription = $user->subscriptions() ->where(function($query) { $query->where('ends_at', null) ->orWhere('ends_at', '>', now()); }) ->first(); if (!$subscription) { return; // No subscription found, skip silently } $apiKey = config('lemon-squeezy.api_key'); if (!$apiKey) { return; // No API key, skip silently } try { // Get subscription details from Lemon Squeezy API to find subscription items $subscriptionResponse = Http::withToken($apiKey) ->withHeaders([ 'Accept' => 'application/vnd.api+json', ]) ->get('https://api.lemonsqueezy.com/v1/subscriptions/' . $subscription->lemon_squeezy_id); if (!$subscriptionResponse->successful()) { return; // Failed to fetch subscription, skip silently } $subscriptionData = $subscriptionResponse->json(); // Get subscription items from the response (handle different response structures) $subscriptionItems = $this->getSubscriptionItems($subscriptionData, $apiKey, $subscription->lemon_squeezy_id); if (empty($subscriptionItems)) { return; // No subscription items found, skip silently } // Find the first subscription item (assuming single item per subscription) $subscriptionItem = $subscriptionItems[0]; $subscriptionItemId = $this->getSubscriptionItemId($subscriptionItem); if (!$subscriptionItemId) { return; // Could not get subscription item ID, skip silently } // Report usage to Lemon Squeezy using the usage-records API endpoint $response = Http::withToken($apiKey) ->withHeaders([ 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json', ]) ->post('https://api.lemonsqueezy.com/v1/usage-records', [ 'data' => [ 'type' => 'usage-records', 'attributes' => [ 'quantity' => $displayCount, 'action' => 'set', // Set the usage count for the current period ], 'relationships' => [ 'subscription-item' => [ 'data' => [ 'type' => 'subscription-items', 'id' => $subscriptionItemId ] ] ] ] ]); if ($response->successful()) { Log::info('Usage-based billing updated successfully', [ 'user_id' => $user->id, 'user_email' => $user->email, 'subscription_item_id' => $subscriptionItemId, 'display_count' => $displayCount ]); } } catch (\Exception $e) { // Log but don't throw - let the quantity-based billing method try Log::debug('Usage-based billing update failed', [ 'user_id' => $user->id, 'error' => $e->getMessage() ]); } } /** * Extract subscription items from Lemon Squeezy API response */ private function getSubscriptionItems(array $subscriptionData, string $apiKey, string $subscriptionId): array { $subscriptionItems = []; // Check if subscription_items is in the attributes if (isset($subscriptionData['data']['attributes']['subscription_items'])) { $subscriptionItems = $subscriptionData['data']['attributes']['subscription_items']; } // Check if subscription_items is in the relationships elseif (isset($subscriptionData['data']['relationships']['subscription_items']['data'])) { $subscriptionItems = $subscriptionData['data']['relationships']['subscription_items']['data']; } // Check if subscription_items is in the included data elseif (isset($subscriptionData['included'])) { $subscriptionItems = collect($subscriptionData['included']) ->filter(fn($item) => $item['type'] === 'subscription-items') ->toArray(); } else { // Try to fetch subscription items directly $subscriptionItemsResponse = Http::withToken($apiKey) ->withHeaders([ 'Accept' => 'application/vnd.api+json', ]) ->get('https://api.lemonsqueezy.com/v1/subscription-items?filter[subscription_id]=' . $subscriptionId); if ($subscriptionItemsResponse->successful()) { $subscriptionItemsData = $subscriptionItemsResponse->json(); if (isset($subscriptionItemsData['data']) && !empty($subscriptionItemsData['data'])) { $subscriptionItems = $subscriptionItemsData['data']; } } } return $subscriptionItems; } /** * Extract subscription item ID from Lemon Squeezy API response */ private function getSubscriptionItemId(array $subscriptionItem): ?string { // Handle different response structures if (isset($subscriptionItem['id'])) { return $subscriptionItem['id']; } elseif (isset($subscriptionItem['attributes']['id'])) { return $subscriptionItem['attributes']['id']; } return null; } } ================================================ FILE: backend/app/Console/Commands/ValidateLicense.php ================================================ getInstanceData(); $response = Http::acceptJson()->post(config('settings.license_server') . '/api/v1/instances/validate', $data); if ($response->successful()) { $this->info('Validation successfully'); $licenseData = LicenseData::from($response->json()['data']); $instanceService->updateLicense($licenseData); return self::SUCCESS; } $this->error('Failed to validate: ' . $response->body()); return self::FAILURE; } } ================================================ FILE: backend/app/Data/CalendarWebhookData.php ================================================ license_key, valid: $instance->license_valid, expiresAt: $instance->license_expires_at, ); } } ================================================ FILE: backend/app/Data/OrderWebhookData.php ================================================ permitted = $permitted; $this->message = $message; $this->code = $code; } } ================================================ FILE: backend/app/Data/UserData.php ================================================ 'Connected', self::ERROR => 'Error - needs re-authentication', }; } public function color(): string { return match($this) { self::CONNECTED => 'green', self::ERROR => 'red', }; } } ================================================ FILE: backend/app/Enums/DisplayStatus.php ================================================ 'Ready', self::ACTIVE => 'Active', self::DEACTIVATED => 'Deactivated', self::ERROR => 'Error - try recreating', }; } public function color(): string { return match($this) { self::READY => 'blue', self::ACTIVE => 'green', self::DEACTIVATED => 'gray', self::ERROR => 'red', }; } } ================================================ FILE: backend/app/Enums/EventSource.php ================================================ 'Read Only', self::WRITE => 'Read & Write', }; } public function description(): string { return match($this) { self::READ => 'View calendar events and room availability. Cannot create or modify events.', self::WRITE => 'View calendar events and create new bookings. Required for ad-hoc room bookings when users book rooms directly from the tablet display.', }; } } ================================================ FILE: backend/app/Enums/Plan.php ================================================ 'Business', self::PERSONAL => 'Personal / Community', }; } } ================================================ FILE: backend/app/Enums/UserStatus.php ================================================ 'Owner', self::ADMIN => 'Admin', self::MEMBER => 'Member', }; } /** * Check if this role can manage the workspace */ public function canManage(): bool { return in_array($this, [self::OWNER, self::ADMIN]); } } ================================================ FILE: backend/app/Events/TrialExpiredOrCancelled.php ================================================ */ protected $dontFlash = [ 'current_password', 'password', 'password_confirmation', ]; /** * Register the exception handling callbacks for the application. */ public function register(): void { // } /** * Render an exception into an HTTP response. * @throws Throwable */ public function render($request, Throwable $e): Response|JsonResponse|\Symfony\Component\HttpFoundation\Response { // Log exceptions with context (skip 4xx noise; avoid leaking details in prod) if ( $this->shouldReport($e) && !($e instanceof NotFoundHttpException) && !($e instanceof ValidationException) && !($e instanceof HttpExceptionInterface && $e->getStatusCode() < 500) ) { $logLevel = $e instanceof AuthenticationException ? 'warning' : 'error'; $context = [ 'exception' => get_class($e), 'route' => $request->route()?->getName(), 'path' => $request->path(), 'method' => $request->method(), 'user_id' => auth()->id(), ]; if (config('app.debug')) { $context += [ 'message' => $e->getMessage(), 'code' => $e->getCode(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'ip' => $request->ip(), 'user_agent' => substr($request->userAgent() ?? '', 0, 200), 'trace' => substr($e->getTraceAsString(), 0, 1000), ]; } logger()->{$logLevel}('Unhandled exception', $context); } if ($request->expectsJson()) { $status = 500; $message = 'Server Error'; $errors = config('app.debug') ? $e->getMessage() : null; if ($e instanceof ValidationException) { $status = 422; $message = 'Validation Error'; $errors = $e->errors(); } if ($e instanceof AuthenticationException) { $status = 401; $message = 'Unauthenticated'; } if ($e instanceof NotFoundHttpException) { $status = 404; $message = 'Resource not found'; } return response()->json([ 'success' => false, 'message' => $message, 'errors' => $errors, ], $status); } return parent::render($request, $e); } } ================================================ FILE: backend/app/Helpers/DisplaySettings.php ================================================ relationLoaded('settings')) { $setting = $display->settings->firstWhere('key', $key); return $setting?->value ?? $default; } // Fallback to querying if relationship is not loaded (backward compatibility) $setting = DisplaySetting::where('display_id', $display->id) ->where('key', $key) ->first(); return $setting?->value ?? $default; } public static function setSetting(Display $display, string $key, mixed $value, string $type = 'string'): bool { try { DisplaySetting::updateOrCreate( [ 'display_id' => $display->id, 'key' => $key, ], [ 'value' => $value, 'type' => $type, ] ); return true; } catch (\Exception $e) { report($e); return false; } } public static function deleteSetting(Display $display, string $key): bool { try { return DisplaySetting::where('display_id', $display->id) ->where('key', $key) ->delete() > 0; } catch (\Exception $e) { report($e); return false; } } public static function getAllSettings(Display $display): array { // If settings relationship is already loaded, use it to avoid N+1 queries if ($display->relationLoaded('settings')) { return $display->settings->mapWithKeys(function ($setting) { return [$setting->key => $setting->value]; })->toArray(); } // Fallback to querying if relationship is not loaded (backward compatibility) return DisplaySetting::where('display_id', $display->id) ->get() ->mapWithKeys(function ($setting) { return [$setting->key => $setting->value]; }) ->toArray(); } // Convenience methods for common settings public static function isCheckInEnabled(Display $display): bool { return self::getSetting($display, 'check_in_enabled', false); } public static function setCheckInEnabled(Display $display, bool $enabled): bool { return self::setSetting($display, 'check_in_enabled', $enabled, 'boolean'); } public static function isBookingEnabled(Display $display): bool { return self::getSetting($display, 'booking_enabled', false); } public static function setBookingEnabled(Display $display, bool $enabled): bool { return self::setSetting($display, 'booking_enabled', $enabled, 'boolean'); } // Logo settings public static function getLogo(Display $display): ?string { return self::getSetting($display, 'logo'); } public static function setLogo(Display $display, string $logoPath): bool { return self::setSetting($display, 'logo', $logoPath, 'string'); } public static function removeLogo(Display $display): bool { return self::deleteSetting($display, 'logo'); } // Background image settings public static function getBackgroundImage(Display $display): ?string { return self::getSetting($display, 'background_image'); } public static function setBackgroundImage(Display $display, string $backgroundPath): bool { return self::setSetting($display, 'background_image', $backgroundPath, 'string'); } public static function removeBackgroundImage(Display $display): bool { return self::deleteSetting($display, 'background_image'); } // Font family settings public static function getFontFamily(Display $display): string { return self::getSetting($display, 'font_family', 'Inter'); } public static function setFontFamily(Display $display, string $fontFamily): bool { return self::setSetting($display, 'font_family', $fontFamily, 'string'); } public static function getCheckInMinutes(Display $display): int { return self::getSetting($display, 'check_in_minutes', 15); } public static function setCheckInMinutes(Display $display, int $minutes): bool { return self::setSetting($display, 'check_in_minutes', $minutes, 'integer'); } public static function getCheckInGracePeriod(Display $display): int { return self::getSetting($display, 'check_in_grace_period', 5); } public static function setCheckInGracePeriod(Display $display, int $minutes): bool { return self::setSetting($display, 'check_in_grace_period', $minutes, 'integer'); } public static function isCalendarEnabled(Display $display): bool { return self::getSetting($display, 'calendar_enabled', false); } public static function setCalendarEnabled(Display $display, bool $enabled): bool { return self::setSetting($display, 'calendar_enabled', $enabled, 'boolean'); } // Customizable display state texts (shorter keys) public static function getAvailableText(Display $display): ?string { return self::getSetting($display, 'text_available'); } public static function setAvailableText(Display $display, string $text): bool { return self::setSetting($display, 'text_available', $text, 'string'); } public static function getTransitioningText(Display $display): ?string { return self::getSetting($display, 'text_transitioning'); } public static function setTransitioningText(Display $display, string $text): bool { return self::setSetting($display, 'text_transitioning', $text, 'string'); } public static function getReservedText(Display $display): ?string { return self::getSetting($display, 'text_reserved'); } public static function setReservedText(Display $display, string $text): bool { return self::setSetting($display, 'text_reserved', $text, 'string'); } public static function getCheckInText(Display $display): ?string { return self::getSetting($display, 'text_checkin'); } public static function setCheckInText(Display $display, string $text): bool { return self::setSetting($display, 'text_checkin', $text, 'string'); } // Toggle for showing meeting title public static function getShowMeetingTitle(Display $display): bool { return self::getSetting($display, 'show_meeting_title', true); } public static function setShowMeetingTitle(Display $display, bool $show): bool { return self::setSetting($display, 'show_meeting_title', $show, 'boolean'); } // Admin actions visibility public static function isAdminActionsHidden(Display $display): bool { return self::getSetting($display, 'hide_admin_actions', false); } public static function setAdminActionsHidden(Display $display, bool $hidden): bool { return self::setSetting($display, 'hide_admin_actions', $hidden, 'boolean'); } // Cancel permission settings // Values: 'all' (default), 'tablet_only', 'none' public static function getCancelPermission(Display $display): string { return self::getSetting($display, 'cancel_permission', 'all'); } public static function setCancelPermission(Display $display, string $permission): bool { if (!in_array($permission, ['all', 'tablet_only', 'none'])) { return false; } return self::setSetting($display, 'cancel_permission', $permission, 'string'); } // Border thickness settings // Values: 'small', 'medium' (default), 'large' public static function getBorderThickness(Display $display): string { return self::getSetting($display, 'border_thickness', 'medium'); } public static function setBorderThickness(Display $display, string $thickness): bool { if (!in_array($thickness, ['small', 'medium', 'large'])) { return false; } return self::setSetting($display, 'border_thickness', $thickness, 'string'); } } ================================================ FILE: backend/app/Helpers/Settings.php ================================================ first(); return $setting?->value ?? $default; } public static function setSetting(string $key, mixed $value, string $type = 'string'): bool { try { Setting::updateOrCreate( ['key' => $key], [ 'value' => $value, 'type' => $type, ] ); return true; } catch (\Exception $e) { report($e); return false; } } public static function deleteSetting(string $key): bool { try { return Setting::where('key', $key)->delete() > 0; } catch (\Exception $e) { report($e); return false; } } public static function getAllSettings(): array { return Setting::all()->mapWithKeys(function ($setting) { return [$setting->key => $setting->value]; })->toArray(); } } ================================================ FILE: backend/app/Http/Controllers/API/ApiController.php ================================================ json([ 'success' => true, 'message' => $message, 'data' => $data, ], $code); } protected function error(string $message = 'Error', mixed $errors = null, int $code = 400): JsonResponse { // Log API errors for observability (skip 404s and auth errors to avoid noise) if ($code >= 500 || ($code >= 400 && $code < 404)) { logger()->warning('API error response', [ 'message' => $message, 'code' => $code, 'errors' => $errors, 'route' => request()->route()?->getName(), 'path' => request()->path(), 'method' => request()->method(), 'ip' => request()->ip(), 'user_id' => auth()->id(), ]); } return response()->json([ 'success' => false, 'message' => $message, 'errors' => $errors, ], $code); } } ================================================ FILE: backend/app/Http/Controllers/API/Auth/AuthController.php ================================================ validated()['code']; $uid = $request->validated()['uid']; $name = $request->validated()['name'] ?? 'Unknown'; // Atomically retrieve and invalidate the connect code $connectedUserId = User::pullConnectCode($code); // Check if the code is a valid connect code and user exists if ($connectedUserId !== null) { $user = User::find($connectedUserId); // Verify user exists before proceeding if (!$user) { logger()->warning('Device authentication failed - user not found', [ 'user_id' => $connectedUserId, 'code_prefix' => substr($code, 0, 3) . '...', 'device_uid' => substr($uid, 0, 8) . '...', 'ip' => $request->ip(), ]); return $this->error( message: 'Code is incorrect.', errors: [ 'code' => [ 'incorrect', ] ] ); } $workspace = $user->primaryWorkspace(); $device = Device::firstOrCreate([ 'user_id' => $connectedUserId, 'uid' => $uid, ],[ 'user_id' => $connectedUserId, 'workspace_id' => $workspace?->id, 'uid' => $uid, 'name' => $name, ]); // Update device name and workspace_id if device already existed $updateData = ['name' => $name]; if ($device->workspace_id === null && $workspace) { $updateData['workspace_id'] = $workspace->id; } $device->update($updateData); logger()->info('Device authentication successful', [ 'user_id' => $connectedUserId, 'device_id' => $device->id, 'device_uid' => substr($uid, 0, 8) . '...', 'device_name' => $name, 'ip' => $request->ip(), 'user_agent' => substr($request->userAgent() ?? '', 0, 100), ]); return $this->success( data: [ 'token' => $device->createToken('device-token')->plainTextToken, 'device' => DeviceResource::make($device), ] ); } logger()->warning('Device authentication failed - invalid connect code', [ 'code_prefix' => substr($code, 0, 3) . '...', 'device_uid' => substr($uid, 0, 8) . '...', 'ip' => $request->ip(), 'user_agent' => substr($request->userAgent() ?? '', 0, 100), ]); return $this->error( message: 'Code is incorrect.', errors: [ 'code' => [ 'incorrect', ] ] ); } } ================================================ FILE: backend/app/Http/Controllers/API/Cloud/InstanceController.php ================================================ info('Instance heartbeat received', [ 'instance_key' => substr($request['instance_key'], 0, 8) . '...', // Log partial key only 'ip_hash' => $this->pseudonymizeIp(request()->ip()), 'version' => $request['version'], ]); // First, try to find an existing instance with the same instance_key $existingInstance = Instance::query() ->where('instance_key', $request['instance_key']) ->latest() ->first(); // Second, try to find an existing instance with the same user data by comparing JSON strings directly // Direct JSON comparison works for both SQLite (TEXT) and MySQL (JSON type) // Always convert users to JSON string for comparison, regardless of input type $usersValue = $request['users']; $usersJson = is_string($usersValue) ? $usersValue : json_encode($usersValue, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); // Ensure it's always a string (not array) for database comparison $usersJson = (string) $usersJson; $existingInstance = $existingInstance ?? Instance::query() ->whereRaw('users = ?', [$usersJson]) ->latest() ->first(); $instanceData = [ 'instance_key' => $request['instance_key'], 'license_key' => $request['license_key'], 'license_valid' => $request['license_valid'], 'license_expires_at' => $request['license_expires_at'], 'is_self_hosted' => $request['is_self_hosted'], 'displays_count' => $request['displays_count'], 'rooms_count' => $request['rooms_count'], 'boards_count' => $request['boards_count'] ?? null, 'users' => $request['users'], 'version' => $request['version'], 'last_heartbeat_at' => now(), ]; // If found, update that instance instead of creating a new one if ($existingInstance !== null) { $existingInstance->update($instanceData); } else { // No existing instance, create a new instance Instance::create($instanceData); } return $this->success( message: 'Heartbeat received' ); } public function validateInstance(ValidateInstanceRequest $request): JsonResponse { // Security: Log instance validation for audit trail logger()->info('Instance validation received', [ 'instance_key' => substr($request['instance_key'], 0, 8) . '...', // Log partial key only 'ip_hash' => $this->pseudonymizeIp(request()->ip()), ]); // Fetch current instance and update last validated at timestamp $instance = Instance::updateOrCreate( ['instance_key' => $request['instance_key']], [ 'instance_key' => $request['instance_key'], 'last_validated_at' => now(), ] ); // Return current instance data to sync license data return $this->success( message: 'Instance successfully validated', data: LicenseData::fromModel($instance) ); } public function activate(ValidateInstanceRequest $request): JsonResponse { // Security: Log instance activation for audit trail logger()->info('Instance activation attempt', [ 'instance_key' => substr($request['instance_key'], 0, 8) . '...', // Log partial key only 'ip_hash' => $this->pseudonymizeIp(request()->ip()), 'has_license_key' => !empty($request['license_key']), ]); $instance = Instance::updateOrCreate( ['instance_key' => $request['instance_key']], [ 'instance_key' => $request['instance_key'], 'last_heartbeat_at' => now(), ] ); try { LicenseService::activateLicense([ 'license_key' => $request['license_key'], 'instance_name' => $instance->id, ]); // Update instance with license key $instance->update([ 'license_key' => $request['license_key'], 'license_valid' => true, ]); return $this->success( message: 'Instance activated successfully', data: LicenseData::fromModel($instance) ); } catch (LicenseKeyNotFound|LemonSqueezyApiError $e) { return $this->error( message: 'License key not found', code: 404 ); } catch (\Exception $e) { report($e); return $this->error( message: 'Instance could not be activated', code: 500 ); } } } ================================================ FILE: backend/app/Http/Controllers/API/DeviceController.php ================================================ user(); // Eager load display with settings to avoid N+1 queries $device->load('display.settings'); return $this->success( data: DeviceResource::make($device) ); } public function changeDisplay(ChangeDisplayRequest $request): JsonResponse { /** @var Device $device */ $device = auth()->user(); $data = $request->validated(); if (!$device->user_id) { return $this->error( message: 'Device is not associated with a user', code: Response::HTTP_BAD_REQUEST ); } $user = User::with('workspaces')->find($device->user_id); if (!$user) { return $this->error( message: 'User not found', code: Response::HTTP_NOT_FOUND ); } // Get all workspace IDs the user is a member of $workspaceIds = $user->workspaces->pluck('id'); if ($workspaceIds->isEmpty()) { return $this->error( message: 'User is not a member of any workspace', code: Response::HTTP_BAD_REQUEST ); } // Find display in any of the user's workspaces $display = Display::query() ->whereIn('workspace_id', $workspaceIds) ->find($data['display_id']); if (! $display) { return $this->error( message: 'Display could not be found', code: Response::HTTP_NOT_FOUND ); } $device->update(['display_id' => $display->id]); $display->update(['status' => DisplayStatus::ACTIVE]); return $this->success( message: 'Successfully changed display.' ); } } ================================================ FILE: backend/app/Http/Controllers/API/DisplayController.php ================================================ user(); if (!$device->user_id) { return $this->success(data: []); } $user = User::find($device->user_id); if (!$user) { return $this->success(data: []); } // Get displays from all workspaces the user is a member of $workspaceIds = $user->workspaces->pluck('id'); if ($workspaceIds->isEmpty()) { return $this->success(data: []); } $displays = Display::query() ->whereIn('workspace_id', $workspaceIds) ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]) ->with('settings') ->get(); logger()->info('Display list requested', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'workspace_ids' => $workspaceIds->toArray(), 'display_count' => $displays->count(), 'ip' => request()->ip(), ]); return $this->success(data: DisplayResource::collection($displays)); } public function getData(string $displayId): JsonResponse { /** @var Device $device */ $device = auth()->user(); $permission = $this->displayService->validateDisplayPermission($displayId, $device->id); if (! $permission->permitted) { logger()->warning('Display data access denied', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'display_id' => $displayId, 'reason' => $permission->message, 'ip' => request()->ip(), ]); return $this->error(message: $permission->message, code: $permission->code); } try { $startTime = microtime(true); $display = $this->displayService->getDisplay($displayId); $events = $this->eventService->getEventsForDisplay($displayId); $duration = round((microtime(true) - $startTime) * 1000, 2); logger()->info('Display data retrieved successfully', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'display_id' => $displayId, 'display_name' => $display->name ?? 'Unknown', 'event_count' => count($events), 'duration_ms' => $duration, 'ip' => request()->ip(), ]); return $this->success(data: DisplayDataResource::make([ 'display' => $display, 'events' => $events, ])); } catch (\Exception $e) { logger()->error('Failed to fetch display data', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'display_id' => $displayId, 'error' => $e->getMessage(), 'trace' => substr($e->getTraceAsString(), 0, 500), 'ip' => request()->ip(), ]); report($e); return $this->error(message: 'Something went wrong while fetching display data. Please try again later.', code: 500); } } /** * Book a room for a given duration (Pro feature). */ public function book(BookEventRequest $request, string $displayId): JsonResponse { /** @var Device $device */ $device = auth()->user(); $permission = $this->displayService->validateDisplayPermission($displayId, $device->id, ['pro' => true, 'booking' => true]); if (! $permission->permitted) { return $this->error(message: $permission->message, code: $permission->code); } try { $data = $request->validated(); // Parse start and end times if provided, otherwise use duration $start = isset($data['start']) ? Carbon::parse($data['start'])->utc() : null; $end = isset($data['end']) ? Carbon::parse($data['end'])->utc() : null; $duration = isset($data['duration']) ? (int) $data['duration'] : null; logger()->info('Room booking requested', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'display_id' => $displayId, 'start' => $start?->toIso8601String(), 'end' => $end?->toIso8601String(), 'duration' => $duration, 'summary' => Arr::get($data, 'summary', __('Reserved')), 'ip' => request()->ip(), ]); $event = $this->eventService->bookRoom( displayId: $displayId, userId: $device->user_id, summary: Arr::get($data, 'summary', __('Reserved')), duration: $duration, start: $start, end: $end ); logger()->info('Room booked successfully', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'display_id' => $displayId, 'event_id' => $event->id ?? null, 'ip' => request()->ip(), ]); return $this->success(data: new EventResource($event), code: 201); } catch (\Exception $e) { logger()->error('Room booking failed', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'display_id' => $displayId, 'error' => $e->getMessage(), 'error_code' => $e->getCode(), 'ip' => request()->ip(), ]); report($e); $status = $e->getCode() === 403 ? 403 : 400; return $this->error(message: 'Room could not be booked. There may be conflicting events during this time period. Please try a different time or duration.', code: $status); } } /** * Check in to an event (Pro feature). */ public function checkIn(string $displayId, string $eventId): JsonResponse { /** @var Device $device */ $device = auth()->user(); $permission = $this->displayService->validateDisplayPermission($displayId, $device->id, ['pro' => true]); if (! $permission->permitted) { return $this->error(message: $permission->message, code: $permission->code); } try { logger()->info('Event check-in requested', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'display_id' => $displayId, 'event_id' => $eventId, 'ip' => request()->ip(), ]); $this->eventService->checkInToEvent($eventId, $displayId); logger()->info('Event check-in successful', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'display_id' => $displayId, 'event_id' => $eventId, 'ip' => request()->ip(), ]); return $this->success(message: 'Checked in successfully'); } catch (\Exception $e) { logger()->error('Event check-in failed', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'display_id' => $displayId, 'event_id' => $eventId, 'error' => $e->getMessage(), 'error_code' => $e->getCode(), 'ip' => request()->ip(), ]); $status = $e->getCode() === 403 ? 403 : 400; return $this->error(message: 'Could not check in to event. Please try again later.', code: $status); } } /** * Cancel an event (Pro feature). */ public function cancel(string $displayId, string $eventId): JsonResponse { /** @var Device $device */ $device = auth()->user(); $permission = $this->displayService->validateDisplayPermission($displayId, $device->id, ['pro' => true]); if (! $permission->permitted) { return $this->error(message: $permission->message, code: $permission->code); } try { logger()->info('Event cancellation requested', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'display_id' => $displayId, 'event_id' => $eventId, 'ip' => request()->ip(), ]); $this->eventService->cancelEvent($eventId, $displayId); logger()->info('Event cancelled successfully', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'display_id' => $displayId, 'event_id' => $eventId, 'ip' => request()->ip(), ]); return $this->success(message: 'Event cancelled successfully'); } catch (\Exception $e) { logger()->error('Event cancellation failed', [ 'user_id' => $device->user_id, 'device_id' => $device->id, 'display_id' => $displayId, 'event_id' => $eventId, 'error' => $e->getMessage(), 'error_code' => $e->getCode(), 'ip' => request()->ip(), ]); $status = $e->getCode() === 403 ? 403 : 400; return $this->error(message: 'Event could not be cancelled. Please try again later.', code: $status); } } /** * Serve display images (logo or background) for mobile app */ public function serveImage(string $displayId, string $type) { /** @var Device $device */ $device = auth()->user(); // Validate that the device has access to this display $permission = $this->displayService->validateDisplayPermission($displayId, $device->id); if (!$permission->permitted) { abort(403, 'Access denied'); } try { $display = $this->displayService->getDisplay($displayId); return $this->imageService->serveImage($display, $type); } catch (\Exception $e) { abort(404, 'Image not found'); } } } ================================================ FILE: backend/app/Http/Controllers/API/EventController.php ================================================ user(); $permission = $this->displayService->validateDisplayPermission($device->display_id, $device->id); if (! $permission->permitted) { return $this->error(message: $permission->message, code: $permission->code); } try { $events = $this->eventService->getEventsForDisplay($device->display_id); return $this->success(data: EventResource::collection($events)); } catch (\Exception $e) { return $this->error(message: $e->getMessage(), code: 500); } } } ================================================ FILE: backend/app/Http/Controllers/AdminController.php ================================================ get('impersonating')) { abort(403, 'Cannot access admin panel while impersonating. Please stop impersonating first.'); } // Check if current user is admin if (!$user || !$user->isAdmin() || config('settings.is_self_hosted')) { abort(403); } } public function index() { $this->checkAdminAccess(); $activeDisplays = Display::where('status', DisplayStatus::ACTIVE)->count(); $totalDisplays = Display::count(); $totalBoards = Board::count(); $totalInstances = Instance::count(); $sevenDaysAgo = now()->subDays(7); // Active self-hosted instances in the last 7 days, sorted by registration order $activeInstances = Instance::where('is_self_hosted', true) ->where('last_heartbeat_at', '>=', $sevenDaysAgo) ->orderBy('created_at', 'asc') ->get() ->map(function($instance) { $instance->is_paid = (bool) $instance->license_valid; return $instance; }); // Active cloud-hosted displays: users with at least one display active in the last 7 days, sorted by registration order $activeDisplays = User::query() ->whereHas('displays', function($q) use ($sevenDaysAgo) { $q->where('last_sync_at', '>=', $sevenDaysAgo); }) ->withCount(['displays' => function($q) use ($sevenDaysAgo) { $q->where('last_sync_at', '>=', $sevenDaysAgo); }]) ->withCount('boards') ->with(['displays' => function($q) use ($sevenDaysAgo) { $q->where('last_sync_at', '>=', $sevenDaysAgo)->orderByDesc('last_sync_at'); }]) ->orderBy('created_at', 'asc') ->get() ->map(function($user) { $user->last_display_activity = $user->displays->max('last_sync_at'); $user->is_paid = $user->hasPro(); return $user; }) ->values(); // Paying cloud-hosted users: users with Pro subscription (is_unlimited or active subscription) $totalMRR = 0; $forecastedMRR = 0; $payingUsers = User::query() ->where(function($query) { $query->where('is_unlimited', true) ->orWhereHas('subscriptions', function($subQuery) { $subQuery->where(function($q) { $q->whereNull('ends_at') // Active subscription ->orWhere('ends_at', '>', now()); // Not expired }); }); }) ->withCount('displays') ->withCount('boards') ->with(['subscriptions' => function($query) { $query->where(function($q) { $q->whereNull('ends_at') ->orWhere('ends_at', '>', now()); })->orderByDesc('created_at'); }]) ->orderBy('created_at', 'asc') ->get() ->map(function($user) use (&$totalMRR, &$forecastedMRR) { $user->subscription_status = $user->is_unlimited ? 'Unlimited' : ($user->subscriptions->isNotEmpty() ? 'Subscribed' : 'None'); $user->subscription_ends_at = $user->subscriptions->first()?->ends_at; // Fetch subscription price and status from Lemon Squeezy API $user->price = 0; $user->mrr = 0; $user->lemon_squeezy_status = null; if (!$user->is_unlimited && $user->subscriptions->isNotEmpty()) { $subscription = $user->subscriptions->first(); $subscriptionData = $this->getSubscriptionData($subscription->lemon_squeezy_id, $user->displays_count); if ($subscriptionData) { $user->lemon_squeezy_status = $subscriptionData['status'] ?? null; $user->price = $subscriptionData['price'] ?? 0; $user->mrr = $user->price * $user->displays_count; // Add to forecasted MRR (all statuses) $forecastedMRR += $user->mrr; // Only add to total MRR if status is 'active' if ($subscriptionData['status'] === 'active') { $totalMRR += $user->mrr; } } } return $user; }); // All users for the users overview tab (paginated for performance) $search = request()->get('search'); $allUsersQuery = User::query() ->withCount('displays') ->withCount('boards') ->with(['subscriptions' => function($query) { $query->where(function($q) { $q->whereNull('ends_at') ->orWhere('ends_at', '>', now()); }); }]); if ($search) { $allUsersQuery->where(function($query) use ($search) { $query->where('name', 'like', "%{$search}%") ->orWhere('email', 'like', "%{$search}%"); }); } $allUsers = $allUsersQuery ->orderBy('created_at', 'desc') ->paginate(50) ->withQueryString(); return view('pages.admin', [ 'activeInstances' => $activeInstances, 'activeDisplays' => $activeDisplays, 'payingUsers' => $payingUsers, 'allUsers' => $allUsers, 'activeDisplaysCount' => $activeDisplays->count(), 'totalDisplays' => $totalDisplays, 'totalBoards' => $totalBoards, 'activeInstancesCount' => $activeInstances->count(), 'totalInstances' => $totalInstances, 'payingUsersCount' => $payingUsers->count(), 'totalMRR' => $totalMRR, 'forecastedMRR' => $forecastedMRR, ]); } /** * Get subscription data (status, price, MRR) from Lemon Squeezy API * Returns array with 'status', 'price', and 'mrr' keys */ private function getSubscriptionData(string $subscriptionId, int $displaysCount = 0): ?array { $apiKey = config('lemon-squeezy.api_key'); if (!$apiKey) { return null; } try { // Cache key for subscription data $subscriptionCacheKey = "lemonsqueezy:subscription:{$subscriptionId}"; // Fetch subscription to get status (cached for 1 hour) $subscriptionData = Cache::remember($subscriptionCacheKey, now()->addHour(), function () use ($apiKey, $subscriptionId) { $subscriptionResponse = Http::withToken($apiKey) ->withHeaders([ 'Accept' => 'application/vnd.api+json', ]) ->get("https://api.lemonsqueezy.com/v1/subscriptions/{$subscriptionId}"); if ($subscriptionResponse->successful()) { return $subscriptionResponse->json(); } return null; }); if (!$subscriptionData || !isset($subscriptionData['data']['attributes'])) { return null; } $subscriptionAttributes = $subscriptionData['data']['attributes']; $status = $subscriptionAttributes['status'] ?? null; // Get price using existing method $price = $this->getSubscriptionPrice($subscriptionId, $displaysCount); if ($price === null) { return null; } // Calculate MRR (price is already calculated with quantity for usage-based) $mrr = $price; return [ 'status' => $status, 'price' => $price, 'mrr' => $mrr, ]; } catch (\Exception $e) { return null; } } /** * Get subscription price from Lemon Squeezy API * Returns monthly recurring revenue (MRR) - converts yearly to monthly if needed * Handles both usage-based and non-usage-based subscriptions * For usage-based subscriptions, multiplies unit price by quantity (displays count) */ private function getSubscriptionPrice(string $subscriptionId, int $displaysCount = 0): ?float { $apiKey = config('lemon-squeezy.api_key'); if (!$apiKey) { return null; } try { // Cache key for subscription items $itemsCacheKey = "lemonsqueezy:subscription-items:{$subscriptionId}"; // Fetch subscription items to get pricing (cached for 1 hour) $itemsData = Cache::remember($itemsCacheKey, now()->addHour(), function () use ($apiKey, $subscriptionId) { $itemsResponse = Http::withToken($apiKey) ->withHeaders([ 'Accept' => 'application/vnd.api+json', ]) ->get("https://api.lemonsqueezy.com/v1/subscription-items?filter[subscription_id]={$subscriptionId}"); if ($itemsResponse->successful()) { return $itemsResponse->json(); } return null; }); if (!$itemsData || !isset($itemsData['data']) || empty($itemsData['data'])) { return null; } $item = $itemsData['data'][0]; $itemAttributes = $item['attributes'] ?? []; $priceId = $itemAttributes['price_id'] ?? null; $isUsageBased = $itemAttributes['is_usage_based'] ?? false; $quantity = $itemAttributes['quantity'] ?? $displaysCount; // Use subscription item quantity or fallback to displays count if (!$priceId) { return null; } // Cache key for price details $priceCacheKey = "lemonsqueezy:price:{$priceId}"; // Fetch price details using price_id (cached for 24 hours) $priceData = Cache::remember($priceCacheKey, now()->addHours(24), function () use ($apiKey, $priceId) { $priceResponse = Http::withToken($apiKey) ->withHeaders([ 'Accept' => 'application/vnd.api+json', ]) ->get("https://api.lemonsqueezy.com/v1/prices/{$priceId}"); if ($priceResponse->successful()) { return $priceResponse->json(); } return null; }); if (!$priceData || !isset($priceData['data']['attributes'])) { return null; } $priceAttributes = $priceData['data']['attributes']; // Handle usage-based subscriptions differently if ($isUsageBased) { // For usage-based subscriptions, calculate MRR as quantity × unit_price // unit_price is often null for usage-based, so we use unit_price_decimal if (isset($priceAttributes['unit_price_decimal']) && $priceAttributes['unit_price_decimal'] !== null) { $unitPrice = (float) ($priceAttributes['unit_price_decimal'] / 100); // Multiply by quantity (number of displays) to get total MRR return $unitPrice * max(1, $quantity); // Ensure at least 1 } // Fallback: check for unit_price if available if (isset($priceAttributes['unit_price']) && $priceAttributes['unit_price'] !== null) { $unitPrice = (float) ($priceAttributes['unit_price'] / 100); // Multiply by quantity (number of displays) to get total MRR return $unitPrice * max(1, $quantity); // Ensure at least 1 } // Fallback: check for setup_price or other price fields if (isset($priceAttributes['setup_price']) && $priceAttributes['setup_price'] !== null) { $price = (float) ($priceAttributes['setup_price'] / 100); return $price; } } else { // For non-usage-based subscriptions, get unit_price (fixed monthly price) // Try unit_price_decimal first (more precise) if (isset($priceAttributes['unit_price_decimal']) && $priceAttributes['unit_price_decimal'] !== null) { $price = (float) ($priceAttributes['unit_price_decimal'] / 100); // Check billing interval from price attributes $interval = strtolower($priceAttributes['renewal_interval_unit'] ?? ''); $isYearly = $interval === 'year'; // Convert yearly to monthly MRR if ($isYearly) { return $price / 12; } return $price; } // Fallback to unit_price if (isset($priceAttributes['unit_price']) && $priceAttributes['unit_price'] !== null) { $price = (float) ($priceAttributes['unit_price'] / 100); // Check billing interval from price attributes $interval = strtolower($priceAttributes['renewal_interval_unit'] ?? ''); $isYearly = $interval === 'year'; // Convert yearly to monthly MRR if ($isYearly) { return $price / 12; } return $price; } } return null; } catch (\Exception $e) { return null; } } /** * Show user details page */ public function showUser(User $user) { $this->checkAdminAccess(); $admin = Auth::user(); // Load user relationships for display $user->load([ 'outlookAccounts', 'googleAccounts', 'caldavAccounts', 'displays', 'devices', 'workspaces', 'subscriptions' => function($query) { $query->where(function($q) { $q->whereNull('ends_at') ->orWhere('ends_at', '>', now()); })->orderByDesc('created_at'); }, ]); // Get subscription info $subscriptionInfo = null; if (!$user->is_unlimited && $user->subscriptions->isNotEmpty()) { $subscription = $user->subscriptions->first(); $subscriptionData = $this->getSubscriptionData($subscription->lemon_squeezy_id, $user->displays->count()); if ($subscriptionData) { $subscriptionInfo = [ 'status' => $subscriptionData['status'] ?? null, 'price' => $subscriptionData['price'] ?? 0, 'mrr' => ($subscriptionData['price'] ?? 0) * $user->displays->count(), 'ends_at' => $subscription->ends_at, ]; } } return view('pages.admin.user', [ 'user' => $user, 'subscriptionInfo' => $subscriptionInfo, ]); } /** * Delete a user account and all associated data */ public function deleteUser(Request $request, User $user): RedirectResponse { $this->checkAdminAccess(); $admin = Auth::user(); // Prevent deleting yourself if ($user->id === $admin->id) { return redirect()->route('admin.index') ->with('error', 'You cannot delete your own account.'); } // Confirm deletion $request->validate([ 'confirm_email' => ['required', 'email'], ]); if ($request->input('confirm_email') !== $user->email) { return back()->withErrors(['confirm_email' => 'Email confirmation does not match.']); } DB::transaction(function () use ($user, $admin) { // Delete all user's personal access tokens $user->tokens()->delete(); // Delete displays and their related data first (before calendars/accounts) if ($user->displays) { foreach ($user->displays as $display) { // Delete event subscriptions $display->eventSubscriptions()->delete(); // Delete display settings $display->settings()->delete(); // Delete events associated with this display $display->events()->delete(); // Delete devices associated with this display $display->devices()->delete(); $display->delete(); } } // Delete devices (standalone devices not linked to displays) $user->devices()->delete(); // Delete rooms $user->rooms()->delete(); // Delete Outlook accounts and their calendars/events if ($user->outlookAccounts) { foreach ($user->outlookAccounts as $account) { if ($account->calendars) { foreach ($account->calendars as $calendar) { $calendar->events()->delete(); $calendar->delete(); } } $account->delete(); } } // Delete Google accounts and their calendars/events if ($user->googleAccounts) { foreach ($user->googleAccounts as $account) { if ($account->calendars) { foreach ($account->calendars as $calendar) { $calendar->events()->delete(); $calendar->delete(); } } $account->delete(); } } // Delete CalDAV accounts and their calendars/events if ($user->caldavAccounts) { foreach ($user->caldavAccounts as $account) { if ($account->calendars) { foreach ($account->calendars as $calendar) { $calendar->events()->delete(); $calendar->delete(); } } $account->delete(); } } // Delete any remaining calendars directly linked to user (shouldn't happen, but safety check) // Note: Calendars are linked through accounts, not directly to users, so this is unlikely // Events are deleted through calendars above // Handle workspaces $ownedWorkspaces = $user->ownedWorkspaces()->get(); foreach ($ownedWorkspaces as $workspace) { // Get other members (excluding the user being deleted) $otherMembers = $workspace->members()->where('user_id', '!=', $user->id)->get(); if ($otherMembers->isNotEmpty()) { // Find first admin or first member to transfer ownership $newOwner = $otherMembers->first(function ($member) { return $member->pivot->role === \App\Enums\WorkspaceRole::ADMIN->value; }) ?? $otherMembers->first(); if ($newOwner) { // Transfer ownership WorkspaceMember::where('workspace_id', $workspace->id) ->where('user_id', $newOwner->id) ->update(['role' => \App\Enums\WorkspaceRole::OWNER]); } } else { // No other members, delete the workspace and all its data foreach ($workspace->displays as $display) { $display->eventSubscriptions()->delete(); $display->settings()->delete(); $display->events()->delete(); $display->devices()->delete(); $display->delete(); } $workspace->devices()->delete(); foreach ($workspace->calendars as $calendar) { $calendar->events()->delete(); $calendar->delete(); } $workspace->rooms()->delete(); WorkspaceMember::where('workspace_id', $workspace->id)->delete(); $workspace->delete(); } } // Delete workspace memberships (user's membership in workspaces they don't own) WorkspaceMember::where('user_id', $user->id)->delete(); // Note: Instances are system-wide (for self-hosted tracking), not user-specific // No need to delete instances when deleting a user // Cancel LemonSqueezy subscriptions (if any) // Note: This doesn't actually cancel them in LemonSqueezy, just removes the local reference // You might want to add API call to cancel subscriptions if (method_exists($user, 'subscriptions')) { $user->subscriptions()->delete(); } // Finally, delete the user $user->delete(); logger()->info('User account deleted by admin', [ 'deleted_user_id' => $user->id, 'deleted_user_email' => $user->email, 'deleted_by_admin_id' => $admin->id, 'deleted_by_admin_email' => $admin->email, ]); }); return redirect()->route('admin.index') ->with('success', "User account {$user->email} and all associated data have been permanently deleted."); } /** * Impersonate a user */ public function impersonate(User $user): RedirectResponse { $this->checkAdminAccess(); $admin = Auth::user(); // Prevent impersonating yourself if ($admin->id === $user->id) { return redirect()->route('admin.index') ->with('error', 'You cannot impersonate yourself.'); } // Store original admin ID in session session()->put('impersonating', true); session()->put('impersonator_id', $admin->id); // Clear any workspace selection from admin session - let impersonated user's workspace be selected session()->forget('selected_workspace_id'); // Log in as the target user Auth::login($user); // Regenerate session and CSRF token to prevent session fixation session()->regenerate(); session()->regenerateToken(); logger()->info('Admin started impersonating user', [ 'admin_id' => $admin->id, 'admin_email' => $admin->email, 'impersonated_user_id' => $user->id, 'impersonated_user_email' => $user->email, ]); return redirect()->route('dashboard') ->with('success', "You are now impersonating {$user->email}"); } /** * Stop impersonating and return to admin account */ public function stopImpersonating(): RedirectResponse { $impersonatorId = session()->get('impersonator_id'); if (!$impersonatorId) { return redirect()->route('dashboard'); } $impersonator = User::find($impersonatorId); if (!$impersonator || !$impersonator->isAdmin()) { session()->forget(['impersonating', 'impersonator_id']); return redirect()->route('dashboard'); } $impersonatedUser = Auth::user(); // Clear impersonation session session()->forget(['impersonating', 'impersonator_id']); // Log back in as admin Auth::login($impersonator); // Regenerate session and CSRF token to prevent session fixation session()->regenerate(); session()->regenerateToken(); logger()->info('Admin stopped impersonating user', [ 'admin_id' => $impersonator->id, 'admin_email' => $impersonator->email, 'impersonated_user_id' => $impersonatedUser->id, 'impersonated_user_email' => $impersonatedUser->email, ]); return redirect()->route('admin.index') ->with('success', 'Stopped impersonating user.'); } } ================================================ FILE: backend/app/Http/Controllers/Auth/AuthController.php ================================================ user(); return $user->createToken($tokenName)->plainTextToken; } protected function createUser( string $name, string $email, string $password = null ): User { $attributes = [ 'name' => $name, 'email' => $email, 'password' => $password ? Hash::make($password) : null, ]; return User::factory()->unverified()->create($attributes); } } ================================================ FILE: backend/app/Http/Controllers/Auth/GoogleController.php ================================================ back()->withErrors(['email' => 'Email login is disabled.']); } $data = $request->validated(); if (! User::isAllowedLogin($data['email'])) { return redirect()->back()->withErrors(['email' => 'Your organization or email is not allowed to log in.']); } $user = User::where('email', $data['email'])->first(); if (!$user) { $user = User::factory()->unverified()->create([ 'name' => Str::before($data['email'], '@'), 'email' => $data['email'] ]); } $loginUrl = MagicLink::create(new LoginAction($user))->url; $user->notify(new MagicLoginNotification($loginUrl)); return redirect() ->back() ->with('success', 'Check your e-mail. You should receive an e-mail with a login link shortly.'); } /** * Destroy an authenticated session. */ public function destroy(Request $request): RedirectResponse { auth()->logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return redirect()->intended('/'); } } ================================================ FILE: backend/app/Http/Controllers/Auth/MicrosoftController.php ================================================ back()->withErrors(['email' => 'Email registration is disabled.']); } $data = $request->validated(); if (! User::isAllowedLogin($data['email'])) { return redirect()->back()->withErrors(['email' => 'Your organization or email is not allowed to register.']); } $user = User::where('email', $data['email'])->first(); if (!$user) { $user = User::factory()->unverified()->create([ 'name' => $data['name'], 'email' => $data['email'], 'terms_accepted_at' => ! config('settings.is_self_hosted') ? now() : null, ]); GoogleTagManager::flashPush([ 'event' => 'sign_up', ]); } $loginUrl = MagicLink::create(new LoginAction($user))->url; $user->notify(new MagicLoginNotification($loginUrl)); return redirect() ->back() ->with('registered', true); } } ================================================ FILE: backend/app/Http/Controllers/Auth/SocialAuthController.php ================================================ driver)->stateless()->redirect(); } catch (\Exception $e) { report($e); logger()->error('Social redirect failed', [ 'provider' => $this->driver, 'error' => $e->getMessage() ]); return redirect()->route('login')->with('error', 'Redirecting to the provider failed. Please try again.'); } } public function callback(): RedirectResponse { try { $socialUser = Socialite::driver($this->driver)->stateless()->user(); if (! User::isAllowedLogin($socialUser->getEmail())) { return redirect() ->route('login') ->with('error', 'Your organization or email is not allowed to log in.'); } $user = $this->findOrCreateUser($socialUser); return $this->authenticateUser($user); } catch (\Exception $e) { report($e); logger()->error('Social authentication failed', [ 'provider' => $this->driver, 'error' => $e->getMessage() ]); return redirect() ->route('login') ->with('error', 'Authentication with ' . Str::ucfirst($this->driver) . ' failed. Please try again.'); } } /** * @throws \Throwable */ public function token(OAuth2TokenRequest $oauthTokenRequest): RedirectResponse { $socialUser = $this->getSocialUserFromToken($oauthTokenRequest); $this->validateSocialUser($socialUser); if (! User::isAllowedLogin($socialUser->getEmail())) { return redirect() ->route('login') ->with('error', 'Your organization or email is not allowed to log in.'); } $user = $this->findOrCreateUser($socialUser); return $this->authenticateUser($user); } private function getSocialUserFromToken(OAuth2TokenRequest $oauthTokenRequest): mixed { $socialUser = null; $socialiteDriver = Socialite::driver($this->driver); try { $token = $oauthTokenRequest->token; $socialUser = $socialiteDriver->userFromToken($token); if (empty($socialUser->getName()) && ! empty($oauthTokenRequest->full_name)) { $socialUser->name = $oauthTokenRequest->full_name; } } catch (\Exception $e) { report($e); logger()->error('Something went wrong during OAuth2 authentication', [ 'provider' => $this->driver, 'exception' => $e, ]); } return $socialUser; } /** * @throws \Throwable */ private function validateSocialUser($socialUser): void { if (empty($socialUser) || empty($socialUser->getId()) || empty($socialUser->getName()) || empty($socialUser->getEmail())) { logger()->error('One or more required properties were empty during OAuth2 authentication', [ 'provider' => $this->driver, 'user' => $socialUser, ]); throw_if(empty($socialUser), ValidationException::withMessages(['token' => ['required']])); throw_if(empty($socialUser->getId()), ValidationException::withMessages(['id' => ['required']])); throw_if(empty($socialUser->getName()), ValidationException::withMessages(['name' => ['required']])); throw_if(empty($socialUser->getEmail()), ValidationException::withMessages(['email' => ['required']])); } } protected function findOrCreateUser(mixed $socialUser): User { // first try to lookup the user by token $user = User::where($this->driver.'_id', $socialUser->getId())->first(); if (empty($user)) { // getting here means there is no user connected to this social provider // check if this user has logged in using another provider or via email $user = User::whereEmail($socialUser->getEmail())->first(); // if there still is no match, create a new user if (empty($user)) { $user = $this->createUser($socialUser->getName(), $socialUser->getEmail()); GoogleTagManager::flashPush([ 'event' => 'sign_up', ]); event(new UserRegistered($user)); } // connect user to the social provider $user->update([$this->driver.'_id' => $socialUser->getId()]); } return $user; } protected function authenticateUser(User $user): RedirectResponse { auth()->login($user); return redirect()->route('dashboard'); } } ================================================ FILE: backend/app/Http/Controllers/BoardController.php ================================================ user(); // Check Pro access if (!$user->hasProForCurrentWorkspace()) { abort(403, 'Boards is a Pro feature. Please upgrade to access this feature.'); } $selectedWorkspace = $user->getSelectedWorkspace(); if (!$selectedWorkspace) { abort(404, 'No workspace found'); } $boards = Board::where('workspace_id', $selectedWorkspace->id) ->with(['user', 'displays']) ->orderBy('name') ->get(); return view('pages.boards.index', [ 'boards' => $boards, 'workspace' => $selectedWorkspace, ]); } /** * Show the form for creating a new board */ public function create(): View|Factory|Application { $user = auth()->user(); // Check Pro access if (!$user->hasProForCurrentWorkspace()) { abort(403, 'Boards is a Pro feature. Please upgrade to access this feature.'); } $this->authorize('create', Board::class); $selectedWorkspace = $user->getSelectedWorkspace(); if (!$selectedWorkspace) { abort(404, 'No workspace found'); } // Get all active displays from the workspace $displays = Display::where('workspace_id', $selectedWorkspace->id) ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]) ->orderBy('name') ->get(); return view('pages.boards.form', [ 'board' => null, 'displays' => $displays, 'workspace' => $selectedWorkspace, ]); } /** * Store a newly created board */ public function store(CreateBoardRequest $request): RedirectResponse { $user = auth()->user(); // Check Pro access if (!$user->hasProForCurrentWorkspace()) { return redirect()->back()->with('error', 'Boards is a Pro feature. Please upgrade to access this feature.'); } $this->authorize('create', Board::class); $validated = $request->validated(); $selectedWorkspace = $user->getSelectedWorkspace(); if (!$selectedWorkspace || $selectedWorkspace->id !== $validated['workspace_id']) { return redirect()->back()->with('error', 'Invalid workspace selected.'); } // Verify user has access to this workspace if (!$selectedWorkspace->hasMember($user)) { return redirect()->back()->with('error', 'You do not have access to this workspace.'); } // Create the board first $board = Board::create([ 'workspace_id' => $validated['workspace_id'], 'user_id' => $user->id, 'name' => $validated['name'], 'title' => $validated['title'] ?? null, 'subtitle' => $validated['subtitle'] ?? null, 'show_all_displays' => $validated['show_all_displays'], 'theme' => $validated['theme'] ?? 'dark', 'show_title' => $validated['show_title'] ?? true, 'show_booker' => $validated['show_booker'] ?? true, 'show_next_event' => $validated['show_next_event'] ?? true, 'show_transitioning' => $validated['show_transitioning'] ?? true, 'transitioning_minutes' => $validated['transitioning_minutes'] ?? 10, 'font_family' => $validated['font_family'] ?? 'Inter', 'language' => $validated['language'] ?? 'en', 'view_mode' => $validated['view_mode'] ?? 'card', 'show_meeting_title' => $validated['show_meeting_title'] ?? true, ]); // Handle logo upload after board is created if ($request->hasFile('logo')) { $logoPath = $this->imageService->storeBoardLogoFile($request->file('logo'), $board); $board->update(['logo' => $logoPath]); } // Sync displays if not showing all if (!$validated['show_all_displays']) { if (isset($validated['display_ids']) && is_array($validated['display_ids']) && count($validated['display_ids']) > 0) { // Verify all display IDs belong to the workspace $displayIds = Display::where('workspace_id', $selectedWorkspace->id) ->whereIn('id', $validated['display_ids']) ->pluck('id') ->toArray(); $board->displays()->sync($displayIds); } else { // No displays selected, clear associations $board->displays()->detach(); } } else { // Clear all display associations if showing all $board->displays()->detach(); } return redirect(route('dashboard') . '?tab=boards') ->with('success', 'Board created successfully.'); } /** * Display the specified board (the actual board view) */ public function show(Board $board): View|Factory|Application { $user = auth()->user(); // Check Pro access if (!$user->hasProForCurrentWorkspace()) { abort(403, 'Boards is a Pro feature. Please upgrade to access this feature.'); } $this->authorize('view', $board); // Store board in a way that getDisplayStatusData can access it $this->currentBoard = $board; // Get displays to show $displays = $board->getDisplaysToShow(); // Fetch events and determine status for each display $displayData = $this->getDisplayStatusData($displays, $board); return view('pages.boards.show', [ 'board' => $board, 'displays' => $displayData, 'workspace' => $board->workspace, ]); } private function getTransitioningMinutes($currentEvent, $nextEvent, ?Board $board = null): ?int { $transitioningMinutes = $board ? ($board->transitioning_minutes ?? 10) : 10; $now = now(); // If current event is ending soon if ($currentEvent) { $minutesLeft = $now->diffInMinutes($currentEvent->end, false); if ($minutesLeft < $transitioningMinutes && $minutesLeft > 0) { return $minutesLeft; } } // If next event is starting soon if ($nextEvent) { $minutesUntil = $now->diffInMinutes($nextEvent->start, false); if ($minutesUntil < $transitioningMinutes && $minutesUntil > 0) { return $minutesUntil; } } return null; } /** * Show the form for editing the specified board */ public function edit(Board $board): View|Factory|Application { $user = auth()->user(); // Check Pro access if (!$user->hasProForCurrentWorkspace()) { abort(403, 'Boards is a Pro feature. Please upgrade to access this feature.'); } $this->authorize('update', $board); // Get all active displays from the workspace $displays = Display::where('workspace_id', $board->workspace_id) ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]) ->orderBy('name') ->get(); return view('pages.boards.form', [ 'board' => $board, 'displays' => $displays, 'workspace' => $board->workspace, ]); } /** * Update the specified board */ public function update(UpdateBoardRequest $request, Board $board): RedirectResponse { $user = auth()->user(); // Check Pro access if (!$user->hasProForCurrentWorkspace()) { return redirect()->back()->with('error', 'Boards is a Pro feature. Please upgrade to access this feature.'); } $this->authorize('update', $board); $validated = $request->validated(); // Verify workspace matches if ($board->workspace_id !== $validated['workspace_id']) { return redirect()->back()->with('error', 'Invalid workspace selected.'); } // Handle logo upload/removal $logoPath = $board->logo; if ($request->boolean('remove_logo')) { $this->imageService->removeBoardLogoFile($board); $logoPath = null; } elseif ($request->hasFile('logo')) { // Remove old logo if exists $this->imageService->removeBoardLogoFile($board); // Store new logo $logoPath = $this->imageService->storeBoardLogoFile($request->file('logo'), $board); } // Update the board $board->update([ 'name' => $validated['name'], 'title' => $validated['title'] ?? null, 'subtitle' => $validated['subtitle'] ?? null, 'show_all_displays' => $validated['show_all_displays'], 'theme' => $validated['theme'] ?? 'dark', 'logo' => $logoPath, 'show_title' => $validated['show_title'] ?? true, 'show_booker' => $validated['show_booker'] ?? true, 'show_next_event' => $validated['show_next_event'] ?? true, 'show_transitioning' => $validated['show_transitioning'] ?? true, 'transitioning_minutes' => $validated['transitioning_minutes'] ?? 10, 'font_family' => $validated['font_family'] ?? 'Inter', 'language' => $validated['language'] ?? 'en', 'view_mode' => $validated['view_mode'] ?? 'card', 'show_meeting_title' => $validated['show_meeting_title'] ?? true, ]); // Sync displays if not showing all if (!$validated['show_all_displays']) { if (isset($validated['display_ids']) && is_array($validated['display_ids']) && count($validated['display_ids']) > 0) { // Verify all display IDs belong to the workspace $displayIds = Display::where('workspace_id', $board->workspace_id) ->whereIn('id', $validated['display_ids']) ->pluck('id') ->toArray(); $board->displays()->sync($displayIds); } else { // No displays selected, clear associations $board->displays()->detach(); } } else { // Clear all display associations if showing all $board->displays()->detach(); } return redirect(route('dashboard') . '?tab=boards') ->with('success', 'Board updated successfully.'); } /** * Remove the specified board */ public function destroy(Board $board): RedirectResponse { $user = auth()->user(); // Check Pro access if (!$user->hasProForCurrentWorkspace()) { return redirect()->back()->with('error', 'Boards is a Pro feature. Please upgrade to access this feature.'); } $this->authorize('delete', $board); // Remove logo file if exists $this->imageService->removeBoardLogoFile($board); $board->delete(); return redirect(route('dashboard') . '?tab=boards') ->with('success', 'Board deleted successfully.'); } /** * Serve board logo image */ public function serveLogo(Board $board) { $this->authorize('view', $board); return $this->imageService->serveBoardLogo($board); } /** * Get display status data for a collection of displays * Extracted from FlightboardController for reusability */ private function getDisplayStatusData(Collection $displays, ?Board $board = null): Collection { return $displays->map(function ($display) use ($board) { try { $events = $this->eventService->getEventsForDisplay($display->id) ->where('status', '!=', EventStatus::CANCELLED); $now = now(); $currentEvent = $events->first(function ($event) use ($now) { return $event->start <= $now && $event->end > $now; }); $upcomingEvents = $events->filter(function ($event) use ($now) { return $event->start > $now; })->sortBy('start'); $nextEvent = $upcomingEvents->first(); // Get board settings $showTransitioning = $board ? ($board->show_transitioning ?? true) : true; // Get board language for translations $boardLanguage = $board ? ($board->language ?? 'en') : 'en'; // Determine status $status = 'available'; // green $statusText = Lang::get('boards.available', [], $boardLanguage); if ($currentEvent) { $status = 'busy'; // red $statusText = Lang::get('boards.busy', [], $boardLanguage); } elseif ($showTransitioning && $this->isTransitioning($display, $currentEvent, $nextEvent, $board)) { $status = 'transitioning'; // amber $statusText = Lang::get('boards.transitioning', [], $boardLanguage); } // Check for check-in active $checkInEnabled = DisplaySettings::isCheckInEnabled($display); $checkInEvent = null; if ($checkInEnabled) { $checkInMinutes = DisplaySettings::getCheckInMinutes($display); $checkInGracePeriod = DisplaySettings::getCheckInGracePeriod($display); $checkInEvent = $events->first(function ($event) use ($now, $checkInMinutes, $checkInGracePeriod) { if (!$event->checkInRequired()) { return false; } $windowStart = $event->start->copy()->subMinutes($checkInMinutes); $windowEnd = $event->start->copy()->addMinutes($checkInGracePeriod); return $now->isAfter($windowStart) && $now->isBefore($windowEnd); }); if ($checkInEvent) { $status = 'transitioning'; $statusText = Lang::get('boards.check_in', [], $boardLanguage); } } // Get board settings for meeting title privacy $showMeetingTitle = $board ? ($board->show_meeting_title ?? true) : DisplaySettings::getShowMeetingTitle($display); // Helper function to truncate summary $truncateSummary = function($text) { if (mb_strlen($text) > 40) { return mb_substr($text, 0, 40) . '...'; } return $text; }; return [ 'display' => $display, 'status' => $status, 'statusText' => $statusText, 'currentEvent' => $currentEvent ? [ 'summary' => $truncateSummary($showMeetingTitle ? $currentEvent->summary : (DisplaySettings::getReservedText($display) ?? 'Reserved')), 'start' => $currentEvent->start, 'end' => $currentEvent->end, 'organizer' => $currentEvent->user?->name ?? 'Unknown', ] : null, 'nextEvent' => $nextEvent ? [ 'summary' => $truncateSummary($showMeetingTitle ? $nextEvent->summary : (DisplaySettings::getReservedText($display) ?? 'Reserved')), 'start' => $nextEvent->start, 'end' => $nextEvent->end, 'organizer' => $nextEvent->user?->name ?? 'Unknown', ] : null, 'transitioningMinutes' => $this->getTransitioningMinutes($currentEvent, $nextEvent, $board), ]; } catch (\Exception $e) { logger()->error('Failed to fetch events for display in board', [ 'display_id' => $display->id, 'error' => $e->getMessage(), ]); // Get board language for translations $boardLanguage = $board ? ($board->language ?? 'en') : 'en'; return [ 'display' => $display, 'status' => 'error', 'statusText' => Lang::get('boards.error', [], $boardLanguage), 'currentEvent' => null, 'nextEvent' => null, ]; } }); } /** * Check if display is in transitioning state */ private function isTransitioning($display, $currentEvent, $nextEvent, ?Board $board = null): bool { $checkInEnabled = DisplaySettings::isCheckInEnabled($display); if ($checkInEnabled) { return false; // Check-in logic handled separately } $transitioningMinutes = $board ? ($board->transitioning_minutes ?? 10) : 10; $now = now(); // Current event ending within configured minutes if ($currentEvent) { $minutesLeft = $now->diffInMinutes($currentEvent->end, false); if ($minutesLeft < $transitioningMinutes && $minutesLeft > 0) { return true; } } // Next event starting within configured minutes if ($nextEvent) { $minutesUntil = $now->diffInMinutes($nextEvent->start, false); if ($minutesUntil < $transitioningMinutes && $minutesUntil > 0) { return true; } } return false; } } ================================================ FILE: backend/app/Http/Controllers/CalDAVAccountsController.php ================================================ validate([ 'url' => 'required|url', 'username' => 'required|string', 'password' => 'required|string', ]); // Test connection before creating account $connectionTest = $this->caldavService->checkConnection( $validated['url'], $validated['username'], $validated['password'] ); if (!$connectionTest['success']) { return back()->withErrors([ 'connection' => $connectionTest['message'] ])->withInput(); } // Get selected workspace (from session or default to primary) $selectedWorkspace = auth()->user()->getSelectedWorkspace(); $workspaceId = $selectedWorkspace?->id; // Create the CalDAV account $account = CalDAVAccount::create([ 'user_id' => auth()->id(), 'workspace_id' => $workspaceId, 'name' => parse_url($validated['url'], PHP_URL_HOST), 'email' => $validated['username'], 'url' => $validated['url'], 'username' => $validated['username'], 'password' => $validated['password'], 'permission_type' => PermissionType::WRITE, ]); return redirect() ->route('dashboard') ->with('status', 'CalDAV account has been connected successfully.'); } public function delete(CalDAVAccount $caldavAccount): RedirectResponse { $caldavAccount->delete(); return redirect() ->route('dashboard') ->with('status', 'CalDAV account has been removed successfully.'); } } ================================================ FILE: backend/app/Http/Controllers/CalendarController.php ================================================ user()->googleAccounts()->findOrFail($id); $calendars = $this->googleService->fetchCalendars($account); return view('components.calendars.picker', [ 'calendars' => collect($calendars)->map(function ($calendar) { return [ 'id' => $calendar->getId(), 'name' => $calendar->getSummary(), ]; })->toArray() ]); } catch (GoogleException $e) { logger()->error('Google API error: ' . $e->getMessage()); // Check for insufficient permissions error if (str_contains($e->getMessage(), 'insufficientPermissions') || str_contains($e->getMessage(), 'ACCESS_TOKEN_SCOPE_INSUFFICIENT')) { return view('components.calendars.picker', [ 'calendars' => [], 'error' => 'Insufficient permissions to access Google Calendar. Please ensure you have granted all required permissions during authentication.' ]); } return view('components.calendars.picker', [ 'calendars' => [], 'error' => 'Could not fetch calendars from Google. Please check your permissions and try again.' ]); } catch (\Exception $e) { logger()->error('Google calendars fetch error: ' . $e->getMessage()); return view('components.calendars.picker', [ 'calendars' => [], 'error' => 'Could not fetch calendars from Google. Please try again later.' ]); } } public function outlook(string $id): View|Factory|Application { try { $account = auth()->user()->outlookAccounts()->findOrFail($id); $calendars = $this->outlookService->fetchCalendars($account); return view('components.calendars.picker', [ 'calendars' => collect($calendars)->map(function (array $calendar) { return [ 'id' => $calendar['id'], 'name' => $calendar['name'] ]; })->toArray() ]); } catch (ConnectionException $e) { logger()->error('Outlook API connection error: ' . $e->getMessage()); return view('components.calendars.picker', [ 'calendars' => [], 'error' => 'Could not connect to Outlook. Please try again later.' ]); } catch (\Exception $e) { logger()->error('Outlook calendars fetch error: ' . $e->getMessage()); return view('components.calendars.picker', [ 'calendars' => [], 'error' => 'Could not fetch calendars from Outlook. Please check your permissions and try again.' ]); } } public function caldav(string $id): View|Factory|Application { try { $account = auth()->user()->caldavAccounts()->findOrFail($id); $calendars = app(CalDAVService::class)->fetchCalendars($account); return view('components.calendars.picker', [ 'calendars' => collect($calendars)->map(function ($calendar) { return [ 'id' => $calendar['id'], 'name' => $calendar['name'] ]; })->toArray() ]); } catch (\Exception $e) { logger()->error('CalDAV calendars fetch error: ' . $e->getMessage()); return view('components.calendars.picker', [ 'calendars' => [], 'error' => 'Could not fetch calendars from CalDAV server. Please check your connection and try again.' ]); } } } ================================================ FILE: backend/app/Http/Controllers/Controller.php ================================================ user(); // Load workspaces with pivot data (role) - this includes all workspaces user is a member of $workspaces = $user->workspaces()->withPivot('role')->get(); // Get selected workspace (from session or default to primary) $selectedWorkspace = $user->getSelectedWorkspace(); // Get connect code from workspace owner (or current user if no workspace selected) $connectCode = null; if ($selectedWorkspace) { $workspaceOwner = $selectedWorkspace->owners()->first(); if ($workspaceOwner) { $connectCode = $workspaceOwner->getConnectCode(); } } // Fallback to current user's connect code if no workspace or owner found if (!$connectCode) { $connectCode = $user->getConnectCode(); } // Get displays from selected workspace only if ($selectedWorkspace) { $displays = Display::where('workspace_id', $selectedWorkspace->id) ->with(['workspace', 'calendar.outlookAccount', 'calendar.googleAccount', 'calendar.caldavAccount']) ->get(); // Get boards for the selected workspace $boards = Board::where('workspace_id', $selectedWorkspace->id) ->with(['user', 'displays']) ->orderBy('name') ->get(); // Get accounts for the selected workspace $outlookAccounts = OutlookAccount::where('workspace_id', $selectedWorkspace->id) ->get(); $googleAccounts = GoogleAccount::where('workspace_id', $selectedWorkspace->id) ->get(); $caldavAccounts = CalDAVAccount::where('workspace_id', $selectedWorkspace->id) ->get(); } else { $displays = collect(); $boards = collect(); $outlookAccounts = collect(); $googleAccounts = collect(); $caldavAccounts = collect(); } logger()->info('Dashboard page accessed', [ 'user_id' => $user->id, 'outlook_accounts_count' => $outlookAccounts->count(), 'google_accounts_count' => $googleAccounts->count(), 'caldav_accounts_count' => $caldavAccounts->count(), 'displays_count' => $displays->count(), 'workspaces_count' => $workspaces->count(), 'selected_workspace_id' => $selectedWorkspace?->id, 'ip' => request()->ip(), 'user_agent' => substr(request()->userAgent() ?? '', 0, 100), ]); $isSelfHosted = config('settings.is_self_hosted'); return view('pages.dashboard', [ 'outlookAccounts' => $outlookAccounts, 'googleAccounts' => $googleAccounts, 'caldavAccounts' => $caldavAccounts, 'displays' => $displays, 'boards' => $boards, 'workspaces' => $workspaces, 'selectedWorkspace' => $selectedWorkspace, 'connectCode' => $connectCode, 'primaryWorkspace' => $user->primaryWorkspace(), 'version' => config('settings.version', 'dev'), 'appEnv' => config('app.env', 'production'), 'appUrl' => config('app.url'), 'isSelfHosted' => $isSelfHosted, ]); } } ================================================ FILE: backend/app/Http/Controllers/DisplayController.php ================================================ user(); $workspaces = $user->workspaces()->withPivot('role')->get(); $selectedWorkspace = $user->getSelectedWorkspace(); // Filter accounts to show all accounts for the selected workspace (from any workspace member) if ($selectedWorkspace) { $outlookAccounts = OutlookAccount::where('workspace_id', $selectedWorkspace->id)->get(); $googleAccounts = GoogleAccount::where('workspace_id', $selectedWorkspace->id)->get(); $caldavAccounts = CalDAVAccount::where('workspace_id', $selectedWorkspace->id)->get(); } else { // Fallback to user's own accounts if no workspace selected $outlookAccounts = $user->outlookAccounts; $googleAccounts = $user->googleAccounts; $caldavAccounts = $user->caldavAccounts; } return view('pages.displays.create', [ 'outlookAccounts' => $outlookAccounts, 'googleAccounts' => $googleAccounts, 'caldavAccounts' => $caldavAccounts, 'workspaces' => $workspaces, 'defaultWorkspace' => $selectedWorkspace ?? $user->primaryWorkspace(), ]); } /** * @throws Exception */ public function store(CreateDisplayRequest $request): RedirectResponse { $validatedData = $request->validated(); $provider = $validatedData['provider']; $accountId = $validatedData['account']; // Check on access to create multiple displays (workspace-aware Pro check) if (auth()->user()->shouldUpgradeForCurrentWorkspace()) { return redirect()->back()->with('error', 'You require an active Pro license to create multiple displays.'); } // Validate the existence of the appropriate account based on provider match ($provider) { 'outlook' => OutlookAccount::findOrFail($accountId), 'google' => GoogleAccount::findOrFail($accountId), 'caldav' => CalDAVAccount::findOrFail($accountId), default => throw new \InvalidArgumentException('Invalid provider') }; $user = auth()->user(); // Get workspace from request, session (selected workspace), or default to primary $workspaceId = $validatedData['workspace_id'] ?? session()->get('selected_workspace_id') ?? $user->primaryWorkspace()?->id; if (!$workspaceId) { return redirect()->back()->with('error', 'No workspace found. Please contact support.'); } // Verify user has access to this workspace $workspace = $user->workspaces()->find($workspaceId); if (!$workspace) { return redirect()->back()->with('error', 'You do not have access to this workspace.'); } // Check if user can create displays in this workspace (owner/admin) if (!$workspace->canBeManagedBy($user)) { return redirect()->back()->with('error', 'You do not have permission to create displays in this workspace.'); } $display = DB::transaction(function () use ($validatedData, $workspace) { // Handle room or calendar selection $calendar = $this->createCalendar($validatedData, $workspace); return Display::create([ 'user_id' => auth()->id(), 'workspace_id' => $workspace->id, 'name' => $validatedData['name'], 'display_name' => $validatedData['displayName'], 'status' => DisplayStatus::READY, 'calendar_id' => $calendar->id, ]); }); if ($display) { event(new UserOnboarded($request->user(), $display)); } return redirect()->route('dashboard')->with($display ? 'success' : 'error', $display ? 'Display created! Now enter the connect code in the app on your tablet to connect it to the display.' : 'Display could not be created. Please try again later.' ); } public function updateStatus(Request $request, Display $display): RedirectResponse { $this->authorize('update', $display); $data = $request->validate([ 'status' => 'required|in:active,deactivated' ]); $display->update(['status' => $data['status']]); return redirect() ->route('dashboard') ->with('status', 'Display status has been changed.'); } public function delete(Display $display): RedirectResponse { $this->authorize('delete', $display); // Get the calendar associated with the display $calendar = $display->calendar; // Delete the display $display->eventSubscriptions()->delete(); $display->delete(); // Delete the calendar if it has no displays if ($calendar && $calendar->displays()->count() === 0) { $calendar->delete(); } return redirect() ->route('dashboard') ->with('status', 'Display has successfully been deleted.'); } private function createCalendar(array $validatedData, $workspace): Calendar { $provider = $validatedData['provider']; $accountId = $validatedData['account']; $userId = auth()->id(); if (isset($validatedData['room'])) { $roomData = explode(',', $validatedData['room']); $calendarId = $roomData[0]; $calendarName = $this->extractCalendarName($roomData[1] ?? ''); $calendar = Calendar::firstOrCreate([ 'calendar_id' => $calendarId, 'workspace_id' => $workspace->id, ], [ 'calendar_id' => $calendarId, 'user_id' => $userId, 'workspace_id' => $workspace->id, "{$provider}_account_id" => $accountId, 'name' => $calendarName, ]); Room::firstOrCreate([ 'email_address' => $calendarId, 'workspace_id' => $workspace->id, ], [ 'email_address' => $calendarId, 'user_id' => $userId, 'workspace_id' => $workspace->id, 'calendar_id' => $calendar->id, 'name' => $calendarName, ]); return $calendar; } $calendarData = explode(',', $validatedData['calendar']); $calendarName = $this->extractCalendarName($calendarData[1] ?? ''); $calendar = Calendar::firstOrCreate([ 'calendar_id' => $calendarData[0], 'workspace_id' => $workspace->id, ], [ 'user_id' => $userId, 'workspace_id' => $workspace->id, "{$provider}_account_id" => $accountId, 'calendar_id' => $calendarData[0], 'name' => $calendarName, ]); return $calendar; } /** * Extract calendar name from a value that might be a URL or a plain name. * Truncates to 255 characters to fit the database column. */ private function extractCalendarName(string $value): string { // If empty, return a default name if (empty($value)) { return 'Calendar'; } // Truncate to 255 characters to fit the database column return mb_substr($value, 0, 255); } } ================================================ FILE: backend/app/Http/Controllers/DisplaySettingsController.php ================================================ authorize('update', $display); // Check if user has Pro access for the display's workspace if (!$display->workspace_id || !auth()->user()->hasProForWorkspace($display->workspace)) { return redirect()->route('dashboard')->with('error', 'Display settings are only available for Pro users.'); } return view('pages.displays.settings', [ 'display' => $display->load('calendar') ]); } public function update(Request $request, Display $display): RedirectResponse { $this->authorize('update', $display); // Check if user has Pro access for the display's workspace if (!$display->workspace_id || !auth()->user()->hasProForWorkspace($display->workspace)) { return redirect()->route('dashboard')->with('error', 'Display settings are only available for Pro users.'); } $request->validate([ 'check_in_enabled' => 'boolean', 'booking_enabled' => 'boolean', 'calendar_enabled' => 'boolean', 'hide_admin_actions' => 'boolean', 'check_in_minutes' => 'nullable|integer|min:1|max:60', 'check_in_grace_period' => 'nullable|integer|min:1|max:30', 'cancel_permission' => 'nullable|in:all,tablet_only,none', 'border_thickness' => 'nullable|in:small,medium,large', ]); $updated = true; $updated = $updated && DisplaySettings::setCheckInEnabled( $display, $request->boolean('check_in_enabled') ); $updated = $updated && DisplaySettings::setBookingEnabled( $display, $request->boolean('booking_enabled') ); $updated = $updated && DisplaySettings::setCalendarEnabled( $display, $request->boolean('calendar_enabled') ); $updated = $updated && DisplaySettings::setAdminActionsHidden( $display, $request->boolean('hide_admin_actions') ); // Only allow updating grace period if check-in is enabled (either in request or already enabled) $checkInEnabled = $request->has('check_in_enabled') ? $request->boolean('check_in_enabled') : $display->isCheckInEnabled(); if ($checkInEnabled && $request->has('check_in_grace_period')) { $updated = $updated && DisplaySettings::setCheckInGracePeriod( $display, (int) $request->input('check_in_grace_period') ); } if ($checkInEnabled && $request->has('check_in_minutes')) { $updated = $updated && DisplaySettings::setCheckInMinutes( $display, (int) $request->input('check_in_minutes') ); } // Handle cancel permission if ($request->has('cancel_permission')) { $updated = $updated && DisplaySettings::setCancelPermission( $display, $request->input('cancel_permission') ); } // Handle border thickness if ($request->has('border_thickness')) { $updated = $updated && DisplaySettings::setBorderThickness( $display, $request->input('border_thickness') ); } if (!$updated) { return back()->withErrors(['error' => 'Failed to update settings']); } // Touch the display to update its updated_at timestamp $display->touch(); return redirect()->route('dashboard')->with('success', 'Display settings updated successfully'); } public function customization(Display $display): View { $this->authorize('update', $display); // Check if user has Pro access for the display's workspace if (!$display->workspace_id || !auth()->user()->hasProForWorkspace($display->workspace)) { return redirect()->route('dashboard')->with('error', 'Display customization is only available for Pro users.'); } return view('pages.displays.customization', [ 'display' => $display->load('calendar') ]); } public function updateCustomization(UpdateDisplayCustomizationRequest $request, Display $display): RedirectResponse { $this->authorize('update', $display); // Check if user has Pro access for the display's workspace if (!$display->workspace_id || !auth()->user()->hasProForWorkspace($display->workspace)) { return redirect()->route('dashboard')->with('error', 'Display customization is only available for Pro users.'); } $updated = true; // Handle text_available if (filled($request->input('text_available'))) { $updated = $updated && DisplaySettings::setAvailableText($display, $request->input('text_available')); } else { DisplaySettings::deleteSetting($display, 'text_available'); } // Handle text_transitioning if (filled($request->input('text_transitioning'))) { $updated = $updated && DisplaySettings::setTransitioningText($display, $request->input('text_transitioning')); } else { DisplaySettings::deleteSetting($display, 'text_transitioning'); } // Handle text_reserved if (filled($request->input('text_reserved'))) { $updated = $updated && DisplaySettings::setReservedText($display, $request->input('text_reserved')); } else { DisplaySettings::deleteSetting($display, 'text_reserved'); } // Handle text_checkin if (filled($request->input('text_checkin'))) { $updated = $updated && DisplaySettings::setCheckInText($display, $request->input('text_checkin')); } else { DisplaySettings::deleteSetting($display, 'text_checkin'); } // Handle show_meeting_title (always set, default to false if not present) $updated = $updated && DisplaySettings::setShowMeetingTitle($display, $request->boolean('show_meeting_title')); // Handle font_family if ($request->has('font_family')) { $updated = $updated && DisplaySettings::setFontFamily($display, $request->input('font_family')); } // Handle logo upload/removal if ($request->boolean('remove_logo')) { $this->imageService->removeLogoFile($display); $updated = $updated && DisplaySettings::removeLogo($display); } elseif ($request->hasFile('logo')) { $logoPath = $this->imageService->storeLogoFile($request->file('logo'), $display); if ($logoPath) { $this->imageService->removeLogoFile($display); // Remove old logo if exists $updated = $updated && DisplaySettings::setLogo($display, $logoPath); } else { $updated = false; } } // Handle background image upload/removal/default selection if ($request->boolean('remove_background_image')) { $this->imageService->removeBackgroundImageFile($display); $updated = $updated && DisplaySettings::removeBackgroundImage($display); } elseif ($request->hasFile('background_image')) { // Custom uploaded background $backgroundPath = $this->imageService->storeBackgroundImageFile($request->file('background_image'), $display); if ($backgroundPath) { $this->imageService->removeBackgroundImageFile($display); // Remove old background if exists $updated = $updated && DisplaySettings::setBackgroundImage($display, $backgroundPath); } else { $updated = false; } } elseif ($request->filled('default_background')) { // Default background selected $defaultKey = $request->input('default_background'); if (isset(\App\Services\ImageService::DEFAULT_BACKGROUNDS[$defaultKey])) { // Remove old custom uploaded background if exists $currentBackground = DisplaySettings::getBackgroundImage($display); if ($currentBackground && !isset(\App\Services\ImageService::DEFAULT_BACKGROUNDS[$currentBackground])) { $this->imageService->removeBackgroundImageFile($display); } // Store the default background key $updated = $updated && DisplaySettings::setBackgroundImage($display, $defaultKey); } } if (!$updated) { return back()->withErrors(['error' => 'Failed to update customization settings']); } // Touch the display to update its updated_at timestamp for cache busting $display->touch(); return redirect()->route('displays.customization', $display)->with('success', 'Customization settings updated successfully. Changes may take up to 1 minute to appear on your display.'); } /** * Serve display images (logo or background) */ public function serveImage(Display $display, string $type) { // Use the policy to check access for both User and Device models $this->authorize('view', $display); return $this->imageService->serveImage($display, $type); } } ================================================ FILE: backend/app/Http/Controllers/GoogleAccountsController.php ================================================ googleService = $googleService; } public function setBookingMethod(Request $request): RedirectResponse { $request->validate([ 'google_account_id' => [ 'required', Rule::exists('google_accounts', 'id')->where('user_id', auth()->id()), ], 'booking_method' => ['required', Rule::in(['service_account', 'user_account'])], ]); $googleAccount = GoogleAccount::where('id', $request->google_account_id) ->where('user_id', auth()->id()) ->firstOrFail(); $googleAccount->update([ 'booking_method' => GoogleBookingMethod::from($request->booking_method), ]); // If service account method selected, redirect to upload service account file if ($request->booking_method === 'service_account') { return redirect()->route('dashboard') ->with('open-service-account-modal', $googleAccount->id) ->with('success', 'Booking method set. Please upload your service account file.'); } return redirect()->route('dashboard')->with('success', 'Booking method has been set successfully.'); } public function auth(Request $request): RedirectResponse { $request->validate([ 'permission_type' => ['required', new Enum(PermissionType::class)], ]); $permissionType = PermissionType::from($request->permission_type); // Store permission type in session session(['google_permission_type' => $request->permission_type]); // Proceed to OAuth - booking method will be set after connection return redirect($this->googleService->getAuthUrl($permissionType)); } /** * Handle service account file upload for workspace accounts. * * @param Request $request * @return RedirectResponse */ public function uploadServiceAccount(Request $request): RedirectResponse { $request->validate([ 'google_account_id' => [ 'required', Rule::exists('google_accounts', 'id')->where('user_id', auth()->id()), ], 'service_account_file' => [ 'required', 'file', 'mimes:json', 'max:50', // Max 50KB function ($attribute, $value, $fail) { $content = file_get_contents($value->getRealPath()); $json = json_decode($content, true); if (!$json || !isset($json['type']) || $json['type'] !== 'service_account') { $fail('The file must be a valid Google Service Account JSON file.'); } $required = ['private_key', 'client_email', 'project_id']; foreach ($required as $field) { if (!isset($json[$field])) { $fail("The service account file is missing required field: {$field}"); } } }, ], ]); $googleAccount = GoogleAccount::where('id', $request->google_account_id) ->where('user_id', auth()->id()) ->firstOrFail(); // Ensure it's a workspace account if (! $googleAccount->isBusiness()) { return redirect()->route('dashboard')->with('error', 'Service account is only required for Google Workspace accounts.'); } // Store file in user-specific directory $userDir = 'google-service-accounts/' . auth()->id(); $fileName = 'google-account-' . $googleAccount->id . '-' . time() . '.json'; $filePath = $userDir . '/' . $fileName; // Delete old file if exists if ($googleAccount->service_account_file_path && Storage::exists($googleAccount->service_account_file_path)) { Storage::delete($googleAccount->service_account_file_path); } // Read file content and encrypt it before storing $fileContent = file_get_contents($request->file('service_account_file')->getRealPath()); $encryptedContent = Crypt::encryptString($fileContent); // Store encrypted file - explicitly construct path to ensure correct value is stored if (! Storage::put($filePath, $encryptedContent)) { return redirect()->route('dashboard')->with('error', 'Failed to save service account file. Please try again.'); } // Update account with file path and upgrade to WRITE permission $googleAccount->update([ 'service_account_file_path' => $filePath, 'permission_type' => PermissionType::WRITE, ]); return redirect()->route('dashboard')->with('success', 'Service account file uploaded successfully. Your account has been upgraded to Read & Write. You can now book rooms directly.'); } /** * @throws \Exception */ public function callback(): RedirectResponse { if (request()->has('error')) { return redirect()->route('dashboard')->with('error', 'Failed to connect to Google. Please try again.'); } $authCode = request('code'); $permissionType = PermissionType::from(session('google_permission_type', PermissionType::READ->value)); // Clear the session values after retrieving them session()->forget('google_permission_type'); session()->forget('google_booking_method'); // Don't set booking_method initially - will be set based on account type $googleAccount = $this->googleService->authenticateGoogleAccount($authCode, $permissionType, null); // If write permission, automatically detect account type and set booking method if ($permissionType === PermissionType::WRITE) { // Refresh account to get hosted_domain $googleAccount->refresh(); // If personal account, automatically set to USER_ACCOUNT if (!$googleAccount->isBusiness()) { $googleAccount->update([ 'booking_method' => GoogleBookingMethod::USER_ACCOUNT, ]); return redirect()->route('dashboard') ->with('success', 'Google account "' . $googleAccount->email . '" has been connected successfully.'); } // If workspace account, show booking method selection modal return redirect()->route('dashboard') ->with('success', 'Google account "' . $googleAccount->email . '" has been connected successfully.') ->with('open-google-booking-method-modal', $googleAccount->id); } return redirect()->route('dashboard')->with('success', 'Google account "' . $googleAccount->email . '" has been connected successfully.'); } public function delete(GoogleAccount $googleAccount): RedirectResponse { if ($googleAccount->calendars()->exists()) { return redirect()->route('dashboard')->with('error', 'Cannot disconnect this account because it is used by one or more displays.'); } // Delete service account file if it exists if ($googleAccount->service_account_file_path && Storage::exists($googleAccount->service_account_file_path)) { Storage::delete($googleAccount->service_account_file_path); } $googleAccount->delete(); return redirect()->route('dashboard')->with('status', 'Google account has been removed successfully.'); } } ================================================ FILE: backend/app/Http/Controllers/GoogleWebhookController.php ================================================ header('X-Goog-Channel-ID'); // Security: Require subscription ID header if (empty($subscriptionId)) { logger()->warning('Google webhook received without subscription ID', [ 'ip' => $request->ip(), 'headers' => $request->headers->all(), ]); return response('Invalid request', 400); } logger()->info("Received Google webhook for channel $subscriptionId", [ 'ip' => $request->ip(), ]); // Find the corresponding subscription in the database // Security: Only process if subscription exists (prevents cache clearing attacks) $subscription = EventSubscription::with('display') ->where('subscription_id', $subscriptionId) ->first(); if (!$subscription) { logger()->warning('Google webhook received for unknown subscription', [ 'subscriptionId' => $subscriptionId, 'ip' => $request->ip(), ]); // Return 200 to prevent subscription enumeration, but don't process return response('Notification processed', 200); } $newSyncTimestamp = now(); // Clear events cache for display cache()->forget($subscription->display->getEventsCacheKey()); // Set new point to sync from $subscription->display->updateLastEventAt($newSyncTimestamp); return response('Notification processed', 200); } } ================================================ FILE: backend/app/Http/Controllers/LicenseController.php ================================================ instanceService->getInstanceData(); // Send validation request to the license server $response = Http::acceptJson()->post(config('settings.license_server') . '/api/v1/instances/activate', [ 'instance_key' => $instanceData->instanceKey, 'license_key' => $request['license_key'], ]); if ($response->notFound()) { return back()->withErrors([ 'license_key' => 'License key was not found.', ]); } if ($response->failed()) { return back()->withErrors([ 'license_key' => 'Failed to validate license key. Please try again later.', ]); } $licenseData = LicenseData::from($response->json()['data']); if (! $licenseData->valid) { return back()->withErrors([ 'license_key' => 'License key was invalid or has been used before.', ]); } $activated = $this->instanceService->updateLicense($licenseData); if (! $activated) { return back()->withErrors([ 'license_key' => 'Instance could not be activated.', ]); } return back()->with('success', 'Thank you for supporting Spacepad! Your license key was validated successfully. Enjoy using the Pro features.'); } catch (\Exception $e) { report($e); return back()->withErrors([ 'license_key' => 'An error occurred while validating the license key. Please try again later.', ]); } } } ================================================ FILE: backend/app/Http/Controllers/OnboardingController.php ================================================ user(); $isSelfHosted = config('settings.is_self_hosted'); // Register email verified if not a social auth user and publish the registered event if (! $user->hasVerifiedEmail() && ! $user->microsoft_id && ! $user->google_id) { $user->update(['email_verified_at' => now()]); event(new UserRegistered($user)); } return view('pages.onboarding', [ 'hasUsageType' => $user->usage_type !== null, 'hasAcceptedTerms' => ! $isSelfHosted || $user->terms_accepted_at !== null, 'hasAnyAccount' => $user->hasAnyAccount(), ]); } public function updateUsageType(Request $request): RedirectResponse { $request->validate([ 'usage_type' => 'required|in:business,personal', ]); auth()->user()->update([ 'usage_type' => $request->usage_type, ]); return redirect()->route('dashboard'); } public function acceptTerms(): RedirectResponse { auth()->user()->update([ 'terms_accepted_at' => now(), ]); return redirect()->route('dashboard'); } } ================================================ FILE: backend/app/Http/Controllers/OutlookAccountsController.php ================================================ outlookService = $outlookService; } public function auth(Request $request): RedirectResponse { $request->validate([ 'permission_type' => ['required', new Enum(PermissionType::class)], ]); // Store permission type in session before redirecting to OAuth session(['outlook_permission_type' => $request->permission_type]); $permissionType = PermissionType::from($request->permission_type); return redirect($this->outlookService->getAuthUrl($permissionType)); } /** * @throws \Exception */ public function callback(): RedirectResponse { if (request()->has('error')) { return redirect()->route('dashboard')->with('error', 'Failed to connect to Outlook. Please try again.'); } $authCode = request('code'); $permissionType = PermissionType::from(session('outlook_permission_type', PermissionType::READ->value)); // Clear the session value after retrieving it session()->forget('outlook_permission_type'); $outlookAccount = $this->outlookService->authenticateOutlookAccount($authCode, $permissionType); return redirect()->route('dashboard')->with('success', 'Microsoft account "' . $outlookAccount->email . '" has been connected successfully.'); } public function delete(OutlookAccount $outlookAccount): RedirectResponse { if ($outlookAccount->calendars()->exists()) { return redirect()->route('dashboard')->with('error', 'Cannot disconnect this account because it is used by one or more displays.'); } $outlookAccount->delete(); return redirect()->route('dashboard')->with('status', 'Outlook account has been removed successfully.'); } } ================================================ FILE: backend/app/Http/Controllers/OutlookWebhookController.php ================================================ has('validationToken')) { return response($request->validationToken, 200) ->header('Content-Type', 'text/plain'); } logger()->info('Received Outlook webhook', [ 'ip' => $request->ip(), 'notification_count' => count($request->input('value', [])), ]); $newSyncTimestamp = now(); $notifications = $request->input('value', []); // Security: Limit number of notifications per request to prevent DoS if (count($notifications) > 100) { logger()->warning('Outlook webhook received too many notifications', [ 'count' => count($notifications), 'ip' => $request->ip(), ]); return response('Too many notifications', 400); } foreach ($notifications as $notification) { $subscriptionId = Arr::get($notification, 'subscriptionId'); $resource = Arr::get($notification, 'resource'); $resourceId = Arr::get($notification, 'resourceData.id'); // Check for required fields if (!$resource || !$resourceId) { logger()->warning('Resource or ResourceData was missing from request body', [ 'subscriptionId' => $subscriptionId, ]); continue; } // Security: Only process if subscription exists (prevents cache clearing attacks) $subscription = EventSubscription::with('display') ->where('subscription_id', $subscriptionId) ->first(); if (!$subscription) { logger()->warning('Outlook webhook received for unknown subscription', [ 'subscriptionId' => $subscriptionId, 'ip' => $request->ip(), ]); // Continue to next notification (don't reveal which subscriptions exist) continue; } // Clear events cache for display cache()->forget($subscription->display->getEventsCacheKey()); // Set new point to sync from $subscription->display->updateLastEventAt($newSyncTimestamp); } return response('Notification processed', 200); } } ================================================ FILE: backend/app/Http/Controllers/RoomController.php ================================================ user()->outlookAccounts()->findOrFail($id); $rooms = $this->outlookService->fetchRooms($account); return view('components.rooms.picker', [ 'rooms' => collect($rooms)->map(function (array $room) { return [ 'emailAddress' => $room['emailAddress'], 'name' => $room['displayName'] ]; })->toArray(), 'type' => Provider::OUTLOOK, ]); } catch (ConnectionException $e) { logger()->error('Outlook API connection error: ' . $e->getMessage()); return view('components.rooms.picker', [ 'rooms' => [], 'type' => Provider::OUTLOOK, 'error' => 'Could not connect to Outlook. Please try again later.' ]); } catch (\Exception $e) { logger()->error('Outlook rooms fetch error: ' . $e->getMessage()); return view('components.rooms.picker', [ 'rooms' => [], 'type' => Provider::OUTLOOK, 'error' => 'Could not fetch rooms from Outlook. Please check your permissions and try again.' ]); } } public function google(string $id): View|Factory|Application { try { $account = auth()->user()->googleAccounts()->findOrFail($id); $rooms = $this->googleService->fetchRooms($account); return view('components.rooms.picker', [ 'rooms' => collect($rooms)->map(function ($room) { return [ 'emailAddress' => $room->getResourceEmail(), 'name' => $room->getResourceName(), ]; })->toArray(), 'type' => Provider::GOOGLE, ]); } catch (GoogleException $e) { logger()->error('Google API error: ' . $e->getMessage()); // Check for insufficient permissions error if (str_contains($e->getMessage(), 'insufficientPermissions') || str_contains($e->getMessage(), 'ACCESS_TOKEN_SCOPE_INSUFFICIENT')) { return view('components.rooms.picker', [ 'rooms' => [], 'type' => Provider::GOOGLE, 'error' => 'Insufficient permissions to access Google Calendar. Please ensure you have granted all required permissions during authentication.' ]); } return view('components.rooms.picker', [ 'rooms' => [], 'type' => Provider::GOOGLE, 'error' => 'Could not fetch rooms from Google. Please check your permissions and try again.' ]); } catch (\Exception $e) { logger()->error('Google rooms fetch error: ' . $e->getMessage()); return view('components.rooms.picker', [ 'rooms' => [], 'type' => Provider::GOOGLE, 'error' => 'Could not fetch rooms from Google. Please try again later.' ]); } } } ================================================ FILE: backend/app/Http/Controllers/UsageController.php ================================================ user(); $selectedWorkspace = $user->getSelectedWorkspace(); if (!$selectedWorkspace) { abort(404, 'No workspace found'); } $usageBreakdown = $selectedWorkspace->getUsageBreakdown(); return view('pages.usage.index', [ 'workspace' => $selectedWorkspace, 'usageBreakdown' => $usageBreakdown, ]); } } ================================================ FILE: backend/app/Http/Controllers/WorkspaceController.php ================================================ validate([ 'workspace_id' => 'required|string|exists:workspaces,id', ]); $user = Auth::user(); $workspaceId = $request->input('workspace_id'); // Validate user has access to this workspace (checks membership, not Pro status) // This works for both regular users and impersonated users $workspace = $user->workspaces()->find($workspaceId); if (!$workspace) { abort(403, 'You do not have access to this workspace.'); } // Store selected workspace in session // This persists during impersonation since we're using the impersonated user's session session()->put('selected_workspace_id', $workspace->id); logger()->info('User switched workspace', [ 'user_id' => $user->id, 'workspace_id' => $workspace->id, 'workspace_name' => $workspace->name, 'is_impersonating' => session()->has('impersonating'), ]); return redirect()->route('dashboard')->with('success', "Switched to workspace: {$workspace->name}"); } } ================================================ FILE: backend/app/Http/Middleware/CheckUserActive.php ================================================ user()->isOnboarded()) { return redirect()->route('onboarding'); } return $next($request); } } ================================================ FILE: backend/app/Http/Middleware/CheckUserOnboarding.php ================================================ user()->isOnboarded()) { return redirect()->route('dashboard'); } return $next($request); } } ================================================ FILE: backend/app/Http/Middleware/UpdateLastActivity.php ================================================ check()) { /** @var Device|User $user */ $user = auth()->user(); $user->updateLastActivity(); } return $next($request); } } ================================================ FILE: backend/app/Http/Requests/API/Auth/LoginRequest.php ================================================ 'required|string', 'uid' => 'required|string', 'name' => 'required|string', ]; } /** * Attempt to authenticate the request's credentials. * * @return void * * @throws ValidationException */ public function authenticate() { $this->ensureIsNotRateLimited(); } /** * Ensure the login request is not rate limited. * * @return void * * @throws ValidationException */ public function ensureIsNotRateLimited() { if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { return; } event(new Lockout($this)); $seconds = RateLimiter::availableIn($this->throttleKey()); throw ValidationException::withMessages([ 'code' => trans('auth.throttle', [ 'seconds' => $seconds, 'minutes' => ceil($seconds / 60), ]), ]); } /** * Get the rate limiting throttle key for the request. * * @return string */ public function throttleKey() { return Str::lower($this->input('code')).'|'.$this->ip(); } } ================================================ FILE: backend/app/Http/Requests/API/BookEventRequest.php ================================================ 'required_without:start|in:15,30,45,60', 'start' => 'required_without:duration|date', 'end' => 'required_with:start|date|after:start', 'summary' => 'nullable|string|max:255', ]; } } ================================================ FILE: backend/app/Http/Requests/API/ChangeDisplayRequest.php ================================================ 'required|string|exists:displays,id', ]; } } ================================================ FILE: backend/app/Http/Requests/API/InstanceHeartbeatRequest.php ================================================ ['required', 'string'], 'license_key' => ['nullable', 'string'], 'license_valid' => ['nullable', 'boolean'], 'license_expires_at' => ['nullable', 'date'], 'is_self_hosted' => ['required', 'boolean'], 'displays_count' => ['required', 'integer', 'min:0'], 'rooms_count' => ['required', 'integer', 'min:0'], 'boards_count' => ['nullable', 'integer', 'min:0'], 'version' => ['required', 'string'], 'users' => ['required', 'array'], 'users.*.email' => ['required', 'email'], 'users.*.usage_type' => ['nullable', 'string'], 'users.*.is_unlimited' => ['nullable', 'boolean'], 'users.*.terms_accepted_at' => ['nullable', 'date'], ]; } public function messages(): array { return [ 'instance_key.required' => 'The instance key is required.', 'license_key.required' => 'The license key is required.', 'license_valid.boolean' => 'The license valid flag must be a boolean.', 'license_expires_at.date' => 'The license expiration date must be a valid date.', 'is_self_hosted.required' => 'The self-hosted flag is required.', 'displays_count.required' => 'The displays count is required.', 'displays_count.integer' => 'The displays count must be an integer.', 'displays_count.min' => 'The displays count cannot be negative.', 'rooms_count.required' => 'The rooms count is required.', 'rooms_count.integer' => 'The rooms count must be an integer.', 'rooms_count.min' => 'The rooms count cannot be negative.', 'version.required' => 'The version is required.', 'users.required' => 'The users array is required.', 'users.*.email.required' => 'Each user must have an email address.', 'users.*.email.email' => 'Each user must have a valid email address.', 'users.*.usage_type.string' => 'The usage type must be a string.', 'users.*.is_unlimited.boolean' => 'The unlimited flag must be a boolean.', 'users.*.terms_accepted_at.date' => 'The terms accepted date must be a valid date.', ]; } } ================================================ FILE: backend/app/Http/Requests/API/ValidateInstanceRequest.php ================================================ ['required', 'string'], 'license_key' => ['required', 'string'], ]; } } ================================================ FILE: backend/app/Http/Requests/ActivateLicenseRequest.php ================================================ ['required', 'string'], ]; } } ================================================ FILE: backend/app/Http/Requests/Auth/LoginRequest.php ================================================ 'required|string|email', 'g-recaptcha-response' => config('recaptchav3.sitekey') ? 'required|recaptchav3:login,0.5' : 'nullable' ]; } /** * Attempt to authenticate the request's credentials. * * @return void * * @throws ValidationException */ public function authenticate() { $this->ensureIsNotRateLimited(); if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { RateLimiter::hit($this->throttleKey()); throw ValidationException::withMessages([ 'email' => __('auth.failed'), ]); } RateLimiter::clear($this->throttleKey()); } /** * Ensure the login request is not rate limited. * * @return void * * @throws ValidationException */ public function ensureIsNotRateLimited() { if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { return; } event(new Lockout($this)); $seconds = RateLimiter::availableIn($this->throttleKey()); throw ValidationException::withMessages([ 'email' => trans('auth.throttle', [ 'seconds' => $seconds, 'minutes' => ceil($seconds / 60), ]), ]); } /** * Get the rate limiting throttle key for the request. * * @return string */ public function throttleKey() { return Str::lower($this->input('email')).'|'.$this->ip(); } } ================================================ FILE: backend/app/Http/Requests/Auth/OAuth2TokenRequest.php ================================================ 'required|string', 'full_name' => 'sometimes|string|max:255', ]; } /** * Get custom messages for validator errors. */ public function messages(): array { return [ 'token.required' => 'required', 'token.string' => 'string', 'full_name.string' => 'string', 'full_name.max' => 'max::max', ]; } } ================================================ FILE: backend/app/Http/Requests/Auth/RegisterRequest.php ================================================ 'required|string', 'email' => 'required|string|email', 'g-recaptcha-response' => config('recaptchav3.sitekey') ? 'required|recaptchav3:register,0.5' : 'nullable' ]; } } ================================================ FILE: backend/app/Http/Requests/CreateBoardRequest.php ================================================ check(); } /** * Get the validation rules that apply to the request. * * @return array */ public function rules(): array { return [ 'name' => 'required|string|max:255', 'title' => 'nullable|string|max:255', 'subtitle' => 'nullable|string|max:255', 'workspace_id' => 'required|string|exists:workspaces,id', 'show_all_displays' => 'required|boolean', 'theme' => 'nullable|string|in:dark,light,system', 'logo' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048', 'remove_logo' => 'nullable|boolean', 'show_title' => 'nullable|boolean', 'show_booker' => 'nullable|boolean', 'show_next_event' => 'nullable|boolean', 'show_transitioning' => 'nullable|boolean', 'transitioning_minutes' => 'nullable|integer|min:1|max:60', 'font_family' => 'nullable|string|in:Inter,Roboto,Open Sans,Lato,Poppins,Montserrat', 'language' => 'nullable|string|in:en,nl,fr,de,es,sv', 'view_mode' => 'nullable|string|in:card,table,grid', 'show_meeting_title' => 'nullable|boolean', 'display_ids' => [ 'nullable', 'array', function ($attribute, $value, $fail) { $showAll = filter_var(request()->input('show_all_displays'), FILTER_VALIDATE_BOOLEAN); if (!$showAll && (empty($value) || !is_array($value) || count($value) === 0)) { $fail('Please select at least one display when not showing all displays.'); } }, ], 'display_ids.*' => [ Rule::exists('displays', 'id')->where('workspace_id', $this->input('workspace_id')), ], ]; } public function messages(): array { return [ 'display_ids.required_if' => 'Please select at least one display when not showing all displays.', ]; } protected function prepareForValidation(): void { // Convert string "1" or "0" to boolean if ($this->has('show_all_displays')) { $this->merge([ 'show_all_displays' => filter_var($this->show_all_displays, FILTER_VALIDATE_BOOLEAN), ]); } // Convert checkbox values to boolean (default to true if not present for new boards) $this->merge([ 'show_title' => $this->has('show_title') ? filter_var($this->show_title, FILTER_VALIDATE_BOOLEAN) : true, 'show_booker' => $this->has('show_booker') ? filter_var($this->show_booker, FILTER_VALIDATE_BOOLEAN) : true, 'show_next_event' => $this->has('show_next_event') ? filter_var($this->show_next_event, FILTER_VALIDATE_BOOLEAN) : true, 'show_transitioning' => $this->has('show_transitioning') ? filter_var($this->show_transitioning, FILTER_VALIDATE_BOOLEAN) : true, 'transitioning_minutes' => $this->has('transitioning_minutes') ? (int) $this->transitioning_minutes : 10, 'show_meeting_title' => $this->has('show_meeting_title') ? filter_var($this->show_meeting_title, FILTER_VALIDATE_BOOLEAN) : true, ]); // Ensure display_ids is an array if ($this->has('display_ids') && !is_array($this->display_ids)) { $this->merge([ 'display_ids' => $this->display_ids ? [$this->display_ids] : [], ]); } } } ================================================ FILE: backend/app/Http/Requests/CreateDisplayRequest.php ================================================ check(); } /** * Get the validation rules that apply to the request. * * @return array */ public function rules(): array { return [ 'name' => 'required|string', 'displayName' => 'required|string', 'account' => 'required|string', 'provider' => 'required|string|in:outlook,google,caldav', 'room' => 'required_without:calendar|string', 'calendar' => 'required_without:room|string', 'workspace_id' => 'nullable|string|exists:workspaces,id', ]; } } ================================================ FILE: backend/app/Http/Requests/UpdateBoardRequest.php ================================================ check(); } /** * Get the validation rules that apply to the request. * * @return array */ public function rules(): array { return [ 'name' => 'required|string|max:255', 'title' => 'nullable|string|max:255', 'subtitle' => 'nullable|string|max:255', 'workspace_id' => 'required|string|exists:workspaces,id', 'show_all_displays' => 'required|boolean', 'theme' => 'nullable|string|in:dark,light,system', 'logo' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048', 'remove_logo' => 'nullable|boolean', 'show_title' => 'nullable|boolean', 'show_booker' => 'nullable|boolean', 'show_next_event' => 'nullable|boolean', 'show_transitioning' => 'nullable|boolean', 'transitioning_minutes' => 'nullable|integer|min:1|max:60', 'font_family' => 'nullable|string|in:Inter,Roboto,Open Sans,Lato,Poppins,Montserrat', 'language' => 'nullable|string|in:en,nl,fr,de,es,sv', 'view_mode' => 'nullable|string|in:card,table,grid', 'show_meeting_title' => 'nullable|boolean', 'display_ids' => [ 'nullable', 'array', function ($attribute, $value, $fail) { $showAll = filter_var(request()->input('show_all_displays'), FILTER_VALIDATE_BOOLEAN); if (!$showAll && (empty($value) || !is_array($value) || count($value) === 0)) { $fail('Please select at least one display when not showing all displays.'); } }, ], 'display_ids.*' => [ Rule::exists('displays', 'id')->where(function ($query) { $workspaceId = $this->input('workspace_id') ?? $this->route('board')?->workspace_id; $query->where('workspace_id', $workspaceId); }), ], ]; } public function messages(): array { return [ 'display_ids.required_if' => 'Please select at least one display when not showing all displays.', ]; } protected function prepareForValidation(): void { // Convert string "1" or "0" to boolean if ($this->has('show_all_displays')) { $this->merge([ 'show_all_displays' => filter_var($this->show_all_displays, FILTER_VALIDATE_BOOLEAN), ]); } // Retrieve the route-bound board model $board = $this->route('board'); // Convert checkbox values to boolean (if checkbox is unchecked, it won't be in request, so default to false) $this->merge([ 'show_title' => $this->has('show_title') && filter_var($this->show_title, FILTER_VALIDATE_BOOLEAN), 'show_booker' => $this->has('show_booker') && filter_var($this->show_booker, FILTER_VALIDATE_BOOLEAN), 'show_next_event' => $this->has('show_next_event') && filter_var($this->show_next_event, FILTER_VALIDATE_BOOLEAN), 'show_transitioning' => $this->has('show_transitioning') && filter_var($this->show_transitioning, FILTER_VALIDATE_BOOLEAN), 'transitioning_minutes' => $this->has('transitioning_minutes') ? (int) $this->transitioning_minutes : ($board?->transitioning_minutes ?? 10), 'show_meeting_title' => $this->has('show_meeting_title') && filter_var($this->show_meeting_title, FILTER_VALIDATE_BOOLEAN), ]); // Ensure display_ids is an array if ($this->has('display_ids') && !is_array($this->display_ids)) { $this->merge([ 'display_ids' => $this->display_ids ? [$this->display_ids] : [], ]); } } } ================================================ FILE: backend/app/Http/Requests/UpdateDisplayCustomizationRequest.php ================================================ 'nullable|string|max:64', 'text_transitioning' => 'nullable|string|max:64', 'text_reserved' => 'nullable|string|max:64', 'text_checkin' => 'nullable|string|max:64', 'show_meeting_title' => 'boolean', 'logo' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', 'background_image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', 'default_background' => 'nullable|string|in:default_1,default_2,default_3,default_4,default_5,default_6,default_7,default_8', 'remove_logo' => 'boolean', 'remove_background_image' => 'boolean', 'font_family' => 'nullable|string|in:Inter,Roboto,Open Sans,Lato,Poppins,Montserrat', ]; } } ================================================ FILE: backend/app/Http/Resources/API/DeviceResource.php ================================================ $this->id, 'name' => $this->name, 'user' => UserResource::make($this->user), 'display' => DisplayResource::make($this->display) ]; } } ================================================ FILE: backend/app/Http/Resources/API/DisplayDataResource.php ================================================ DisplayResource::make($this['display']), 'events' => EventResource::collection($this['events']), ]; } } ================================================ FILE: backend/app/Http/Resources/API/DisplayResource.php ================================================ $this->id, 'name' => $this->display_name, 'settings' => DisplaySettingsResource::make($this), ]; } } ================================================ FILE: backend/app/Http/Resources/API/DisplaySettingsResource.php ================================================ $this->isCheckInEnabled(), 'booking_enabled' => $this->isBookingEnabled(), 'calendar_enabled' => $this->isCalendarEnabled(), 'hide_admin_actions' => $this->isAdminActionsHidden(), 'check_in_minutes' => $this->getCheckInMinutes(), 'check_in_grace_period' => $this->getCheckInGracePeriod(), 'text_available' => $this->getAvailableText(), 'text_transitioning' => $this->getTransitioningText(), 'text_reserved' => $this->getReservedText(), 'text_checkin' => $this->getCheckInText(), 'show_meeting_title' => $this->getShowMeetingTitle(), 'logo_url' => $this->getLogoUrl(), 'background_image_url' => $this->getBackgroundImageUrl(), 'font_family' => $this->getFontFamily(), 'cancel_permission' => $this->getCancelPermission(), 'border_thickness' => $this->getBorderThickness(), // Feature flags 'has_custom_booking' => $this->hasCustomBooking(), ]; } } ================================================ FILE: backend/app/Http/Resources/API/EventResource.php ================================================ $this['id'], 'status' => $this['status'], 'summary' => $this['summary'], 'location' => $this['location'], 'description' => $this['description'], 'start' => $this['start']->setTimezone($timezone)->toAtomString(), 'end' => $this['end']->setTimezone($timezone)->toAtomString(), 'checkedInAt' => $this['checked_in_at']?->toAtomString(), 'timezone' => $this['timezone'], 'checkInRequired' => $this->checkInRequired(), 'source' => $this['source'] ?? null, 'isTabletBooking' => $this->isTabletBooking(), ]; } } ================================================ FILE: backend/app/Http/Resources/API/UserResource.php ================================================ $this->id, 'name' => $this->name, 'email' => $this->email, ]; } } ================================================ FILE: backend/app/Infrastructure/Cloud/LicenseService.php ================================================ withUserAgent('LemonSqueezy\Laravel/' . static::VERSION) ->accept('application/vnd.api+json') ->contentType('application/vnd.api+json') ->$method(static::API . "/{$uri}", $payload); return $response; } /** * @throws LemonSqueezyApiError * @throws LicenseKeyNotFound|MalformedDataError */ public static function getLicenseKey(string $id): array { $response = static::api('GET', "license-keys/$id"); if ($response->notFound()) { throw new LicenseKeyNotFound(); } if ($response->failed()) { throw new LemonSqueezyApiError($response['error'], (int) $response['error']); } return $response->json(); } /** * @throws LemonSqueezyApiError * @throws LicenseKeyNotFound|MalformedDataError */ public static function activateLicense(array $payload = []): array { $response = static::api('POST', 'licenses/activate', $payload); if ($response->notFound()) { throw new LicenseKeyNotFound(); } if ($response->failed()) { throw new LemonSqueezyApiError($response['error'], (int) $response['error']); } return $response->json(); } } ================================================ FILE: backend/app/Listeners/ActivateUser.php ================================================ user->update(['status' => UserStatus::ACTIVE]); } } ================================================ FILE: backend/app/Listeners/SendOnboardingCompleteNotification.php ================================================ 'onboarding_complete', 'user' => UserWebhookData::from($event->user), 'display' => DisplayWebhookData::from($event->display), 'calendar' => CalendarWebhookData::from($event->display->calendar), ]); } } ================================================ FILE: backend/app/Listeners/SendOrderCreatedNotification.php ================================================ 'order_created', 'user' => UserWebhookData::from($event->billable), 'order' => OrderWebhookData::from($event->order), ]); } } ================================================ FILE: backend/app/Listeners/SendRegistrationNotification.php ================================================ 'registration', 'user' => UserWebhookData::from($event->user), ]); } } ================================================ FILE: backend/app/Listeners/SendTrialExpiredOrCancelledNotification.php ================================================ 'trial_expired_or_cancelled', 'user' => UserWebhookData::from($event->user), ]); } } ================================================ FILE: backend/app/Listeners/SendUserActivatedAfter24hNotification.php ================================================ 'user_activated_after_24h', 'user' => UserWebhookData::from($event->user), ]); } } ================================================ FILE: backend/app/Listeners/SendUserInactiveNotification.php ================================================ 'user_inactive', 'user' => UserWebhookData::from($event->user), ]); } } ================================================ FILE: backend/app/Listeners/SendUserNotActivatedAfter24hNotification.php ================================================ 'user_not_activated_after_24h', 'user' => UserWebhookData::from($event->user), ]); } } ================================================ FILE: backend/app/Listeners/SendUserPassiveNotification.php ================================================ 'user_passive', 'user' => UserWebhookData::from($event->user), ]); } } ================================================ FILE: backend/app/Models/Board.php ================================================ 'boolean', 'show_title' => 'boolean', 'show_booker' => 'boolean', 'show_next_event' => 'boolean', 'show_transitioning' => 'boolean', 'transitioning_minutes' => 'integer', 'show_meeting_title' => 'boolean', ]; public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class, 'workspace_id'); } public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } public function displays(): BelongsToMany { return $this->belongsToMany(Display::class, 'board_displays') ->withTimestamps(); } /** * Get the query builder for displays that should be shown on this board * If show_all_displays is true, returns query for all active displays from the workspace * Otherwise, returns query for selected displays from the pivot table */ public function getDisplaysToShowQuery() { if ($this->show_all_displays) { return Display::where('workspace_id', $this->workspace_id) ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]); } return $this->displays() ->where('workspace_id', $this->workspace_id) ->whereIn('status', [DisplayStatus::READY, DisplayStatus::ACTIVE]); } /** * Get the displays that should be shown on this board * If show_all_displays is true, returns all active displays from the workspace * Otherwise, returns only the selected displays from the pivot table */ public function getDisplaysToShow() { return $this->getDisplaysToShowQuery() ->with(['settings', 'user']) ->orderBy('name') ->get(); } /** * Check if a display is included in this board */ public function hasDisplay(Display $display): bool { if ($this->show_all_displays) { return $display->workspace_id === $this->workspace_id; } return $this->displays() ->where('workspace_id', $this->workspace_id) ->where('displays.id', $display->id) ->exists(); } /** * Get the count of displays shown on this board */ public function getDisplayCountAttribute(): int { return $this->getDisplaysToShowQuery()->count(); } } ================================================ FILE: backend/app/Models/CalDAVAccount.php ================================================ AccountStatus::class, 'permission_type' => PermissionType::class, 'password' => 'encrypted', ]; public function user(): BelongsTo { return $this->belongsTo(User::class); } public function calendars(): HasMany { return $this->hasMany(Calendar::class, 'caldav_account_id'); } public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class); } } ================================================ FILE: backend/app/Models/Calendar.php ================================================ belongsTo(OutlookAccount::class, 'outlook_account_id'); } public function googleAccount(): ?BelongsTo { return $this->belongsTo(GoogleAccount::class, 'google_account_id'); } public function caldavAccount(): ?BelongsTo { return $this->belongsTo(CalDAVAccount::class, 'caldav_account_id'); } public function room(): HasOne { return $this->hasOne(Room::class); } public function displays(): HasMany { return $this->hasMany(Display::class); } public function events(): HasMany { return $this->hasMany(Event::class); } public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class, 'workspace_id'); } } ================================================ FILE: backend/app/Models/Device.php ================================================ 'datetime', ]; public function display(): BelongsTo { return $this->belongsTo(Display::class, 'display_id'); } public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class, 'workspace_id'); } } ================================================ FILE: backend/app/Models/Display.php ================================================ 'datetime', 'last_event_at' => 'datetime', 'status' => DisplayStatus::class, ]; public function calendar(): BelongsTo { return $this->belongsTo(Calendar::class, 'calendar_id'); } public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class, 'workspace_id'); } public function eventSubscriptions(): HasMany { return $this->hasMany(EventSubscription::class); } public function events(): HasMany { return $this->hasMany(Event::class); } public function devices(): HasMany { return $this->hasMany(Device::class); } public function settings(): HasMany { return $this->hasMany(DisplaySetting::class); } public function boards(): BelongsToMany { return $this->belongsToMany(Board::class, 'board_displays') ->withTimestamps(); } public function getStartTime(): Carbon { return now()->startOfDay(); } public function getEndTime(): Carbon { return now()->endOfDay(); } public function getEventsCacheKey(): string { return self::getEventsCacheKeyForDisplay($this->id); } public static function getEventsCacheKeyForDisplay(string $displayId): string { return "display:$displayId:events"; } public function isDeactivated(): bool { return $this->status === DisplayStatus::DEACTIVATED; } public function updateLastEventAt(Carbon|null $date = null): void { $this->update(['last_event_at' => $date ?? now()]); } public function updateLastSyncAt(Carbon|null $date = null): void { $this->update(['last_sync_at' => $date ?? now()]); } // Display settings convenience methods public function isCheckInEnabled(): bool { return DisplaySettings::isCheckInEnabled($this); } public function isBookingEnabled(): bool { return DisplaySettings::isBookingEnabled($this); } public function hasCustomBooking(): bool { // Check if booking is enabled in settings if (! DisplaySettings::isBookingEnabled($this)) { return false; } return true; } public function setCheckInEnabled(bool $enabled): bool { return DisplaySettings::setCheckInEnabled($this, $enabled); } public function setBookingEnabled(bool $enabled): bool { return DisplaySettings::setBookingEnabled($this, $enabled); } public function getCheckInMinutes(): int { return DisplaySettings::getCheckInMinutes($this); } public function setCheckInMinutes(int $minutes): bool { return DisplaySettings::setCheckInMinutes($this, $minutes); } public function getCheckInGracePeriod(): int { return DisplaySettings::getCheckInGracePeriod($this); } public function setCheckInGracePeriod(int $minutes): bool { return DisplaySettings::setCheckInGracePeriod($this, $minutes); } public function isCalendarEnabled(): bool { return DisplaySettings::isCalendarEnabled($this); } public function setCalendarEnabled(bool $enabled): bool { return DisplaySettings::setCalendarEnabled($this, $enabled); } public function getAvailableText(): ?string { return DisplaySettings::getAvailableText($this); } public function getTransitioningText(): ?string { return DisplaySettings::getTransitioningText($this); } public function getReservedText(): ?string { return DisplaySettings::getReservedText($this); } public function getCheckInText(): ?string { return DisplaySettings::getCheckInText($this); } public function getLogoUrl(): ?string { return app(\App\Services\ImageService::class)->getLogoUrl($this); } public function getBackgroundImageUrl(): ?string { return app(\App\Services\ImageService::class)->getBackgroundImageUrl($this); } public function getShowMeetingTitle(): bool { return DisplaySettings::getShowMeetingTitle($this); } public function getFontFamily(): string { return DisplaySettings::getFontFamily($this); } public function isAdminActionsHidden(): bool { return DisplaySettings::isAdminActionsHidden($this); } public function getCancelPermission(): string { return DisplaySettings::getCancelPermission($this); } public function getBorderThickness(): string { return DisplaySettings::getBorderThickness($this); } } ================================================ FILE: backend/app/Models/DisplaySetting.php ================================================ 'encrypted', ]; public function display(): BelongsTo { return $this->belongsTo(Display::class); } public function getValueAttribute($value) { if (!$value) { return null; } $decrypted = Crypt::decryptString($value); return match ($this->type) { 'boolean' => filter_var($decrypted, FILTER_VALIDATE_BOOLEAN), 'integer' => (int) $decrypted, 'float' => (float) $decrypted, 'array' => json_decode($decrypted, true), 'object' => json_decode($decrypted), default => $decrypted, }; } public function setValueAttribute($value) { if ($value === null) { $this->attributes['value'] = null; return; } $this->attributes['value'] = Crypt::encryptString( is_array($value) || is_object($value) ? json_encode($value) : (string) $value ); } } ================================================ FILE: backend/app/Models/Event.php ================================================ 'datetime', 'end' => 'datetime', 'checked_in_at' => 'datetime', ]; public function display(): BelongsTo { return $this->belongsTo(Display::class); } public function user(): BelongsTo { return $this->belongsTo(User::class); } public function calendar(): BelongsTo { return $this->belongsTo(Calendar::class); } /** * Check if this is a custom (user-created) event */ public function isCustomEvent(): bool { return $this->source === EventSource::CUSTOM; } /** * Check if this event was booked via the tablet * Tablet bookings have calendar_id set (even if they exist in external calendars) * Synced events from external calendars don't have calendar_id set */ public function isTabletBooking(): bool { // If it's a custom event (no external calendar), it's definitely a tablet booking if ($this->isCustomEvent()) { return true; } // If it has external_id AND calendar_id, it was created via tablet and synced to external calendar // Synced events from external calendars don't have calendar_id set return $this->external_id !== null && $this->calendar_id !== null; } /** * Check if event is currently active */ public function isActive(): bool { $now = now(); return $this->start <= $now && $this->end > $now; } /** * Check if event is upcoming (starts within next hour) */ public function isUpcoming(): bool { $now = now(); $nextHour = $now->copy()->addHour(); return $this->start > $now && $this->start <= $nextHour; } /** * Check in to this event */ public function checkIn(): void { $this->update([ 'checked_in_at' => now(), ]); } /** * Get unique identifier for external events */ public function getUniqueKey(): string { return $this->external_id ?? $this->id; } /** * Should the app require check-in for this event? */ public function checkInRequired(): bool { // Never require check-in for custom events if ($this->isCustomEvent()) { return false; } // Only require if event is upcoming, and not checked in return ! $this->checked_in_at; } } ================================================ FILE: backend/app/Models/EventSubscription.php ================================================ where('expiration', '<=', now()->toAtomString()); } public function outlookAccount(): BelongsTo { return $this->belongsTo(OutlookAccount::class, 'outlook_account_id'); } public function googleAccount(): BelongsTo { return $this->belongsTo(GoogleAccount::class, 'google_account_id'); } public function display(): BelongsTo { return $this->belongsTo(Display::class, 'display_id'); } } ================================================ FILE: backend/app/Models/GoogleAccount.php ================================================ 'datetime', 'status' => AccountStatus::class, 'permission_type' => PermissionType::class, 'booking_method' => GoogleBookingMethod::class, 'token' => 'encrypted', 'refresh_token' => 'encrypted', ]; public function user(): BelongsTo { return $this->belongsTo(User::class); } public function calendars(): HasMany { return $this->hasMany(Calendar::class, 'google_account_id'); } public function isBusiness(): bool { return !empty($this->hosted_domain); } public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class); } } ================================================ FILE: backend/app/Models/Instance.php ================================================ 'boolean', 'is_self_hosted' => 'boolean', 'users' => 'array', 'license_expires_at' => 'datetime', 'last_validated_at' => 'datetime', 'last_heartbeat_at' => 'datetime', ]; public function user(): BelongsTo { return $this->belongsTo(User::class); } } ================================================ FILE: backend/app/Models/OutlookAccount.php ================================================ 'datetime', 'status' => AccountStatus::class, 'permission_type' => PermissionType::class, 'token' => 'encrypted', 'refresh_token' => 'encrypted', ]; public function isBusiness(): bool { return !empty($this->tenant_id); } public function calendars(): HasMany { return $this->hasMany(Calendar::class, 'outlook_account_id'); } public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class); } } ================================================ FILE: backend/app/Models/PersonalAccessToken.php ================================================ belongsTo(Calendar::class, 'calendar_id'); } public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class, 'workspace_id'); } } ================================================ FILE: backend/app/Models/Setting.php ================================================ 'encrypted', ]; public function getValueAttribute($value) { if (!$value) { return null; } $decrypted = Crypt::decryptString($value); return match ($this->type) { 'boolean' => filter_var($decrypted, FILTER_VALIDATE_BOOLEAN), 'integer' => (int) $decrypted, 'float' => (float) $decrypted, 'array' => json_decode($decrypted, true), 'object' => json_decode($decrypted), default => $decrypted, }; } public function setValueAttribute($value) { if ($value === null) { $this->attributes['value'] = null; return; } $this->attributes['value'] = Crypt::encryptString( is_array($value) || is_object($value) ? json_encode($value) : (string) $value ); } } ================================================ FILE: backend/app/Models/User.php ================================================ workspaces()->exists()) { $workspace = Workspace::create([ 'name' => $user->name . "'s Workspace", ]); // Add user as owner member (use WorkspaceMember::create to generate ULID) WorkspaceMember::create([ 'workspace_id' => $workspace->id, 'user_id' => $user->id, 'role' => WorkspaceRole::OWNER, ]); } }); } /** * The attributes that are mass assignable. * * @var array */ protected $fillable = [ 'name', 'first_name', 'last_name', 'email', 'password', 'microsoft_id', 'google_id', 'status', 'usage_type', 'email_verified_at', 'last_activity_at', 'is_unlimited', 'terms_accepted_at', 'is_admin', ]; /** * The attributes that should be hidden for serialization. * * @var array */ protected $hidden = [ 'password', 'remember_token', ]; /** * The attributes that should be cast. * * @var array */ protected $casts = [ 'email_verified_at' => 'datetime', 'password' => 'hashed', 'last_activity_at' => 'datetime', 'is_unlimited' => 'boolean', 'usage_type' => UsageType::class, 'terms_accepted_at' => 'datetime', 'is_admin' => 'boolean', ]; public function outlookAccounts(): HasMany { return $this->hasMany(OutlookAccount::class); } public function googleAccounts(): HasMany { return $this->hasMany(GoogleAccount::class); } public function caldavAccounts(): HasMany { return $this->hasMany(CalDAVAccount::class); } public function displays(): HasMany { return $this->hasMany(Display::class); } public function devices(): HasMany { return $this->hasMany(Device::class); } public function rooms(): HasMany { return $this->hasMany(Room::class); } public function boards(): HasMany { return $this->hasMany(Board::class); } /** * Get workspaces owned by this user (where user has 'owner' role) */ public function ownedWorkspaces() { return $this->workspaces()->wherePivot('role', WorkspaceRole::OWNER->value); } /** * Get workspaces this user is a member of */ public function workspaces(): BelongsToMany { return $this->belongsToMany(Workspace::class, 'workspace_members') ->withPivot('role') ->withTimestamps(); } /** * Get the primary workspace for this user (first workspace where user is owner) */ public function primaryWorkspace(): ?Workspace { return $this->ownedWorkspaces()->first() ?? $this->workspaces()->first(); } /** * Get all workspaces this user has access to */ public function accessibleWorkspaces() { return $this->workspaces()->get(); } public function hasAnyDisplay(): bool { return $this->displays()->count() > 0; } public function hasAnyAccount(): bool { return $this->outlookAccounts()->count() > 0 || $this->googleAccounts()->count() > 0 || $this->caldavAccounts()->count() > 0; } /** * Get or generate a connect code for this user * * @return string 6-digit connect code */ public function getConnectCode(): string { $connectCode = cache()->get("user:$this->id:connect-code"); if (!$connectCode) { $expiresAt = now()->addMinutes(30); do { $connectCode = mt_rand(100000, 999999); } while (cache()->has("connect-code:$connectCode")); cache()->put("user:$this->id:connect-code", $connectCode, $expiresAt); cache()->put("connect-code:$connectCode", $this->id, $expiresAt); } return $connectCode; } /** * Retrieve and invalidate a connect code atomically * This ensures the code can only be used once * * @param string $code The 6-digit connect code * @return string|null The user ID associated with the code, or null if invalid/already used */ public static function pullConnectCode(string $code): ?string { // Atomically retrieve and remove the connect code from cache $userId = cache()->pull("connect-code:$code"); // If code was valid, also remove the reverse mapping if ($userId !== null) { cache()->forget("user:$userId:connect-code"); } return $userId; } public function isOnboarded(): bool { // Check if user has accounts OR if any workspace they're a member of has accounts $hasAccounts = $this->hasAnyAccount(); if (!$hasAccounts) { // Check if any workspace the user is a member of has accounts $workspaceIds = $this->workspaces()->pluck('workspaces.id')->toArray(); if (!empty($workspaceIds)) { $workspaceAccountCount = OutlookAccount::whereIn('workspace_id', $workspaceIds)->count() + GoogleAccount::whereIn('workspace_id', $workspaceIds)->count() + CalDAVAccount::whereIn('workspace_id', $workspaceIds)->count(); if ($workspaceAccountCount > 0) { $hasAccounts = true; } } } if (config('settings.is_self_hosted')) { return $this->usage_type && $this->terms_accepted_at && $hasAccounts; } return $this->usage_type && $hasAccounts; } public function hasPro(): bool { if (config('settings.is_self_hosted')) { return $this->usage_type === UsageType::PERSONAL || InstanceService::hasValidLicense(); } return $this->is_unlimited || $this->subscribed(); } /** * Check if the user has Pro for the current workspace context. * Returns true if the user has Pro OR if the selected workspace has Pro (any owner has Pro). */ public function hasProForCurrentWorkspace(): bool { // If user has Pro, they have Pro everywhere if ($this->hasPro()) { return true; } // Check if the selected workspace has Pro (any owner has Pro) $selectedWorkspace = $this->getSelectedWorkspace(); if ($selectedWorkspace && $selectedWorkspace->hasPro()) { return true; } return false; } /** * Check if the user has Pro for a specific workspace. * Returns true if the user has Pro OR if the workspace has Pro (any owner has Pro). */ public function hasProForWorkspace(Workspace $workspace): bool { // If user has Pro, they have Pro everywhere if ($this->hasPro()) { return true; } // Check if the workspace has Pro (any owner has Pro) return $workspace->hasPro(); } /** * Check if the user should be treated as a business user */ public function isBusinessUser(): bool { return $this->usage_type === UsageType::BUSINESS; } /** * Check if the user should be treated as a personal user */ public function isPersonalUser(): bool { return $this->usage_type === UsageType::PERSONAL; } /** * Check if the user should upgrade to Pro */ public function shouldUpgrade(): bool { // Self Hosted: If the user is a personal user, use a soft limit if (config('settings.is_self_hosted') && $this->isPersonalUser()) { return false; } // Cloud Hosted: If the user is a business user and doesn't have Pro, they should upgrade return ! $this->hasPro() && $this->hasAnyDisplay(); } /** * Check if the user should upgrade to Pro for the current workspace context. * Returns false if the user has Pro OR if the selected workspace has Pro. */ public function shouldUpgradeForCurrentWorkspace(): bool { // If user has Pro for current workspace, no upgrade needed if ($this->hasProForCurrentWorkspace()) { return false; } // Self Hosted: If the user is a personal user, use a soft limit if (config('settings.is_self_hosted') && $this->isPersonalUser()) { return false; } // Get the current workspace to scope the display check $selectedWorkspace = $this->getSelectedWorkspace(); if (!$selectedWorkspace) { // No workspace context, no upgrade needed return false; } // Cloud Hosted: Check if the user has any displays in the current workspace return $this->displays()->where('workspace_id', $selectedWorkspace->id)->exists(); } public function getCheckoutUrl(?string $redirectUrl = null): ?Checkout { $redirectUrl ??= route('dashboard'); if (config('settings.is_self_hosted')) { return null; } $cacheKey = "user:{$this->id}:checkout-url:{$redirectUrl}"; return cache()->remember($cacheKey, now()->addHour(), function () use ($redirectUrl) { return auth()->user()->subscribe(config('settings.cloud_hosted_pro_plan_id'))->redirectTo($redirectUrl); }); } /** * Check if the given email is allowed based on config('settings.allowed_logins') */ public static function isAllowedLogin(string $email): bool { $allowed = config('settings.allowed_logins', []); if (empty($allowed)) { return true; // No restrictions set } $email = strtolower(trim($email)); $domain = substr(strrchr($email, '@'), 1); foreach ($allowed as $allowedEntry) { $allowedEntry = strtolower($allowedEntry); if ($allowedEntry === $email || $allowedEntry === $domain) { return true; } } return false; } /** * Check if the user is an admin */ public function isAdmin(): bool { return (bool) $this->is_admin; } /** * Get the currently selected workspace (from session) or default to primary workspace * * Note: This works for all users (including non-Pro users) who are members of workspaces. * Workspace access is based on membership, not Pro status. */ public function getSelectedWorkspace(): ?Workspace { $selectedWorkspaceId = session()->get('selected_workspace_id'); if ($selectedWorkspaceId) { // Validate user has access to the selected workspace (checks membership, not Pro status) $workspace = $this->workspaces()->find($selectedWorkspaceId); if ($workspace) { return $workspace; } // If selected workspace is invalid or user no longer has access, clear it from session session()->forget('selected_workspace_id'); } // Default to primary workspace (first owned workspace, or first workspace user is a member of) return $this->primaryWorkspace(); } } ================================================ FILE: backend/app/Models/Workspace.php ================================================ belongsToMany(User::class, 'workspace_members') ->withPivot('role') ->withTimestamps(); } /** * Get all displays in this workspace */ public function displays(): HasMany { return $this->hasMany(Display::class); } /** * Get all devices in this workspace */ public function devices(): HasMany { return $this->hasMany(Device::class); } /** * Get all calendars in this workspace */ public function calendars(): HasMany { return $this->hasMany(Calendar::class); } /** * Get all rooms in this workspace */ public function rooms(): HasMany { return $this->hasMany(Room::class); } /** * Get all boards in this workspace */ public function boards(): HasMany { return $this->hasMany(Board::class); } /** * Check if a user is a member of this workspace */ public function hasMember(User $user): bool { return $this->members()->where('user_id', $user->id)->exists(); } /** * Get the owner(s) of the workspace (members with 'owner' role) */ public function owners() { return $this->members()->wherePivot('role', WorkspaceRole::OWNER->value); } /** * Check if a user is the owner of this workspace */ public function isOwnedBy(User $user): bool { return $this->members()->where('user_id', $user->id)->wherePivot('role', WorkspaceRole::OWNER->value)->exists(); } /** * Check if a user can manage this workspace (owner or admin) */ public function canBeManagedBy(User $user): bool { $member = $this->members()->where('user_id', $user->id)->first(); if (!$member) { return false; } $role = $member->pivot->role instanceof WorkspaceRole ? $member->pivot->role : WorkspaceRole::from($member->pivot->role); return $role->canManage(); } /** * Get the role of a user in this workspace */ public function getUserRole(User $user): ?WorkspaceRole { $member = $this->members()->where('user_id', $user->id)->first(); if (!$member) { return null; } $role = $member->pivot->role; return $role instanceof WorkspaceRole ? $role : WorkspaceRole::from($role); } /** * Check if this workspace has Pro (any owner has Pro) */ public function hasPro(): bool { // Only eager load subscriptions in cloud-hosted mode $ownersQuery = $this->owners(); if (!config('settings.is_self_hosted')) { $ownersQuery = $ownersQuery->with('subscriptions'); } $owners = $ownersQuery->get(); foreach ($owners as $owner) { if ($owner->hasPro()) { return true; } } return false; } /** * Get the total usage count for billing purposes * Displays count as 1x, Boards count as 2x */ public function getTotalUsageCount(): int { $displayCount = $this->displays() ->count(); $boardCount = $this->boards()->count(); return $displayCount + ($boardCount * 2); } /** * Get breakdown of usage for display */ public function getUsageBreakdown(): array { $displayCount = $this->displays() ->count(); $boardCount = $this->boards()->count(); $boardUsage = $boardCount * 2; $totalUsage = $displayCount + $boardUsage; return [ 'displays' => $displayCount, 'boards' => $boardCount, 'board_usage' => $boardUsage, 'total' => $totalUsage, ]; } } ================================================ FILE: backend/app/Models/WorkspaceMember.php ================================================ WorkspaceRole::class, ]; /** * Get the workspace this member belongs to */ public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class); } /** * Get the user member */ public function user(): BelongsTo { return $this->belongsTo(User::class); } } ================================================ FILE: backend/app/Notifications/MagicLoginNotification.php ================================================ */ public function via(object $notifiable): array { return ['mail']; } /** * Get the mail representation of the notification. */ public function toMail(object $notifiable): MailMessage { return (new MailMessage) ->subject('🔐 Your Login Link - ' . config('app.name')) ->greeting('Hello! 👋') ->line('We\'ve generated a secure login link just for you.') ->line('Click the button below to access your account:') ->action('Log in to ' . config('app.name'), $this->loginUrl) ->line('This link will expire in 15 minutes for your security.') ->line('If you didn\'t request this login link, you can safely ignore this email.') ->salutation('Best regards,'); } /** * Get the array representation of the notification. * * @return array */ public function toArray(object $notifiable): array { return [ // ]; } } ================================================ FILE: backend/app/Observers/EventObserver.php ================================================ clearDisplayCache($event); } /** * Handle the Event "updated" event. */ public function updated(Event $event): void { $this->clearDisplayCache($event); } /** * Handle the Event "deleted" event. */ public function deleted(Event $event): void { $this->clearDisplayCache($event); } /** * Clear the cache for the display's events if display is attached. */ protected function clearDisplayCache(Event $event): void { if ($event->display_id) { cache()->forget(Display::getEventsCacheKeyForDisplay($event->display_id)); } } } ================================================ FILE: backend/app/Policies/BoardPolicy.php ================================================ hasProForCurrentWorkspace() && $user->getSelectedWorkspace() !== null; } /** * Determine whether the user can view the board. */ public function view(User $user, Board $board): bool { // User must be a member of the workspace return $board->workspace && $board->workspace->hasMember($user); } /** * Determine whether the user can update the board. */ public function update(User $user, Board $board): bool { // User must be able to manage the workspace (owner/admin) if (!$board->workspace_id) { return false; } $workspace = $board->workspace; return $workspace && $workspace->canBeManagedBy($user); } /** * Determine whether the user can delete the board. */ public function delete(User $user, Board $board): bool { // User must be able to manage the workspace (owner/admin) if (!$board->workspace_id) { return false; } $workspace = $board->workspace; return $workspace && $workspace->canBeManagedBy($user); } } ================================================ FILE: backend/app/Policies/DisplayPolicy.php ================================================ isOnboarded(); } /** * Determine whether the user can update the display. */ public function update(User $user, Display $display): bool { if (!$display->workspace_id) { return false; } $workspace = $display->workspace; return $workspace && $workspace->canBeManagedBy($user); } /** * Determine whether the user can delete the display. */ public function delete(User $user, Display $display): bool { if (!$display->workspace_id) { return false; } $workspace = $display->workspace; return $workspace && $workspace->canBeManagedBy($user); } /** * Determine whether the user can view the display. */ public function view($user, Display $display): bool { // Handle User model if ($user instanceof User) { if (!$display->workspace_id) { return false; } $workspace = $display->workspace; return $workspace && $workspace->hasMember($user); } // Handle Device model if ($user instanceof Device) { return $user->display_id === $display->id; } return false; } } ================================================ FILE: backend/app/Providers/AppServiceProvider.php ================================================ environment('testing')) { LemonSqueezy::ignoreMigrations(); } } /** * Bootstrap any application services. */ public function boot(): void { Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); EventModel::observe(EventObserver::class); Event::listen(function (SocialiteWasCalled $event) { $event->extendSocialite('microsoft', Provider::class); }); } } ================================================ FILE: backend/app/Providers/AuthServiceProvider.php ================================================ */ protected $policies = [ Display::class => DisplayPolicy::class, Board::class => BoardPolicy::class, ]; /** * Register any authentication / authorization services. */ public function boot(): void { $this->registerPolicies(); } } ================================================ FILE: backend/app/Services/CalDAVService.php ================================================ client = new Client([ 'baseUri' => '', 'userName' => '', 'password' => '', ]); } private function configureClient(CalDAVAccount $account): void { $this->client = new Client([ 'baseUri' => rtrim($account->url, '/'), 'userName' => $account->username, 'password' => $account->password, ]); } public function fetchCalendars(CalDAVAccount $account): array { $this->configureClient($account); try { $response = $this->client->propFind("$account->url/calendars/$account->username/", [ '{DAV:}resourcetype', '{DAV:}displayname', '{urn:ietf:params:xml:ns:caldav}calendar-description', ], 1); $calendars = []; foreach ($response as $path => $properties) { if (!isset($properties['{DAV:}resourcetype'])) { continue; } $resourceType = $properties['{DAV:}resourcetype']; if (!$resourceType instanceof ResourceType || !$resourceType->is('{urn:ietf:params:xml:ns:caldav}calendar')) { continue; } $calendars[] = [ 'id' => $path, 'name' => $properties['{DAV:}displayname'] ?? basename($path), 'description' => $properties['{urn:ietf:params:xml:ns:caldav}calendar-description'] ?? '', ]; } return $calendars; } catch (\Exception $e) { throw new \Exception("Failed to fetch calendars: " . $e->getMessage()); } } public function fetchEvents( CalDAVAccount $caldavAccount, string $calendarId, Carbon $startDateTime, Carbon $endDateTime ): array { $this->configureClient($caldavAccount); $query = << XML; try { $response = $this->client->request('REPORT', $calendarId, $query, [ 'Depth' => 1, 'Content-Type' => 'application/xml; charset=utf-8', ]); if ($response['statusCode'] !== 207) { throw new \Exception("Unexpected status code {$response['statusCode']}"); } // Parse multi-status response with calendar-data entries $body = $response['body']; $events = []; preg_match_all('/<(?:cal|C):calendar-data[^>]*>(.*?)<\/(?:cal|C):calendar-data>/s', $body, $matches); foreach ($matches[1] as $icalData) { $vcalendar = Reader::read($icalData); foreach ($vcalendar->select('VEVENT') as $vevent) { $start = $vevent->DTSTART->getDateTime(); $end = $vevent->DTEND->getDateTime(); $events[] = [ 'id' => (string) $vevent->UID, 'summary' => (string) $vevent->SUMMARY, 'description' => (string) $vevent->DESCRIPTION, 'location' => (string) $vevent->LOCATION, 'start' => $start->format('Y-m-d\TH:i:sP'), 'end' => $end->format('Y-m-d\TH:i:sP'), 'timezone' => $start->getTimezone()->getName() ?? $end->getTimezone()->getName() ?? 'UTC', 'isAllDay' => $vevent->DTSTART->hasTime() === false, ]; } } return $events; } catch (\Exception $e) { throw new \Exception("CalDAV request failed: " . $e->getMessage()); } } /** * Create an event in CalDAV calendar. * * @param CalDAVAccount $caldavAccount * @param string $calendarId * @param string $summary * @param Carbon $start * @param Carbon $end * @return string|null Event UID * @throws \Exception */ public function createEvent( CalDAVAccount $caldavAccount, string $calendarId, string $summary, Carbon $start, Carbon $end ): ?string { $this->configureClient($caldavAccount); // Create VCalendar with VEvent $vcalendar = new VCalendar(); $vevent = $vcalendar->createComponent('VEVENT'); $uid = Str::uuid()->toString(); $vevent->UID = $uid; $vevent->SUMMARY = $summary; // Set DTSTART and DTEND - VObject handles DateTime objects directly $vevent->DTSTART = $start; $vevent->DTEND = $end; $vevent->DTSTAMP = now(); $vcalendar->add($vevent); // Generate event filename $filename = $uid . '.ics'; try { // PUT the event to the calendar - trim trailing slash from calendarId to avoid double slashes $path = rtrim($calendarId, '/') . '/' . $filename; $response = $this->client->request('PUT', $path, $vcalendar->serialize(), [ 'Content-Type' => 'text/calendar; charset=utf-8', ]); if ($response['statusCode'] >= 200 && $response['statusCode'] < 300) { return $uid; } throw new \Exception("Failed to create CalDAV event: Status code {$response['statusCode']}"); } catch (\Exception $e) { throw new \Exception('Failed to create CalDAV event: ' . $e->getMessage()); } } /** * Delete an event from CalDAV calendar. * * @param CalDAVAccount $caldavAccount * @param string $calendarId * @param string $eventId * @return void * @throws \Exception */ public function deleteEvent( CalDAVAccount $caldavAccount, string $calendarId, string $eventId ): void { $this->configureClient($caldavAccount); // Generate event filename (assuming .ics extension) $filename = $eventId . '.ics'; try { // DELETE the event from the calendar - trim trailing slash from calendarId to avoid double slashes $path = rtrim($calendarId, '/') . '/' . $filename; $response = $this->client->request('DELETE', $path); if ($response['statusCode'] < 200 || $response['statusCode'] >= 300) { throw new \Exception("Failed to delete CalDAV event: Status code {$response['statusCode']}"); } } catch (\Exception $e) { throw new \Exception('Failed to delete CalDAV event: ' . $e->getMessage()); } } /** * Check if the CalDAV server is accessible and credentials are valid * * @param string $url The CalDAV server URL * @param string $username The username for authentication * @param string $password The password for authentication * @return array{success: bool, message: string} Connection test result */ public function checkConnection(string $url, string $username, string $password): array { try { $settings = [ 'baseUri' => rtrim($url, '/'), 'userName' => $username, 'password' => $password, ]; $client = new Client($settings); // Try to fetch the principal URL to verify connection $response = $client->propFind('', [ '{DAV:}current-user-principal' ], 0); if (isset($response['{DAV:}current-user-principal'])) { return [ 'success' => true, 'message' => 'Successfully connected to CalDAV server' ]; } return [ 'success' => false, 'message' => 'Failed to connect to CalDAV server: Could not find principal URL' ]; } catch (\Exception $e) { return [ 'success' => false, 'message' => 'Failed to connect to CalDAV server: ' . $e->getMessage() ]; } } } ================================================ FILE: backend/app/Services/DisplayService.php ================================================ with('settings')->findOrFail($displayId); } /** * Validate if a display is permitted to perform actions. * * @param string|null $displayId * @param string $deviceId * @param array $options ['pro' => true, 'booking' => true] * @return PermissionResult */ public function validateDisplayPermission(?string $displayId, string $deviceId, array $options = []): PermissionResult { $device = Device::with('user.workspaces')->find($deviceId); if (!$device || !$device->user_id) { return new PermissionResult(false, 'Device not found', 404); } $user = $device->user; if (!$user) { return new PermissionResult(false, 'User not found', 404); } if (!$displayId) { return new PermissionResult(false, 'Display not found', 404); } // Get all workspace IDs the user is a member of $workspaceIds = $user->workspaces->pluck('id'); if ($workspaceIds->isEmpty()) { return new PermissionResult(false, 'User is not a member of any workspace', 403); } // Find display in any of the user's workspaces $display = Display::with('workspace.members') ->whereIn('workspace_id', $workspaceIds) ->find($displayId); if (!$display) { return new PermissionResult(false, 'Display not found', 404); } if ($display->isDeactivated()) { return new PermissionResult(false, 'Display is deactivated', 400); } // Pro feature check: check if any workspace owner has Pro if (!empty($options['pro'])) { if (!$display->workspace->hasPro()) { return new PermissionResult(false, 'This is a Pro feature. Please upgrade to Pro to use this feature.', 403); } } if (!empty($options['booking']) && !$display->isBookingEnabled()) { return new PermissionResult(false, 'Booking is not enabled for this display', 403); } // Add more checks as needed return new PermissionResult(true); } } ================================================ FILE: backend/app/Services/EventService.php ================================================ withCount('eventSubscriptions')->findOrFail($display); // Update last sync timestamp $display->updateLastSyncAt(); // Release rooms that have not been checked in $this->processExpiredCheckIns($display); // Cache events if caching is enabled and the display has an event subscription $cachingEnabled = config('services.events.cache_enabled') && $display->event_subscriptions_count > 0; if ($cachingEnabled) { $events = cache()->remember( key: $display->getEventsCacheKey(), ttl: now()->addMinutes(15), callback: fn() => $this->getAllEvents($display) ); } else { $events = $this->getAllEvents($display); } return $events; } /** * Book a room for a given duration. Handles all business logic. * If the connected account has write permissions, creates the event via API. * Otherwise, creates a custom event locally. * Throws exception if not allowed. */ public function bookRoom(string $displayId, string $userId, string $summary, ?int $duration = null, ?Carbon $start = null, ?Carbon $end = null): Event { // Normalize summary: trim and replace empty with default $summary = trim($summary); if (empty($summary)) { $summary = __('Reserved'); } // Validate duration if provided if ($duration !== null) { if (!is_int($duration) || $duration <= 0) { throw new Exception('Duration must be a positive integer greater than 0'); } $start = now(); $end = $start->copy()->addMinutes($duration); } else { // Validate that both start and end are provided if ($start === null || $end === null) { throw new Exception('Either duration or both start and end times must be provided'); } // Validate that start is before end if (!$start->lt($end)) { throw new Exception('Start time must be before end time'); } } // Check for any conflicting events (both custom and external) if ($this->hasConflictingEvents($displayId, $start, $end)) { throw new Exception('Cannot book room: there are conflicting events during this time period'); } $display = Display::query() ->with(['calendar.outlookAccount', 'calendar.googleAccount', 'calendar.caldavAccount', 'calendar.room']) ->findOrFail($displayId); $calendar = $display->calendar; // Check if we have write permissions and can create via API if ($calendar) { $hasWritePermissions = false; $account = null; // Check Outlook account if ($calendar->outlook_account_id && $calendar->outlookAccount) { $account = $calendar->outlookAccount; $hasWritePermissions = $account->permission_type === PermissionType::WRITE; } // Check Google account elseif ($calendar->google_account_id && $calendar->googleAccount) { $account = $calendar->googleAccount; $hasWritePermissions = $account->permission_type === PermissionType::WRITE; } // Check CalDAV account elseif ($calendar->caldav_account_id && $calendar->caldavAccount) { $account = $calendar->caldavAccount; $hasWritePermissions = $account->permission_type === PermissionType::WRITE; } // If we have write permissions, create event via API if ($hasWritePermissions && $account) { try { $externalEventId = null; // Create event via Outlook API if ($calendar->outlook_account_id) { $eventData = $this->outlookService->createEvent( $calendar->outlookAccount, $calendar, $summary, $start, $end ); $externalEventId = $eventData['id'] ?? null; } // Create event via Google API elseif ($calendar->google_account_id) { $googleEvent = $this->googleService->createEvent( $calendar->googleAccount, $calendar, $summary, $start, $end ); $externalEventId = $googleEvent?->getId(); } // Create event via CalDAV API elseif ($calendar->caldav_account_id) { $externalEventId = $this->caldavService->createEvent( $calendar->caldavAccount, $calendar->calendar_id, $summary, $start, $end ); } // Validate that external event ID was returned // If API creation succeeded but no ID was returned, we can't track/cancel the event if (!is_string($externalEventId) || $externalEventId === '') { throw new Exception('External event was created but no external ID was returned. Cannot track or cancel this event.'); } // Clear cache to force refetch Cache::forget($display->getEventsCacheKey()); // Create event in database immediately with external_id (optimistic approach) // Wrap in transaction to ensure atomicity - if DB write fails, we know the external event exists but isn't tracked // Note: source is set to the calendar system (GOOGLE/OUTLOOK/CALDAV) so cancellation knows which API to use // calendar_id is set to identify this as a tablet booking (isTabletBooking() checks for calendar_id) $event = DB::transaction(function () use ($displayId, $userId, $calendar, $externalEventId, $start, $end, $summary) { return Event::create([ 'display_id' => $displayId, 'user_id' => $userId, 'calendar_id' => $calendar->id, // Set calendar_id to mark as tablet booking 'external_id' => $externalEventId, 'status' => EventStatus::CONFIRMED, 'source' => $calendar->google_account_id ? EventSource::GOOGLE : ($calendar->outlook_account_id ? EventSource::OUTLOOK : EventSource::CALDAV), 'start' => $start, 'end' => $end, 'summary' => $summary, 'timezone' => config('app.timezone', 'UTC'), ]); }); // Wait for Google Calendar API to reflect the change (with retry logic) // This ensures the event appears in API queries if ($calendar->google_account_id) { $this->waitForEventInApi($calendar, $externalEventId, $start, $end, true); } // Sync to update event details from API (times, summary, etc. might differ slightly) $this->syncAllExternalEventsForDisplay($display); // Refresh event from database after sync $event->refresh(); return $event; } catch (\Exception $e) { // If API creation fails or external ID is missing, throw exception // Don't silently fall back to custom event - this would create duplicates logger()->error('Failed to create external event or track it in database', [ 'error' => $e->getMessage(), 'display_id' => $displayId, 'start' => $start->toIso8601String(), 'end' => $end->toIso8601String(), ]); throw $e; } } } // Fall back to creating a custom event (no write permissions or API creation failed) return Event::create([ 'display_id' => $displayId, 'user_id' => $userId, 'status' => EventStatus::CONFIRMED, 'source' => EventSource::CUSTOM, 'start' => $start, 'end' => $end, 'summary' => $summary, 'timezone' => config('app.timezone', 'UTC'), ]); } /** * Cancel an event. If the event was created via API and account has write permissions, * deletes it from the external calendar. Otherwise, marks it as cancelled. */ public function cancelEvent(string $eventId, string $displayId): void { $event = Event::query() ->where('display_id', $displayId) ->with(['display.calendar.outlookAccount', 'display.calendar.googleAccount', 'display.calendar.caldavAccount', 'display.calendar.room', 'display.settings']) ->find($eventId); if (!$event) { throw new Exception('Event not found or not accessible'); } $display = $event->display; $calendar = $display->calendar; // Check cancel permission setting $cancelPermission = DisplaySettings::getCancelPermission($display); if ($cancelPermission === 'none') { throw new Exception('Cancelling events is not allowed on this display'); } if ($cancelPermission === 'tablet_only' && !$event->isTabletBooking()) { throw new Exception('Only events booked via this tablet can be cancelled'); } // If event has external_id and calendar has write permissions, delete via API if ($event->external_id && $calendar) { $hasWritePermissions = false; $account = null; // Check Outlook account if ($calendar->outlook_account_id && $calendar->outlookAccount) { $account = $calendar->outlookAccount; $hasWritePermissions = $account->permission_type === PermissionType::WRITE; } // Check Google account elseif ($calendar->google_account_id && $calendar->googleAccount) { $account = $calendar->googleAccount; $hasWritePermissions = $account->permission_type === PermissionType::WRITE; } // Check CalDAV account elseif ($calendar->caldav_account_id && $calendar->caldavAccount) { $account = $calendar->caldavAccount; $hasWritePermissions = $account->permission_type === PermissionType::WRITE; } // If we have write permissions, delete event via API if ($hasWritePermissions && $account) { try { // Delete event via Outlook API if ($calendar->outlook_account_id) { $this->outlookService->deleteEvent( $calendar->outlookAccount, $calendar, $event->external_id ); } // Delete event via Google API elseif ($calendar->google_account_id) { $this->googleService->deleteEvent( $calendar->googleAccount, $calendar, $event->external_id ); } // Delete event via CalDAV API elseif ($calendar->caldav_account_id) { $this->caldavService->deleteEvent( $calendar->caldavAccount, $calendar->calendar_id, $event->external_id ); } // Clear cache to force refetch Cache::forget($display->getEventsCacheKey()); // Store external_id before deletion $externalEventId = $event->external_id; $eventStart = $event->start; $eventEnd = $event->end; // Wait for Google Calendar API to reflect the deletion (with retry logic) // This ensures the event is removed from API queries before we delete from DB if ($calendar->google_account_id) { $this->waitForEventInApi($calendar, $externalEventId, $eventStart, $eventEnd, false); } // Delete the event from database (it's been removed from external calendar) $event->update(['status' => EventStatus::CANCELLED]); // Sync to ensure everything is in sync $this->syncAllExternalEventsForDisplay($display); return; } catch (\Exception $e) { // If API deletion fails, fall back to marking as cancelled logger()->warning('Failed to delete event via API, marking as cancelled', [ 'error' => $e->getMessage(), 'event_id' => $eventId, 'display_id' => $displayId, ]); } } } // For custom events (no external_id), delete them directly since they don't exist externally if ($event->isCustomEvent()) { $event->delete(); Cache::forget($display->getEventsCacheKey()); return; } // Fall back to marking as cancelled (for external events if API deletion failed) $event->update(['status' => EventStatus::CANCELLED]); } /** * @throws Exception */ private function getAllEvents(Display $display): Collection { // Make sure external events are up to date $this->syncAllExternalEventsForDisplay($display); // Then query all events return Event::query() ->where('display_id', $display->id) ->where('start', '>=', $display->getStartTime()) ->where('start', '<', $display->getEndTime()) ->orderBy('start') ->get(); } /** * @throws Exception */ private function syncAllExternalEventsForDisplay(Display $display): void { $calendar = $display->calendar() ->with(['googleAccount', 'outlookAccount', 'caldavAccount', 'room']) ->first(); // Handle Google integration if ($calendar->google_account_id) { $googleEvents = $this->fetchGoogleEvents($calendar, $display); $this->syncExternalEvents($display, EventSource::GOOGLE, $googleEvents); } // Handle Outlook integration if ($calendar->outlook_account_id) { $outlookEvents = $this->fetchOutlookEvents($calendar, $display); $this->syncExternalEvents($display, EventSource::OUTLOOK, $outlookEvents); } // Handle CalDAV integration if ($calendar->caldav_account_id) { $caldavEvents = $this->fetchCalDAVEvents($calendar, $display); $this->syncExternalEvents($display, EventSource::CALDAV, $caldavEvents); } } /** * @param Calendar $calendar * @param Display $display * @return Collection * @throws Exception */ private function fetchOutlookEvents(Calendar $calendar, Display $display): Collection { $events = []; // Fetch events by user (room) if ($calendar->room) { $events = $this->outlookService->fetchEventsByUser( outlookAccount: $calendar->outlookAccount, emailAddress: $calendar->calendar_id, startDateTime: $display->getStartTime(), endDateTime: $display->getEndTime(), ); } // Fetch events by calendar if (! $calendar->room) { $events = $this->outlookService->fetchEventsByCalendar( outlookAccount: $calendar->outlookAccount, calendarId: $calendar->calendar_id, startDateTime: $display->getStartTime(), endDateTime: $display->getEndTime(), ); } return collect($events)->map(fn($e) => $this->sanitizeOutlookEvent($e)); } /** * @param Calendar $calendar * @param Display $display * @return Collection * @throws \Exception */ private function fetchGoogleEvents(Calendar $calendar, Display $display): Collection { $events = $this->googleService->fetchEvents( googleAccount: $calendar->googleAccount, calendarId: $calendar->calendar_id, startDateTime: $display->getStartTime(), endDateTime: $display->getEndTime(), ); // Get room email if this calendar has a room $roomEmail = $calendar->room?->email_address; // Filter out cancelled events and events where the room declined as attendee return collect($events) ->filter(function ($event) use ($roomEmail) { // Filter out cancelled events if ($event->getStatus() === 'cancelled') { return false; } // If this calendar has a room, check if the room declined the event if ($roomEmail && $event->getAttendees()) { foreach ($event->getAttendees() as $attendee) { // Check if this attendee is the room and if it declined if (strtolower($attendee->getEmail()) === strtolower($roomEmail)) { $responseStatus = $attendee->getResponseStatus(); // Filter out events where the room declined if ($responseStatus === 'declined') { return false; } } } } return true; }) ->map(fn($e) => $this->sanitizeGoogleEvent($e)); } /** * @param Calendar $calendar * @param Display $display * @return Collection * @throws Exception */ private function fetchCalDAVEvents(Calendar $calendar, Display $display): Collection { $events = $this->caldavService->fetchEvents( caldavAccount: $calendar->caldavAccount, calendarId: $calendar->calendar_id, startDateTime: $display->getStartTime(), endDateTime: $display->getEndTime(), ); return collect($events)->map(fn($e) => $this->sanitizeCalDAVEvent($e)); } /** * @param array $outlookEvent * @return array */ public function sanitizeOutlookEvent(array $outlookEvent): array { $summary = $this->cleanSubject($outlookEvent['subject']); $description = $this->cleanBody( Arr::has($outlookEvent, 'body') && is_array($outlookEvent['body']) ? $outlookEvent['body']['content'] : $outlookEvent['bodyPreview'] ); // Get location if available $location = $outlookEvent['location']['displayName'] ?? ''; // Handle all-day event $isAllDay = $outlookEvent['isAllDay'] ?? false; // Extract date for all-day events, or dateTime with timeZone for regular events $start = $isAllDay ? ['dateTime' => explode('T', $outlookEvent['start']['dateTime'])[0]] : ['dateTime' => $outlookEvent['start']['dateTime'], 'timeZone' => $outlookEvent['start']['timeZone']]; $end = $isAllDay ? ['dateTime' => explode('T', $outlookEvent['end']['dateTime'])[0]] : ['dateTime' => $outlookEvent['end']['dateTime'], 'timeZone' => $outlookEvent['end']['timeZone']]; return [ 'id' => $outlookEvent['id'], 'summary' => $summary, 'location' => $location, 'description' => $description, 'start' => $start['dateTime'], 'end' => $end['dateTime'], 'timezone' => $outlookEvent['start']['timeZone'] ?? $outlookEvent['end']['timeZone'] ?? 'UTC', 'isAllDay' => $isAllDay ]; } /** * @param GoogleEvent $googleEvent * @return array */ public function sanitizeGoogleEvent(GoogleEvent $googleEvent): array { $start = $googleEvent->getStart(); $end = $googleEvent->getEnd(); // Handle all-day event - Google Calendar uses 'date' field for all-day events $isAllDay = $start->getDate() !== null; return [ 'id' => $googleEvent->getId(), 'summary' => $this->cleanSubject($googleEvent->getSummary()), 'location' => $googleEvent->getLocation(), 'description' => $googleEvent->getDescription(), 'start' => $isAllDay ? $start->getDate() : $start->getDateTime(), 'end' => $isAllDay ? $end->getDate() : $end->getDateTime(), 'timezone' => $start->getTimeZone() ?? $end->getTimeZone() ?? 'UTC', 'isAllDay' => $isAllDay ]; } /** * @param array $caldavEvent * @return array */ public function sanitizeCalDAVEvent(array $caldavEvent): array { return [ 'id' => $caldavEvent['id'], 'summary' => $this->cleanSubject($caldavEvent['summary']), 'location' => $caldavEvent['location'], 'description' => $this->cleanBody($caldavEvent['description']), 'start' => $caldavEvent['start'], 'end' => $caldavEvent['end'], 'timezone' => $caldavEvent['timezone'], 'isAllDay' => $caldavEvent['isAllDay'] ]; } private function cleanSubject(?string $subject): string { // Ensure variable is set $subject ??= ""; return trim($subject); // Basic cleanup, can be expanded if necessary } private function cleanBody(?string $body): string { // Ensure variable is set $body ??= ""; // Replace newlines and carriage returns as in JS version $body = str_replace("\r", "\n", $body); return str_replace("\n", ' ', $body); } /** * Safely truncate description to prevent database errors. * MEDIUMTEXT can hold up to 16MB, but we'll limit to 10MB for safety. */ private function truncateDescription(?string $description): string { if ($description === null || $description === '') { return ''; } // MEDIUMTEXT limit is 16,777,215 bytes, but we'll use 10MB (10,485,760 bytes) as a safe limit $maxLength = 10 * 1024 * 1024; // 10MB in bytes if (strlen($description) > $maxLength) { // Truncate and add ellipsis return substr($description, 0, $maxLength - 3) . '...'; } return $description; } /** * Check if there are any conflicting events for a display in a given time range. * * @param string $displayId * @param Carbon $start * @param Carbon $end * @return bool */ public function hasConflictingEvents(string $displayId, Carbon $start, Carbon $end): bool { return Event::query() ->where('display_id', $displayId) ->where('status', '!=', EventStatus::CANCELLED) ->where(function ($q) use ($start, $end) { // Check if an existing event starts within the new booking period (exclusive of end time) $q->where('start', '>=', $start)->where('start', '<', $end) // Check if an existing event ends within the new booking period (exclusive of start time) ->orWhere(function ($q2) use ($start, $end) { $q2->where('end', '>', $start)->where('end', '<=', $end); }) // Check if an existing event completely contains the new booking (exclusive boundaries) ->orWhere(function ($q2) use ($start, $end) { $q2->where('start', '<', $start)->where('end', '>', $end); }); }) ->exists(); } /** * Sync external events to the database for a display and source. * * @param Display $display * @param string $source * @param Collection $externalEvents */ public function syncExternalEvents(Display $display, string $source, Collection $externalEvents): void { $existing = Event::query() ->where('display_id', $display->id) ->where('source', $source) ->get() ->keyBy('external_id'); $seenIds = []; $externalEvents = $externalEvents->filter(fn ($event) => ! $event['isAllDay']); foreach ($externalEvents as $ext) { $externalId = $ext['id']; $seenIds[] = $externalId; $event = $existing->get($externalId); // If event doesn't exist, create it if (!$event) { $event = new Event(); $event->id = (string) Str::ulid(); // Manually generate ULID since saveQuietly() disables creating event $event->display_id = $display->id; $event->user_id = $display->user_id; $event->source = $source; $event->external_id = $externalId; $event->status = EventStatus::CONFIRMED; } else { // If event exists but is cancelled, don't reactivate it // (it was likely just cancelled and Google API hasn't updated yet) if ($event->status === EventStatus::CANCELLED) { continue; } } // Parse datetime strings and convert to UTC for storage // Carbon will automatically parse the timezone from the string and convert to UTC $event->start = Carbon::parse($ext['start'])->utc(); $event->end = Carbon::parse($ext['end'])->utc(); $event->summary = $ext['summary']; $event->description = $this->truncateDescription($ext['description'] ?? null); $event->location = $ext['location']; $event->timezone = $ext['timezone']; // Ensure status is confirmed when syncing (unless it was cancelled) if ($event->status !== EventStatus::CANCELLED) { $event->status = EventStatus::CONFIRMED; } // Save without firing events to prevent N+1 queries (cache cleared once at end) $event->saveQuietly(); } // Delete events that no longer exist externally Event::query() ->where('display_id', $display->id) ->where('source', $source) ->whereNotIn('external_id', $seenIds) ->delete(); // Clear cache once at the end instead of for each event Cache::forget($display->getEventsCacheKey()); } public function checkInToEvent(string $eventId, string $displayId): void { $event = Event::query() ->where('display_id', $displayId) ->find($eventId); if (!$event) { throw new Exception('Event not found or not accessible'); } // Only allow check-in if not already checked in if ($event->checked_in_at) { throw new Exception('Already checked in'); } $event->checkIn(); } /** * Wait for an event to appear or disappear in Google Calendar API. * Retries with exponential backoff to handle Google's eventual consistency. * * @param Calendar $calendar * @param string $externalEventId * @param Carbon $start * @param Carbon $end * @param bool $shouldExist True if waiting for event to appear, false if waiting for it to disappear * @return void */ private function waitForEventInApi(Calendar $calendar, string $externalEventId, Carbon $start, Carbon $end, bool $shouldExist): void { if (!$calendar->google_account_id || !$calendar->googleAccount) { return; // Only wait for Google Calendar API } $maxAttempts = 5; $baseDelay = 0.5; // Start with 500ms for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { try { // Fetch events from Google Calendar API $googleEvents = $this->googleService->fetchEvents( $calendar->googleAccount, $calendar->calendar_id, $start->copy()->subHours(1), // Fetch slightly wider range $end->copy()->addHours(1) ); // Check if event exists in the API response $eventExists = false; foreach ($googleEvents as $googleEvent) { if ($googleEvent->getId() === $externalEventId) { $eventExists = true; break; } } // If event state matches what we expect, we're done if ($eventExists === $shouldExist) { return; } // If this is the last attempt, log a warning but continue if ($attempt === $maxAttempts) { logger()->warning('Event state in Google API did not match expected state after retries', [ 'external_event_id' => $externalEventId, 'expected_exists' => $shouldExist, 'actual_exists' => $eventExists, 'attempts' => $maxAttempts, ]); return; } // Exponential backoff: wait before retrying $delay = $baseDelay * pow(2, $attempt - 1); usleep((int)($delay * 1000000)); // Convert to microseconds } catch (\Exception $e) { // If API call fails, log and continue (don't block the operation) logger()->warning('Error checking event in Google API during wait', [ 'error' => $e->getMessage(), 'external_event_id' => $externalEventId, 'attempt' => $attempt, ]); // On last attempt, give up if ($attempt === $maxAttempts) { return; } // Wait before retrying $delay = $baseDelay * pow(2, $attempt - 1); usleep((int)($delay * 1000000)); } } } private function processExpiredCheckIns(Display $display): void { if (! $display->isCheckInEnabled()) { return; } $gracePeriod = $display->getCheckInGracePeriod(); $events = Event::query() ->select('id') ->where('display_id', $display->id) ->whereNull('checked_in_at') ->where('start', '<', now()->subMinutes($gracePeriod)) ->where('status', '!=', EventStatus::CANCELLED) ->get(); if ($events->isNotEmpty()) { $events->each->update(['status' => EventStatus::CANCELLED]); } } } ================================================ FILE: backend/app/Services/GoogleService.php ================================================ client = new Client(); $this->client->setClientId(config('services.google.client_id')); $this->client->setClientSecret(config('services.google.client_secret')); $this->client->setRedirectUri(config('services.google.calendar_redirect')); $this->client->setAccessType('offline'); $this->client->setPrompt('consent'); } /** * Handle Google OAuth callback and store tokens in the database. * * @param string $authCode * @param PermissionType $permissionType * @param GoogleBookingMethod $bookingMethod * @return GoogleAccount * @throws Exception */ public function authenticateGoogleAccount(string $authCode, PermissionType $permissionType = PermissionType::READ, ?GoogleBookingMethod $bookingMethod = null): GoogleAccount { $accessToken = $this->client->fetchAccessTokenWithAuthCode($authCode); if (Arr::exists($accessToken, 'error')) { throw new Exception('Error authenticating with Google: ' . Arr::get($accessToken, 'error')); } logger()->info('Received Google access token:', $accessToken); $this->client->setAccessToken($accessToken['access_token']); // Get the authenticated user's profile and save tokens $googleService = new Oauth2($this->client); $googleUserInfo = $googleService->userinfo->get(); // Get selected workspace (from session or default to primary) $selectedWorkspace = auth()->user()->getSelectedWorkspace(); $workspaceId = $selectedWorkspace?->id; // Save the user's Google account and tokens in the database return GoogleAccount::updateOrCreate( [ 'user_id' => auth()->id(), 'google_id' => $googleUserInfo->id, 'workspace_id' => $workspaceId, ], [ 'user_id' => auth()->id(), 'workspace_id' => $workspaceId, 'email' => $googleUserInfo->email, 'name' => $googleUserInfo->name, 'avatar' => $googleUserInfo->picture, 'hosted_domain' => $googleUserInfo->hd, 'token' => $accessToken['access_token'], 'refresh_token' => $accessToken['refresh_token'] ?? null, 'token_expires_at' => now()->addSeconds($accessToken['expires_in']), 'status' => AccountStatus::CONNECTED, 'permission_type' => $permissionType, 'booking_method' => $bookingMethod, ] ); } /** * Determine if a Google account is personal or business */ public function isGoogleBusiness(GoogleAccount $account): bool { $this->ensureAuthenticated($account); try { $googleService = new Oauth2($this->client); $googleUserInfo = $googleService->userinfo->get(); // Check if it's a Gmail account $isGmail = str_ends_with(strtolower($googleUserInfo->email), '@gmail.com') || str_ends_with(strtolower($googleUserInfo->email), '@googlemail.com'); // If it's not Gmail and has a hosted domain, it's a business account return !$isGmail && isset($googleUserInfo->hd); } catch (\Exception $e) { logger()->error('Error checking Google account type', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return false; } } /** * Generate Google OAuth URL for authentication. * * @param PermissionType $permissionType * @return string */ public function getAuthUrl(PermissionType $permissionType = PermissionType::READ): string { $scopes = [ Oauth2::USERINFO_EMAIL, Oauth2::USERINFO_PROFILE, Directory::ADMIN_DIRECTORY_RESOURCE_CALENDAR_READONLY, ]; if ($permissionType === PermissionType::WRITE) { $scopes[] = GoogleCalendar::CALENDAR_EVENTS; $scopes[] = GoogleCalendar::CALENDAR_READONLY; } else { $scopes[] = GoogleCalendar::CALENDAR_EVENTS_READONLY; $scopes[] = GoogleCalendar::CALENDAR_READONLY; } $this->client->setScopes($scopes); return $this->client->createAuthUrl(); } private function ensureAuthenticated(GoogleAccount $account): void { if (!$account->token) { throw new Exception('Google account has no token.'); } // Set the access token for API requests $this->client->setAccessToken($account->token); // Refresh the token if expired if ($this->client->isAccessTokenExpired()) { $this->refreshToken($account); } } private function refreshToken(GoogleAccount $account): void { $this->client->setAccessToken($account->token); $tokenData = $this->client->fetchAccessTokenWithRefreshToken($account->refresh_token); if (Arr::exists($tokenData, 'error')) { throw new Exception('Error authenticating with Google: ' . Arr::get($tokenData, 'error')); } $account->update([ 'token' => $tokenData['access_token'], 'refresh_token' => $tokenData['refresh_token'] ?? $account->refresh_token, 'token_expires_at' => now()->addSeconds($tokenData['expires_in']), ]); } public function fetchCalendars(GoogleAccount $account): array { $this->ensureAuthenticated($account); $service = new GoogleCalendar($this->client); $calendarList = $service->calendarList->listCalendarList(); return $calendarList->getItems(); } public function fetchRooms(GoogleAccount $account): array { $this->ensureAuthenticated($account); $service = new Directory($this->client); $customerId = 'my_customer'; // Default customer ID for the current domain $results = $service->resources_calendars->listResourcesCalendars($customerId); return $results->getItems(); } /** * @throws Exception */ public function fetchEvents( GoogleAccount $googleAccount, string $calendarId, Carbon $startDateTime, Carbon $endDateTime ): array { $this->ensureAuthenticated($googleAccount); $calendarService = new GoogleCalendar($this->client); $events = $calendarService->events->listEvents($calendarId, [ 'timeMin' => $startDateTime->toRfc3339String(), 'timeMax' => $endDateTime->toRfc3339String(), 'maxResults' => 100, 'singleEvents' => true, 'showDeleted' => false, 'orderBy' => 'startTime' ]); return $events->getItems(); } /** * Create an event in Google Calendar. * * @param GoogleAccount $googleAccount * @param Calendar $calendar * @param string $summary * @param Carbon $start * @param Carbon $end * @return GoogleEvent|null * @throws Exception */ public function createEvent( GoogleAccount $googleAccount, Calendar $calendar, string $summary, Carbon $start, Carbon $end ): ?GoogleEvent { $event = new GoogleEvent(); $event->setSummary($summary); $startDateTime = new \Google\Service\Calendar\EventDateTime(); $startDateTime->setDateTime($start->toRfc3339String()); $startDateTime->setTimeZone($start->timezone->getName()); $event->setStart($startDateTime); $endDateTime = new \Google\Service\Calendar\EventDateTime(); $endDateTime->setDateTime($end->toRfc3339String()); $endDateTime->setTimeZone($end->timezone->getName()); $event->setEnd($endDateTime); // Get booking method, defaulting to USER_ACCOUNT if null $bookingMethod = $googleAccount->booking_method ?? GoogleBookingMethod::USER_ACCOUNT; // For workspace accounts with room resources and service account booking method, write directly to room calendar if ($calendar->room && $googleAccount->isBusiness() && $bookingMethod === GoogleBookingMethod::SERVICE_ACCOUNT && $googleAccount->service_account_file_path) { return $this->createRoomEventWithServiceAccount($googleAccount, $calendar, $event); } // Fall back to user OAuth method (current account booking method or personal accounts) $this->ensureAuthenticated($googleAccount); $calendarService = new GoogleCalendar($this->client); $calendarId = $calendar->room ? 'primary' : $calendar->calendar_id; // For room resources with current account method, create event on user's primary calendar and add room as attendee // Room resource calendars are read-only and cannot be written to directly without service account if ($calendar->room) { $attendee = new \Google\Service\Calendar\EventAttendee(); $attendee->setEmail($calendar->calendar_id); $event->setAttendees([$attendee]); } try { $createdEvent = $calendarService->events->insert($calendarId, $event, [ 'sendUpdates' => 'none' ]); return $createdEvent; } catch (\Exception $e) { throw new Exception('Failed to create Google event: ' . $e->getMessage()); } } /** * Delete an event from Google Calendar. * * @param GoogleAccount $googleAccount * @param Calendar $calendar * @param string $eventId * @return void * @throws Exception */ public function deleteEvent( GoogleAccount $googleAccount, Calendar $calendar, string $eventId ): void { // Get booking method, defaulting to USER_ACCOUNT if null $bookingMethod = $googleAccount->booking_method ?? GoogleBookingMethod::USER_ACCOUNT; // For workspace accounts with room resources and service account booking method, delete directly from room calendar if ($calendar->room && $googleAccount->isBusiness() && $bookingMethod === GoogleBookingMethod::SERVICE_ACCOUNT && $googleAccount->service_account_file_path) { $this->deleteRoomEventWithServiceAccount($googleAccount, $calendar, $eventId); return; } // Fall back to user OAuth method (current account booking method or personal accounts) $this->ensureAuthenticated($googleAccount); $calendarService = new GoogleCalendar($this->client); try { if ($calendar->room) { $this->deleteRoomEvent($calendarService, $calendar, $eventId); } else { $calendarService->events->delete($calendar->calendar_id, $eventId, [ 'sendUpdates' => 'none' ]); } } catch (\Exception $e) { throw new Exception('Failed to delete Google event: ' . $e->getMessage()); } } /** * Delete an event for a room resource. * For room resources, events are created on the user's primary calendar, * but the eventId we receive is from the room's calendar (from fetchEvents). * * @param GoogleCalendar $calendarService * @param Calendar $calendar * @param string $eventId * @return void * @throws Exception */ private function deleteRoomEvent( GoogleCalendar $calendarService, Calendar $calendar, string $eventId ): void { // Try deleting from primary calendar first (where we created it) try { $calendarService->events->delete('primary', $eventId, [ 'sendUpdates' => 'none' ]); } catch (\Exception $e) { // If that fails, try deleting from the room calendar // The event might have a different ID on the room calendar $calendarService->events->delete($calendar->calendar_id, $eventId, [ 'sendUpdates' => 'none' ]); } } /** * Create a webhook subscription for Google Calendar events. * * @param GoogleAccount $googleAccount * @param Display $display * @param string $calendarId * @return EventSubscription|null * @throws Exception */ public function createEventSubscription( GoogleAccount $googleAccount, Display $display, string $calendarId ): ?EventSubscription { $this->ensureAuthenticated($googleAccount); $calendarService = new GoogleCalendar($this->client); try { $channel = new Channel(); $channel->setId(str()->uuid()); $channel->setType('web_hook'); $channel->setAddress(config('services.google.webhook_url')); $channel->setExpiration(now()->addDays(3)->getTimestampMs()); $response = $calendarService->events->watch($calendarId, $channel); if (!$response->getId()) { logger()->error('Creating Google subscription failed - no subscription ID returned', [ 'response' => $response, ]); // This is likely a user error (invalid calendar, permissions, etc.) throw new Exception("Failed to create Google subscription: No subscription ID returned"); } // Create the subscription record in the database $eventSubscription = EventSubscription::create([ 'subscription_id' => $response->getId(), 'resource' => $calendarId, 'expiration' => Carbon::createFromTimestampMs($response->getExpiration()), 'notification_url' => config('services.google.webhook_url'), 'display_id' => $display->id, 'google_account_id' => $googleAccount->id, ]); // Log the creation for debugging logger()->info('Google subscription created', ['subscription' => $response]); return $eventSubscription; } catch (Exception $e) { // Re-throw if it's already a user error exception we just created if (str_contains($e->getMessage(), 'Failed to create Google subscription')) { throw $e; } // Check if this is a Google API exception with HTTP status code $statusCode = $e->getCode(); $isUserError = $statusCode >= 400 && $statusCode < 500; // Check exception class name for Google API exceptions $exceptionClass = get_class($e); logger()->error('Error creating Google subscription', [ 'error' => $e->getMessage(), 'calendarId' => $calendarId, 'status_code' => $statusCode, 'is_user_error' => $isUserError, 'exception_type' => $exceptionClass, ]); // Throw exception for user errors (4xx) so the command can handle it // Return null for server errors (5xx) or connection errors to avoid marking display as error if ($isUserError) { throw new Exception("Failed to create Google subscription: HTTP {$statusCode} - " . $e->getMessage()); } // For connection errors, timeouts, etc., don't throw - these are transient return null; } } /** * Delete a webhook subscription for Google Calendar events. * * @param GoogleAccount $googleAccount * @param EventSubscription $eventSubscription * @param bool $useApi * @return void * @throws Exception */ public function deleteEventSubscription( GoogleAccount $googleAccount, EventSubscription $eventSubscription, bool $useApi = true ): void { if ($useApi) { $this->ensureAuthenticated($googleAccount); try { $calendarService = new GoogleCalendar($this->client); $channel = new Channel(); $channel->setId($eventSubscription->subscription_id); $channel->setResourceId($eventSubscription->resource); $calendarService->channels->stop($channel); } catch (Exception $e) { report($e); logger()->error('Error stopping Google subscription', [ 'error' => $e->getMessage(), 'subscriptionId' => $eventSubscription->subscription_id ]); } } // Delete the subscription record from the database $eventSubscription->delete(); // Log the deletion for debugging logger()->info('Google subscription deleted', ['subscriptionId' => $eventSubscription->id]); } /** * Create a Google Calendar client authenticated with service account. * * @param GoogleAccount $googleAccount * @return Client * @throws Exception */ private function getServiceAccountClient(GoogleAccount $googleAccount): Client { if (!$googleAccount->service_account_file_path) { throw new Exception('Service account file path not set for Google account.'); } if (!Storage::exists($googleAccount->service_account_file_path)) { throw new Exception('Service account file not found: ' . $googleAccount->service_account_file_path); } // Read and decrypt the encrypted service account file $encryptedContent = Storage::get($googleAccount->service_account_file_path); $decryptedContent = Crypt::decryptString($encryptedContent); // Parse the JSON content $serviceAccountData = json_decode($decryptedContent, true); if (!$serviceAccountData) { throw new Exception('Invalid service account JSON file.'); } $client = new Client(); // setAuthConfig() can accept either a file path or an array // Using array avoids creating temporary files with sensitive data $client->setAuthConfig($serviceAccountData); $scopes = [ GoogleCalendar::CALENDAR_READONLY, GoogleCalendar::CALENDAR_EVENTS, ]; $client->setScopes($scopes); // For domain-wide delegation, impersonate the user who owns the Google account // This allows the service account to access resources on behalf of the user if ($googleAccount->email) { $client->setSubject($googleAccount->email); } return $client; } /** * Create an event directly on a room resource calendar using service account. * This allows booking rooms without using a user's calendar for workspace accounts. * * @param GoogleAccount $googleAccount * @param Calendar $calendar * @param GoogleEvent $event * @return GoogleEvent * @throws Exception */ private function createRoomEventWithServiceAccount( GoogleAccount $googleAccount, Calendar $calendar, GoogleEvent $event ): GoogleEvent { $client = $this->getServiceAccountClient($googleAccount); $calendarService = new GoogleCalendar($client); try { // With service account and proper permissions, we can write directly to room calendars $createdEvent = $calendarService->events->insert($calendar->calendar_id, $event, [ 'sendUpdates' => 'none' ]); return $createdEvent; } catch (\Exception $e) { throw new Exception('Failed to create Google room event with service account: ' . $e->getMessage()); } } /** * Delete an event directly from a room resource calendar using service account. * * @param GoogleAccount $googleAccount * @param Calendar $calendar * @param string $eventId * @return void * @throws Exception */ private function deleteRoomEventWithServiceAccount( GoogleAccount $googleAccount, Calendar $calendar, string $eventId ): void { $client = $this->getServiceAccountClient($googleAccount); $calendarService = new GoogleCalendar($client); try { // With service account and proper permissions, we can delete directly from room calendars $calendarService->events->delete($calendar->calendar_id, $eventId, [ 'sendUpdates' => 'none' ]); } catch (\Exception $e) { throw new Exception('Failed to delete Google room event with service account: ' . $e->getMessage()); } } } ================================================ FILE: backend/app/Services/ImageService.php ================================================ 'images/backgrounds/default_1.jpg', 'default_2' => 'images/backgrounds/default_2.jpg', 'default_3' => 'images/backgrounds/default_3.jpg', 'default_4' => 'images/backgrounds/default_4.jpg', 'default_5' => 'images/backgrounds/default_5.jpg', 'default_6' => 'images/backgrounds/default_6.jpg', 'default_7' => 'images/backgrounds/default_7.jpg', 'default_8' => 'images/backgrounds/default_8.jpg', ]; /** * Get all available default backgrounds */ public function getDefaultBackgrounds(): array { return array_map(function ($path, $key) { return [ 'key' => $key, 'url' => asset($path), 'path' => $path, ]; }, self::DEFAULT_BACKGROUNDS, array_keys(self::DEFAULT_BACKGROUNDS)); } /** * Get the logo URL for a display */ public function getLogoUrl(Display $display): ?string { $logo = DisplaySettings::getLogo($display); if (!$logo) { return null; } // Add version parameter based on when logo was last updated $version = $this->getImageVersion($display, 'logo'); return url('api/displays/' . $display->id . '/images/logo') . '?v=' . $version; } /** * Get the background image URL for a display */ public function getBackgroundImageUrl(Display $display): ?string { $background = DisplaySettings::getBackgroundImage($display); if (!$background) { return null; } // Check if it's a default background - if so, return the direct asset URL if (isset(self::DEFAULT_BACKGROUNDS[$background])) { return asset(self::DEFAULT_BACKGROUNDS[$background]); } // Add version parameter based on when background was last updated $version = $this->getImageVersion($display, 'background'); return url('api/displays/' . $display->id . '/images/background') . '?v=' . $version; } /** * Get image version based on file modification time or fallback to display updated_at */ private function getImageVersion(Display $display, string $type): string { $imagePath = $type === 'logo' ? DisplaySettings::getLogo($display) : DisplaySettings::getBackgroundImage($display); if ($imagePath && Storage::disk('public')->exists($imagePath)) { // Use file modification time as version return (string) Storage::disk('public')->lastModified($imagePath); } // Fallback to display updated_at timestamp return $display->updated_at->timestamp; } /** * Serve a display image (logo or background) */ public function serveImage(Display $display, string $type) { if ($type === 'logo') { $imagePath = DisplaySettings::getLogo($display); } elseif ($type === 'background') { $imagePath = DisplaySettings::getBackgroundImage($display); // Check if it's a default background if ($imagePath && isset(self::DEFAULT_BACKGROUNDS[$imagePath])) { $publicPath = public_path(self::DEFAULT_BACKGROUNDS[$imagePath]); if (file_exists($publicPath)) { return response()->file($publicPath); } } } else { abort(404, 'Invalid image type'); } if (!$imagePath || !Storage::disk('public')->exists($imagePath)) { abort(404, 'Image not found'); } return response()->file(Storage::disk('public')->path($imagePath)); } /** * Store a logo file and return the path */ public function storeLogoFile($file, Display $display): ?string { try { $filename = 'logo_' . $display->id . '_' . time() . '.' . $file->getClientOriginalExtension(); $path = $file->storeAs('displays/logos', $filename, 'public'); return $path; } catch (\Exception $e) { return null; } } /** * Store a background image file and return the path */ public function storeBackgroundImageFile($file, Display $display): ?string { try { $filename = 'background_' . $display->id . '_' . time() . '.' . $file->getClientOriginalExtension(); $path = $file->storeAs('displays/backgrounds', $filename, 'public'); return $path; } catch (\Exception $e) { return null; } } /** * Remove logo file from storage */ public function removeLogoFile(Display $display): void { $currentLogo = DisplaySettings::getLogo($display); if ($currentLogo && Storage::disk('public')->exists($currentLogo)) { Storage::disk('public')->delete($currentLogo); } } /** * Remove background image file from storage */ public function removeBackgroundImageFile(Display $display): void { $currentBackground = DisplaySettings::getBackgroundImage($display); if ($currentBackground && Storage::disk('public')->exists($currentBackground)) { Storage::disk('public')->delete($currentBackground); } } /** * Get the logo URL for a board */ public function getBoardLogoUrl(\App\Models\Board $board): ?string { if (!$board->logo) { return null; } // Add version parameter based on when logo was last updated $version = $board->updated_at->timestamp; return url('boards/' . $board->id . '/images/logo') . '?v=' . $version; } /** * Store a logo file for a board and return the path */ public function storeBoardLogoFile($file, \App\Models\Board $board): ?string { try { $filename = 'logo_' . $board->id . '_' . time() . '.' . $file->getClientOriginalExtension(); $path = $file->storeAs('boards/logos', $filename, 'public'); return $path; } catch (\Exception $e) { return null; } } /** * Remove logo file from storage for a board */ public function removeBoardLogoFile(\App\Models\Board $board): void { if ($board->logo && Storage::disk('public')->exists($board->logo)) { Storage::disk('public')->delete($board->logo); } } /** * Serve board logo image */ public function serveBoardLogo(\App\Models\Board $board) { if (!$board->logo || !Storage::disk('public')->exists($board->logo)) { abort(404, 'Logo not found'); } return response()->file(Storage::disk('public')->path($board->logo)); } } ================================================ FILE: backend/app/Services/InstanceService.php ================================================ isPast()) { return false; } return true; } public static function hasLicense(): bool { return self::getInstanceVariable('license_key') !== null; } public static function updateLicense(LicenseData $data): bool { $updatedLicense = self::storeInstanceVariable('license_key', $data->licenseKey) && self::storeInstanceVariable('license_valid', $data->valid); if ($data->expiresAt && ! self::storeInstanceVariable('license_expires_at', $data->expiresAt->toDateTimeString())) { return false; } return $updatedLicense; } public static function storeInstanceVariable(string $key, ?string $value): bool { try { $key = self::getSettingKey($key); if (is_null($value)) { return Settings::deleteSetting($key); } return Settings::setSetting($key, $value); } catch (\Exception $e) { report($e); return false; } } public static function getInstanceVariable(string $key, mixed $default = null): mixed { try { return Settings::getSetting(self::getSettingKey($key), $default); } catch (\Exception $e) { report($e); return $default; } } private static function getInstanceKey(): string { $instanceKey = self::getInstanceVariable('instance_key'); // Generate and set a new key when non existant if (is_null($instanceKey)) { $instanceKey = self::generateInstanceKey(); self::storeInstanceVariable('instance_key', $instanceKey); } return $instanceKey; } private static function generateInstanceKey(): string { return sha1(Str::ulid()->toString()); } private static function getSettingKey(string $key): string { return self::SETTING_PREFIX . '_' . Str::snake($key); } public static function getInstanceData(): InstanceData { $instanceKey = self::getInstanceKey(); $users = User::all()->map(function ($user) { return new UserData( email: $user->email, usageType: $user->usage_type?->value, isUnlimited: $user->is_unlimited, termsAcceptedAt: $user->terms_accepted_at, ); }); $version = config('settings.version'); $licenseExpiresAt = self::getInstanceVariable('license_expires_at'); return new InstanceData( instanceKey: $instanceKey, licenseKey: self::getInstanceVariable('license_key'), licenseValid: self::getInstanceVariable('license_valid'), licenseExpiresAt: $licenseExpiresAt ? Carbon::parse($licenseExpiresAt) : null, isSelfHosted: config('settings.is_self_hosted'), displaysCount: Display::count(), roomsCount: Room::count(), boardsCount: Board::count(), version: ! empty($version) ? $version : 'unknown', users: $users->toArray() ); } } ================================================ FILE: backend/app/Services/OutlookService.php ================================================ clientId = config('services.azure_ad.client_id'); $this->clientSecret = config('services.azure_ad.client_secret'); $this->redirectUri = config('services.azure_ad.redirect'); $this->tenantId = config('services.azure_ad.tenant_id'); } /** * Get the access token for Google Calendar API * @throws \Exception */ private function ensureAuthenticated(&$outlookAccount): void { if (now()->lte($outlookAccount->token_expires_at)) { return; } // Set the access token for API requests $this->refreshToken($outlookAccount); } /** * Generate Outlook OAuth URL for authentication. * * @param PermissionType $permissionType 'read' or 'write', or PermissionType enum * @return string */ public function getAuthUrl(PermissionType $permissionType = PermissionType::READ): string { $oauthEndpoint = "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize"; $scopes = $permissionType === PermissionType::WRITE ? self::OAUTH_SCOPES_WRITE : self::OAUTH_SCOPES_READ; $params = [ 'client_id' => $this->clientId, 'response_type' => 'code', 'redirect_uri' => $this->redirectUri, 'response_mode' => 'query', 'scope' => $scopes, 'state' => csrf_token(), ]; return $oauthEndpoint . '?' . http_build_query($params); } /** * Handle Outlook OAuth callback and store tokens in the database. * * @param string $authCode * @param string|PermissionType $permissionType 'read' or 'write', or PermissionType enum * @return OutlookAccount * @throws \Exception */ public function authenticateOutlookAccount(string $authCode, string|PermissionType $permissionType = PermissionType::READ): OutlookAccount { $oauthTokenEndpoint = "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token"; // Convert string to enum if needed if (is_string($permissionType)) { $permissionType = PermissionType::from($permissionType); } $scopes = $permissionType === PermissionType::WRITE ? self::OAUTH_SCOPES_WRITE : self::OAUTH_SCOPES_READ; // Exchange authorization code for tokens $response = Http::asForm()->post($oauthTokenEndpoint, [ 'client_id' => $this->clientId, 'scope' => $scopes, 'code' => $authCode, 'redirect_uri' => $this->redirectUri, 'grant_type' => 'authorization_code', 'client_secret' => $this->clientSecret, ]); $tokenData = $response->json(); if (Arr::exists($tokenData, 'error')) { throw new Exception('Error authenticating with Outlook: ' . Arr::get($tokenData, 'error.message')); } // Get the current user information $response = Http::acceptJson() ->withToken($tokenData['access_token']) ->get('https://graph.microsoft.com/v1.0/me'); $user = $response->json(); $tenantId = $this->getTenantId($tokenData['access_token']); // Get selected workspace (from session or default to primary) $selectedWorkspace = auth()->user()->getSelectedWorkspace(); $workspaceId = $selectedWorkspace?->id; // Save the Outlook account and tokens return OutlookAccount::updateOrCreate( [ 'user_id' => auth()->id(), 'outlook_id' => $user['id'], 'workspace_id' => $workspaceId, ], [ 'user_id' => auth()->id(), 'workspace_id' => $workspaceId, 'email' => $user['mail'] ?? $user['userPrincipalName'], 'name' => $user['displayName'], 'tenant_id' => $tenantId, 'permission_type' => $permissionType->value, 'token' => $tokenData['access_token'], 'refresh_token' => $tokenData['refresh_token'] ?? null, 'token_expires_at' => now()->addSeconds($tokenData['expires_in']), 'status' => AccountStatus::CONNECTED, ] ); } public function getTenantId(string $token): ?string { try { $response = Http::withToken($token) ->get('https://graph.microsoft.com/v1.0/organization'); if (!$response->successful()) { logger()->error('Failed to fetch Microsoft user info', [ 'status' => $response->status(), 'response' => $response->json(), ]); return null; } $data = Arr::get($response->json(), 'value') ?? []; return Arr::get($data, '0.id'); } catch (\Exception $e) { report($e); return null; } } /** * Refresh Outlook access token. * * @param OutlookAccount $outlookAccount * @return void * @throws \Exception */ protected function refreshToken(OutlookAccount &$outlookAccount): void { $oauthTokenEndpoint = "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token"; $scopes = $outlookAccount->permission_type === PermissionType::WRITE ? self::OAUTH_SCOPES_WRITE : self::OAUTH_SCOPES_READ; $response = Http::asForm()->post($oauthTokenEndpoint, [ 'client_id' => $this->clientId, 'scope' => $scopes, 'refresh_token' => $outlookAccount->refresh_token, 'grant_type' => 'refresh_token', 'client_secret' => $this->clientSecret, ]); $tokenData = $response->json(); if (Arr::exists($tokenData, 'error')) { $outlookAccount->update([ 'status' => AccountStatus::ERROR, ]); throw new Exception('Error refreshing Outlook token: ' . Arr::get($tokenData, 'error.message')); } $outlookAccount->update([ 'token' => $tokenData['access_token'], 'token_expires_at' => now()->addSeconds($tokenData['expires_in'])->subSeconds(5), 'refresh_token' => $tokenData['refresh_token'] ?? $outlookAccount->refresh_token, ]); } /** * Fetch calendar events from Outlook account. * * @param OutlookAccount $outlookAccount * @param string $emailAddress * @param Carbon $startDateTime * @param Carbon $endDateTime * @return mixed * @throws \Exception */ public function fetchEventsByUser( OutlookAccount $outlookAccount, string $emailAddress, Carbon $startDateTime, Carbon $endDateTime, ): array { $this->ensureAuthenticated($outlookAccount); $params = [ 'startDateTime' => $startDateTime->toIso8601String(), 'endDateTime' => $endDateTime->toIso8601String(), '$select' => 'id,lastModifiedDateTime,subject,body,bodyPreview,isAllDay,location,start,end', '$orderby' => 'createdDateTime', '$top' => 100 ]; $response = Http::withToken($outlookAccount->token) ->get("https://graph.microsoft.com/v1.0/users/$emailAddress/calendarview", $params); return Arr::get($response->json(), 'value') ?? []; } /** * Fetch calendar events from Outlook account. * * @param OutlookAccount $outlookAccount * @param string $calendarId * @param Carbon $startDateTime * @param Carbon $endDateTime * @return mixed * @throws \Exception */ public function fetchEventsByCalendar( OutlookAccount $outlookAccount, string $calendarId, Carbon $startDateTime, Carbon $endDateTime, ): array { $this->ensureAuthenticated($outlookAccount); $params = [ 'startDateTime' => $startDateTime->toIso8601String(), 'endDateTime' => $endDateTime->toIso8601String(), '$select' => 'id,lastModifiedDateTime,subject,body,bodyPreview,isAllDay,location,start,end', '$orderby' => 'createdDateTime', '$top' => 100 ]; $response = Http::withToken($outlookAccount->token) ->get("https://graph.microsoft.com/v1.0/me/calendars/$calendarId/calendarview", $params); return Arr::get($response->json(), 'value') ?? []; } /** * Fetch calendars from the authenticated user's Outlook account. * * @param OutlookAccount $outlookAccount * @return mixed * @throws \Exception */ public function fetchCalendars(OutlookAccount $outlookAccount): mixed { $this->ensureAuthenticated($outlookAccount); // Get the current user information $response = Http::acceptJson()->withHeaders([ 'Authorization' => 'Bearer ' . $outlookAccount->token, ])->get('https://graph.microsoft.com/v1.0/me/calendars'); return Arr::get($response->json(), 'value'); } /** * Fetch rooms from the authenticated user's Outlook account. * * @param OutlookAccount $outlookAccount * @return mixed * @throws \Exception */ public function fetchRooms(OutlookAccount $outlookAccount): mixed { $this->ensureAuthenticated($outlookAccount); // Get the current user information $response = Http::acceptJson()->withHeaders([ 'Authorization' => 'Bearer ' . $outlookAccount->token, ])->get('https://graph.microsoft.com/v1.0/places/microsoft.graph.room'); return Arr::get($response->json(), 'value'); } /** * Create an event in Outlook calendar. * * @param OutlookAccount $outlookAccount * @param Calendar $calendar * @param string $summary * @param Carbon $start * @param Carbon $end * @return array|null * @throws \Exception */ public function createEvent( OutlookAccount $outlookAccount, Calendar $calendar, string $summary, Carbon $start, Carbon $end ): ?array { $this->ensureAuthenticated($outlookAccount); $eventData = [ 'subject' => $summary, 'start' => [ 'dateTime' => $start->toIso8601String(), 'timeZone' => $start->timezone->getName(), ], 'end' => [ 'dateTime' => $end->toIso8601String(), 'timeZone' => $end->timezone->getName(), ], ]; // Determine the endpoint based on whether it's a room or calendar if ($calendar->room) { // For rooms, use the user's calendar $endpoint = "https://graph.microsoft.com/v1.0/users/{$calendar->calendar_id}/calendar/events"; } elseif ($calendar->is_primary) { // For primary calendar, use /me/calendar/events (without calendar ID) $endpoint = "https://graph.microsoft.com/v1.0/me/calendar/events"; } else { // For other calendars, use the calendar ID $endpoint = "https://graph.microsoft.com/v1.0/me/calendars/{$calendar->calendar_id}/events"; } $response = Http::acceptJson() ->withHeaders([ 'Authorization' => 'Bearer ' . $outlookAccount->token, ]) ->post($endpoint, $eventData); if (!$response->successful()) { throw new Exception('Failed to create Outlook event: ' . $response->body()); } return $response->json(); } /** * Delete an event from Outlook calendar. * * @param OutlookAccount $outlookAccount * @param Calendar $calendar * @param string $eventId * @return void * @throws \Exception */ public function deleteEvent( OutlookAccount $outlookAccount, Calendar $calendar, string $eventId ): void { $this->ensureAuthenticated($outlookAccount); // Determine the endpoint based on whether it's a room or calendar if ($calendar->room) { // For rooms, use the user's calendar $endpoint = "https://graph.microsoft.com/v1.0/users/{$calendar->calendar_id}/calendar/events/{$eventId}"; } elseif ($calendar->is_primary) { // For primary calendar, use /me/calendar/events (without calendar ID) $endpoint = "https://graph.microsoft.com/v1.0/me/calendar/events/{$eventId}"; } else { // For other calendars, use the calendar ID $endpoint = "https://graph.microsoft.com/v1.0/me/calendars/{$calendar->calendar_id}/events/{$eventId}"; } $response = Http::acceptJson() ->withHeaders([ 'Authorization' => 'Bearer ' . $outlookAccount->token, ]) ->delete($endpoint); if (!$response->successful()) { throw new Exception('Failed to delete Outlook event: ' . $response->body()); } } /** * Create an event subscription for Outlook calendar events. * * @param OutlookAccount $outlookAccount * @param Display $display * @param string $emailAddress * @return EventSubscription|null * @throws \Exception */ public function createEventSubscriptionByUser( OutlookAccount $outlookAccount, Display $display, string $emailAddress ): ?EventSubscription { // Try the standard path first try { return $this->createEventSubscription($outlookAccount, $display, "/users/$emailAddress/events"); } catch (\Exception $e) { // If it fails with a resource invalid error, try with /calendar/ path as backup if (str_contains($e->getMessage(), 'Resource') && str_contains($e->getMessage(), 'invalid')) { logger()->warning('Subscription failed with /events path, trying /calendar/events as backup', [ 'email' => $emailAddress, 'display_id' => $display->id, 'error' => $e->getMessage(), ]); return $this->createEventSubscription($outlookAccount, $display, "/users/$emailAddress/calendar/events"); } // Re-throw if it's not a resource invalid error throw $e; } } /** * Create an event subscription for Outlook calendar events. * * @param OutlookAccount $outlookAccount * @param Display $display * @param string $calendarId * @return EventSubscription|null * @throws \Exception */ public function createEventSubscriptionByCalendar( OutlookAccount $outlookAccount, Display $display, string $calendarId ): ?EventSubscription { return $this->createEventSubscription($outlookAccount, $display, "/me/calendars/$calendarId/events"); } /** * Create an event subscription for Outlook calendar events. * * @param OutlookAccount $outlookAccount * @param Display $display * @param string $resource * @return EventSubscription|null * @throws \Exception */ private function createEventSubscription( OutlookAccount $outlookAccount, Display $display, string $resource ): ?EventSubscription { $this->ensureAuthenticated($outlookAccount); $data = [ 'resource' => $resource, 'changeType' => 'created,updated,deleted', 'notificationUrl' => config('services.azure_ad.webhook_url'), 'expirationDateTime' => now()->addHours(3)->toISOString(), 'includeResourceData' => "false", ]; logger()->info('Creating subscription', [ 'data' => $data ]); try { // Create a subscription with Microsoft Graph $response = Http::withToken($outlookAccount->token) ->post("https://graph.microsoft.com/v1.0/subscriptions", $data); $responseBody = $response->json(); if ( $response->failed() || !Arr::has($responseBody, ['id', 'resource', 'expirationDateTime', 'notificationUrl']) ) { $statusCode = $response->status(); $isUserError = $statusCode >= 400 && $statusCode < 500; logger()->error('Creating outlook subscription failed', [ 'statuscode' => $statusCode, 'response' => $responseBody, 'is_user_error' => $isUserError, ]); // Throw exception for user errors (4xx) so the command can handle it // Return null for server errors (5xx) to avoid marking display as error if ($isUserError) { throw new Exception("Failed to create Outlook subscription: HTTP {$statusCode} - " . ($responseBody['error']['message'] ?? $responseBody['message'] ?? 'Unknown error')); } return null; } } catch (Exception $e) { // Re-throw if it's already a user error exception we just created if (str_contains($e->getMessage(), 'Failed to create Outlook subscription')) { throw $e; } // For connection errors, timeouts, etc., don't throw - these are transient logger()->error('Error creating outlook subscription - connection/timeout error', [ 'error' => $e->getMessage(), 'exception_type' => get_class($e), ]); return null; } // Create the subscription record in the database $eventSubscription = EventSubscription::create([ 'subscription_id' => $responseBody['id'], 'resource' => $responseBody['resource'], 'expiration' => Carbon::parse($responseBody['expirationDateTime']), 'notification_url' => $data['notificationUrl'], 'display_id' => $display->id, 'outlook_account_id' => $outlookAccount->id, ]); // Log the creation for debugging logger()->info('Outlook subscription created', ['subscription' => $responseBody]); return $eventSubscription; } /** * Delete an event subscription in Outlook. * * @param OutlookAccount $outlookAccount * @param EventSubscription $eventSubscription * @param bool $useApi * @return void * @throws \Exception */ public function deleteEventSubscription( OutlookAccount $outlookAccount, EventSubscription $eventSubscription, bool $useApi = true ): void { // Delete the subscription on Microsoft Graph if ($useApi) { $this->ensureAuthenticated($outlookAccount); Http::withToken($outlookAccount->token) ->delete("https://graph.microsoft.com/v1.0/subscriptions/{$eventSubscription->subscription_id}"); } // Delete the subscription record from the database $eventSubscription->delete(); // Log the deletion for debugging logger()->info('Outlook subscription deleted', ['subscriptionId' => $eventSubscription->id]); } } ================================================ FILE: backend/app/Traits/HasLastActivity.php ================================================ update(['last_activity_at' => now()]); } } ================================================ FILE: backend/app/Traits/HasUlid.php ================================================ {$model->getKeyName()} = $model->{$model->getKeyName()} ?: (string) Str::ulid(); }); } public function getIncrementing(): bool { return false; } public function getKeyType(): string { return 'string'; } public function getCasts(): array { return array_merge([$this->getKeyName() => $this->getKeyType()], $this->casts); } } ================================================ FILE: backend/app/Traits/RespondsWithApiResponse.php ================================================ json($response, $response->status); } } ================================================ FILE: backend/artisan ================================================ #!/usr/bin/env php handleCommand(new ArgvInput); exit($status); ================================================ FILE: backend/bootstrap/app.php ================================================ withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', channels: __DIR__.'/../routes/channels.php', health: '/health', ) ->withMiddleware(function (Middleware $middleware) { $middleware->trustProxies(at: '*'); $middleware->alias([ 'user.update-last-activity' => UpdateLastActivity::class, 'user.active' => CheckUserActive::class, 'user.onboarding' => CheckUserOnboarding::class, 'gtm' => GoogleTagManagerMiddleware::class, ]); $middleware->validateCsrfTokens(except: [ 'lemon-squeezy/*', ]); }) ->withExceptions(function (Exceptions $exceptions) { Integration::handles($exceptions); })->create(); ================================================ FILE: backend/bootstrap/cache/.gitignore ================================================ * !.gitignore ================================================ FILE: backend/bootstrap/opentelemetry.php ================================================ env('APP_NAME', 'Laravel'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => (bool) env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | the application so that it's available within Artisan commands. | */ 'url' => env('APP_URL', 'http://localhost'), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. The timezone | is set to "UTC" by default as it is suitable for most use cases. | */ 'timezone' => env('APP_TIMEZONE', 'UTC'), /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by Laravel's translation / localization methods. This option can be | set to any locale for which you plan to have translation strings. | */ 'locale' => env('APP_LOCALE', 'en'), 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is utilized by Laravel's encryption services and should be set | to a random, 32 character string to ensure that all encrypted values | are secure. You should do this prior to deploying the application. | */ 'cipher' => 'AES-256-CBC', 'key' => env('APP_KEY'), 'previous_keys' => [ ...array_filter( explode(',', env('APP_PREVIOUS_KEYS', '')) ), ], /* |-------------------------------------------------------------------------- | Maintenance Mode Driver |-------------------------------------------------------------------------- | | These configuration options determine the driver used to determine and | manage Laravel's "maintenance mode" status. The "cache" driver will | allow maintenance mode to be controlled across multiple machines. | | Supported drivers: "file", "cache" | */ 'maintenance' => [ 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], ]; ================================================ FILE: backend/config/auth.php ================================================ [ 'guard' => env('AUTH_GUARD', 'web'), 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), ], /* |-------------------------------------------------------------------------- | Authentication Guards |-------------------------------------------------------------------------- | | Next, you may define every authentication guard for your application. | Of course, a great default configuration has been defined for you | which utilizes session storage plus the Eloquent user provider. | | All authentication guards have a user provider, which defines how the | users are actually retrieved out of your database or other storage | system used by the application. Typically, Eloquent is utilized. | | Supported: "session" | */ 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], ], /* |-------------------------------------------------------------------------- | User Providers |-------------------------------------------------------------------------- | | All authentication guards have a user provider, which defines how the | users are actually retrieved out of your database or other storage | system used by the application. Typically, Eloquent is utilized. | | If you have multiple user tables or models you may configure multiple | providers to represent the model / table. These providers may then | be assigned to any extra authentication guards you have defined. | | Supported: "database", "eloquent" | */ 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => env('AUTH_MODEL', App\Models\User::class), ], // 'users' => [ // 'driver' => 'database', // 'table' => 'users', // ], ], /* |-------------------------------------------------------------------------- | Resetting Passwords |-------------------------------------------------------------------------- | | These configuration options specify the behavior of Laravel's password | reset functionality, including the table utilized for token storage | and the user provider that is invoked to actually retrieve users. | | The expiry time is the number of minutes that each reset token will be | considered valid. This security feature keeps tokens short-lived so | they have less time to be guessed. You may change this as needed. | | The throttle setting is the number of seconds a user must wait before | generating more password reset tokens. This prevents the user from | quickly generating a very large amount of password reset tokens. | */ 'passwords' => [ 'users' => [ 'provider' => 'users', 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), 'expire' => 60, 'throttle' => 60, ], ], /* |-------------------------------------------------------------------------- | Password Confirmation Timeout |-------------------------------------------------------------------------- | | Here you may define the amount of seconds before a password confirmation | window expires and users are asked to re-enter their password via the | confirmation screen. By default, the timeout lasts for three hours. | */ 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), ]; ================================================ FILE: backend/config/broadcasting.php ================================================ env('BROADCAST_CONNECTION', 'null'), /* |-------------------------------------------------------------------------- | Broadcast Connections |-------------------------------------------------------------------------- | | Here you may define all of the broadcast connections that will be used | to broadcast events to other systems or over WebSockets. Samples of | each available type of connection are provided inside this array. | */ 'connections' => [ 'redis' => [ 'driver' => 'redis', 'connection' => 'default', ], 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', 'port' => env('PUSHER_PORT', 443), 'scheme' => env('PUSHER_SCHEME', 'https'), 'encrypted' => true, 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', ], 'client_options' => [ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html ], ], 'ably' => [ 'driver' => 'ably', 'key' => env('ABLY_KEY'), ], 'log' => [ 'driver' => 'log', ], 'null' => [ 'driver' => 'null', ], ], ]; ================================================ FILE: backend/config/cache.php ================================================ env('CACHE_STORE', 'database'), /* |-------------------------------------------------------------------------- | Cache Stores |-------------------------------------------------------------------------- | | Here you may define all of the cache "stores" for your application as | well as their drivers. You may even define multiple stores for the | same cache driver to group types of items stored in your caches. | | Supported drivers: "array", "database", "file", "memcached", | "redis", "dynamodb", "octane", "null" | */ 'stores' => [ 'array' => [ 'driver' => 'array', 'serialize' => false, ], 'database' => [ 'driver' => 'database', 'connection' => env('DB_CACHE_CONNECTION'), 'table' => env('DB_CACHE_TABLE', 'cache'), 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), 'lock_table' => env('DB_CACHE_LOCK_TABLE'), ], 'file' => [ 'driver' => 'file', 'path' => storage_path('framework/cache/data'), 'lock_path' => storage_path('framework/cache/data'), ], 'memcached' => [ 'driver' => 'memcached', 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), 'sasl' => [ env('MEMCACHED_USERNAME'), env('MEMCACHED_PASSWORD'), ], 'options' => [ // Memcached::OPT_CONNECT_TIMEOUT => 2000, ], 'servers' => [ [ 'host' => env('MEMCACHED_HOST', '127.0.0.1'), 'port' => env('MEMCACHED_PORT', 11211), 'weight' => 100, ], ], ], 'redis' => [ 'driver' => 'redis', 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), ], 'dynamodb' => [ 'driver' => 'dynamodb', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), 'endpoint' => env('DYNAMODB_ENDPOINT'), ], 'octane' => [ 'driver' => 'octane', ], ], /* |-------------------------------------------------------------------------- | Cache Key Prefix |-------------------------------------------------------------------------- | | When utilizing the APC, database, memcached, Redis, and DynamoDB cache | stores, there might be other applications using the same cache. For | that reason, you may prefix every cache key to avoid collisions. | */ 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'), ]; ================================================ FILE: backend/config/database.php ================================================ env('DB_CONNECTION', 'sqlite'), /* |-------------------------------------------------------------------------- | Database Connections |-------------------------------------------------------------------------- | | Below are all of the database connections defined for your application. | An example configuration is provided for each database system which | is supported by Laravel. You're free to add / remove connections. | */ 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', 'url' => env('DB_URL'), 'database' => env('DB_DATABASE', storage_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), 'busy_timeout' => null, 'journal_mode' => null, 'synchronous' => null, ], 'mysql' => [ 'driver' => 'mysql', 'url' => env('DB_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'laravel'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), 'charset' => env('DB_CHARSET', 'utf8mb4'), 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], 'mariadb' => [ 'driver' => 'mariadb', 'url' => env('DB_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'laravel'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), 'charset' => env('DB_CHARSET', 'utf8mb4'), 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], 'pgsql' => [ 'driver' => 'pgsql', 'url' => env('DB_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '5432'), 'database' => env('DB_DATABASE', 'laravel'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), 'charset' => env('DB_CHARSET', 'utf8'), 'prefix' => '', 'prefix_indexes' => true, 'search_path' => 'public', 'sslmode' => 'prefer', ], 'sqlsrv' => [ 'driver' => 'sqlsrv', 'url' => env('DB_URL'), 'host' => env('DB_HOST', 'localhost'), 'port' => env('DB_PORT', '1433'), 'database' => env('DB_DATABASE', 'laravel'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), 'charset' => env('DB_CHARSET', 'utf8'), 'prefix' => '', 'prefix_indexes' => true, // 'encrypt' => env('DB_ENCRYPT', 'yes'), // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), ], ], /* |-------------------------------------------------------------------------- | Migration Repository Table |-------------------------------------------------------------------------- | | This table keeps track of all the migrations that have already run for | your application. Using this information, we can determine which of | the migrations on disk haven't actually been run on the database. | */ 'migrations' => [ 'table' => 'migrations', 'update_date_on_publish' => true, ], /* |-------------------------------------------------------------------------- | Redis Databases |-------------------------------------------------------------------------- | | Redis is an open source, fast, and advanced key-value store that also | provides a richer body of commands than a typical key-value system | such as Memcached. You may define your connection settings here. | */ 'redis' => [ 'client' => env('REDIS_CLIENT', 'phpredis'), 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'redis'), 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), ], 'default' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_DB', '0'), ], 'cache' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_CACHE_DB', '1'), ], ], ]; ================================================ FILE: backend/config/faro.php ================================================ env('FARO_ENABLED', false), /* |-------------------------------------------------------------------------- | Faro Collector Endpoint |-------------------------------------------------------------------------- | | The URL where Grafana Alloy FARO receiver is listening. | Default: http://localhost:12347/collect | | In Docker environments, use host.docker.internal to reach the host. | In production, use your actual Grafana Alloy endpoint. | */ 'collector_url' => env('FARO_COLLECTOR_URL', 'http://localhost:12347/collect'), /* |-------------------------------------------------------------------------- | API Key |-------------------------------------------------------------------------- | | The API key that must match the api_key configured in Grafana Alloy. | Default: faro-secret-key (change this in production!) | */ 'api_key' => env('FARO_API_KEY', 'faro-secret-key'), /* |-------------------------------------------------------------------------- | Application Information |-------------------------------------------------------------------------- | | Application metadata sent with Faro telemetry. | */ 'app' => [ 'name' => env('FARO_APP_NAME', env('APP_NAME', 'spacepad')), 'version' => env('FARO_APP_VERSION', env('APP_VERSION', '1.0.0')), 'environment' => env('FARO_APP_ENV', env('APP_ENV', 'local')), ], /* |-------------------------------------------------------------------------- | Session Tracking |-------------------------------------------------------------------------- | | Enable session tracking and user identification. | */ 'session_tracking' => env('FARO_SESSION_TRACKING', true), /* |-------------------------------------------------------------------------- | Performance Monitoring |-------------------------------------------------------------------------- | | Enable Web Vitals and performance metrics collection. | */ 'performance' => [ 'enabled' => env('FARO_PERFORMANCE_ENABLED', true), 'observe_long_tasks' => env('FARO_OBSERVE_LONG_TASKS', true), 'observe_resources' => env('FARO_OBSERVE_RESOURCES', true), ], /* |-------------------------------------------------------------------------- | Error Tracking |-------------------------------------------------------------------------- | | Enable automatic error and exception tracking. | */ 'errors' => [ 'enabled' => env('FARO_ERRORS_ENABLED', true), 'capture_unhandled_rejections' => env('FARO_CAPTURE_UNHANDLED_REJECTIONS', true), ], /* |-------------------------------------------------------------------------- | Console Logs |-------------------------------------------------------------------------- | | Enable capturing console logs (errors and warnings). | */ 'console' => [ 'enabled' => env('FARO_CONSOLE_ENABLED', true), 'levels' => env('FARO_CONSOLE_LEVELS', 'error,warn'), // Comma-separated: error, warn, info, debug ], /* |-------------------------------------------------------------------------- | User Interactions |-------------------------------------------------------------------------- | | Enable tracking user interactions (clicks, form submissions). | */ 'interactions' => [ 'enabled' => env('FARO_INTERACTIONS_ENABLED', true), ], ]; ================================================ FILE: backend/config/filesystems.php ================================================ env('FILESYSTEM_DISK', 'local'), /* |-------------------------------------------------------------------------- | Filesystem Disks |-------------------------------------------------------------------------- | | Below you may configure as many filesystem disks as necessary, and you | may even configure multiple disks for the same driver. Examples for | most supported storage drivers are configured here for reference. | | Supported drivers: "local", "ftp", "sftp", "s3" | */ 'disks' => [ 'local' => [ 'driver' => 'local', 'root' => storage_path('app/private'), 'serve' => true, 'throw' => false, ], 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), 'url' => env('APP_URL').'/storage', 'visibility' => 'public', 'throw' => false, ], 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'), 'url' => env('AWS_URL'), 'endpoint' => env('AWS_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 'throw' => false, ], ], /* |-------------------------------------------------------------------------- | Symbolic Links |-------------------------------------------------------------------------- | | Here you may configure the symbolic links that will be created when the | `storage:link` Artisan command is executed. The array keys should be | the locations of the links and the values should be their targets. | */ 'links' => [ public_path('storage') => storage_path('app/public'), ], ]; ================================================ FILE: backend/config/googletagmanager.php ================================================ env('GTM_ID', ''), // Enable or disable script rendering. Useful for local development. 'enabled' => env('GTM_ENABLED', false), // Script domain; keep default unless using a Server-Side GTM custom domain 'domain' => env('GTM_DOMAIN', 'www.googletagmanager.com'), // Session key for flashed data layer values 'sessionKey' => '_googleTagManager', ]; ================================================ FILE: backend/config/lemon-squeezy.php ================================================ env('LEMON_SQUEEZY_API_KEY'), /* |-------------------------------------------------------------------------- | Lemon Squeezy Signing Secret |-------------------------------------------------------------------------- | | The Lemon Squeezy signing secret is used to verify that the webhook | requests are coming from Lemon Squeezy. You can find your signing | secret in the Lemon Squeezy dashboard under the "Webhooks" section. | */ 'signing_secret' => env('LEMON_SQUEEZY_SIGNING_SECRET'), /* |-------------------------------------------------------------------------- | Lemon Squeezy Url Path |-------------------------------------------------------------------------- | | This is the base URI where routes from Lemon Squeezy will be served | from. The URL built into Lemon Squeezy is used by default; however, | you can modify this path as you see fit for your application. | */ 'path' => env('LEMON_SQUEEZY_PATH', 'lemon-squeezy'), /* |-------------------------------------------------------------------------- | Lemon Squeezy Store |-------------------------------------------------------------------------- | | This is the ID of your Lemon Squeezy store. You can find your store | ID in the Lemon Squeezy dashboard. The entered value should be the | part after the # sign. | */ 'store' => env('LEMON_SQUEEZY_STORE'), /* |-------------------------------------------------------------------------- | Default Redirect URL |-------------------------------------------------------------------------- | | This is the default redirect URL that will be used when a customer | is redirected back to your application after completing a purchase | from a checkout session in your Lemon Squeezy store. | */ 'redirect_url' => null, /* |-------------------------------------------------------------------------- | Currency Locale |-------------------------------------------------------------------------- | | This is the default locale in which your money values are formatted in | for display. To utilize other locales besides the default en locale | verify you have the "intl" PHP extension installed on the system. | */ 'currency_locale' => env('LEMON_SQUEEZY_CURRENCY_LOCALE', 'en'), ]; ================================================ FILE: backend/config/logging.php ================================================ env('LOG_CHANNEL', 'stderr'), /* |-------------------------------------------------------------------------- | Deprecations Log Channel |-------------------------------------------------------------------------- | | This option controls the log channel that should be used to log warnings | regarding deprecated PHP and library features. This allows you to get | your application ready for upcoming major versions of dependencies. | */ 'deprecations' => [ 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), 'trace' => env('LOG_DEPRECATIONS_TRACE', false), ], /* |-------------------------------------------------------------------------- | Log Channels |-------------------------------------------------------------------------- | | Here you may configure the log channels for your application. Laravel | utilizes the Monolog PHP logging library, which includes a variety | of powerful log handlers and formatters that you're free to use. | | Available drivers: "single", "daily", "slack", "syslog", | "errorlog", "monolog", "custom", "stack" | */ 'channels' => [ 'stack' => [ 'driver' => 'stack', 'channels' => explode(',', env('LOG_STACK', 'single')), 'ignore_exceptions' => false, ], 'single' => [ 'driver' => 'single', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, ], 'daily' => [ 'driver' => 'daily', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => env('LOG_DAILY_DAYS', 14), 'replace_placeholders' => true, ], 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), 'level' => env('LOG_LEVEL', 'critical'), 'replace_placeholders' => true, ], 'papertrail' => [ 'driver' => 'monolog', 'level' => env('LOG_LEVEL', 'debug'), 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), 'handler_with' => [ 'host' => env('PAPERTRAIL_URL'), 'port' => env('PAPERTRAIL_PORT'), 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), ], 'processors' => [PsrLogMessageProcessor::class], ], 'stderr' => [ 'driver' => 'monolog', 'level' => env('LOG_LEVEL', 'debug'), 'handler' => StreamHandler::class, 'formatter' => env('LOG_STDERR_FORMATTER'), 'with' => [ 'stream' => 'php://stderr', ], 'processors' => [PsrLogMessageProcessor::class], ], 'syslog' => [ 'driver' => 'syslog', 'level' => env('LOG_LEVEL', 'debug'), 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), 'replace_placeholders' => true, ], 'errorlog' => [ 'driver' => 'errorlog', 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, ], 'null' => [ 'driver' => 'monolog', 'handler' => NullHandler::class, ], 'emergency' => [ 'path' => storage_path('logs/laravel.log'), ], ], ]; ================================================ FILE: backend/config/magiclink.php ================================================ [ /* |-------------------------------------------------------------------------- | Token size |-------------------------------------------------------------------------- | | Here you may specify the length of token to verify the identify. | Max value is 255 characters, it will be used if bigger value is set. | */ 'length' => 64, ], 'url' => [ /* |-------------------------------------------------------------------------- | Path to Validate Token and Auto Auth |-------------------------------------------------------------------------- | | Here you may specify the name of the path you'd like to use so that | the verify token and auth in system. | */ 'validate_path' => 'magiclink', /* |-------------------------------------------------------------------------- | Path default to redirect |-------------------------------------------------------------------------- | | Here you may specify the name of the path you'd like to use so that | the redirect when verify correct token. | */ 'redirect_default' => '/', ], /* |-------------------------------------------------------------------------- | Response when token is invalid |-------------------------------------------------------------------------- | | Here you may specify the class with method __invoke to get the response | when token is invalid | */ 'invalid_response' => [ 'class' => MagicLink\Responses\Response::class, ], /* |-------------------------------------------------------------------------- | Disable default route |-------------------------------------------------------------------------- | | If you wish use your custom controller, you can invalidate the | default route of magic link, mark this configuration as true, | and add your custom route with the middleware: | MagicLink\Middlewares\MagiclinkMiddleware | */ 'disable_default_route' => false, 'access_code' => [ 'view' => 'magiclink::ask-for-access-code-form', ] ]; ================================================ FILE: backend/config/mail.php ================================================ env('MAIL_MAILER', 'log'), /* |-------------------------------------------------------------------------- | Mailer Configurations |-------------------------------------------------------------------------- | | Here you may configure all of the mailers used by your application plus | their respective settings. Several examples have been configured for | you and you are free to add your own as your application requires. | | Laravel supports a variety of mail "transport" drivers that can be used | when delivering an email. You may specify which one you're using for | your mailers below. You may also add additional mailers if needed. | | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", | "postmark", "resend", "log", "array", | "failover", "roundrobin" | */ 'mailers' => [ 'smtp' => [ 'transport' => 'smtp', 'scheme' => env('MAIL_SCHEME'), 'url' => env('MAIL_URL'), 'host' => env('MAIL_HOST', '127.0.0.1'), 'port' => env('MAIL_PORT', 2525), 'username' => env('MAIL_USERNAME'), 'password' => env('MAIL_PASSWORD'), 'timeout' => null, 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)), ], 'ses' => [ 'transport' => 'ses', ], 'postmark' => [ 'transport' => 'postmark', // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), // 'client' => [ // 'timeout' => 5, // ], ], 'resend' => [ 'transport' => 'resend', ], 'sendmail' => [ 'transport' => 'sendmail', 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), ], 'log' => [ 'transport' => 'log', 'channel' => env('MAIL_LOG_CHANNEL'), ], 'array' => [ 'transport' => 'array', ], 'failover' => [ 'transport' => 'failover', 'mailers' => [ 'smtp', 'log', ], ], 'roundrobin' => [ 'transport' => 'roundrobin', 'mailers' => [ 'ses', 'postmark', ], ], ], /* |-------------------------------------------------------------------------- | Global "From" Address |-------------------------------------------------------------------------- | | You may wish for all emails sent by your application to be sent from | the same address. Here you may specify a name and address that is | used globally for all emails that are sent by your application. | */ 'from' => [ 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 'name' => env('MAIL_FROM_NAME', 'Example'), ], ]; ================================================ FILE: backend/config/queue.php ================================================ env('QUEUE_CONNECTION', 'sync'), /* |-------------------------------------------------------------------------- | Queue Connections |-------------------------------------------------------------------------- | | Here you may configure the connection options for every queue backend | used by your application. An example configuration is provided for | each backend supported by Laravel. You're also free to add more. | | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" | */ 'connections' => [ 'sync' => [ 'driver' => 'sync', ], 'database' => [ 'driver' => 'database', 'connection' => env('DB_QUEUE_CONNECTION'), 'table' => env('DB_QUEUE_TABLE', 'jobs'), 'queue' => env('DB_QUEUE', 'default'), 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), 'after_commit' => false, ], 'beanstalkd' => [ 'driver' => 'beanstalkd', 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), 'queue' => env('BEANSTALKD_QUEUE', 'default'), 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), 'block_for' => 0, 'after_commit' => false, ], 'sqs' => [ 'driver' => 'sqs', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), 'queue' => env('SQS_QUEUE', 'default'), 'suffix' => env('SQS_SUFFIX'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'after_commit' => false, ], 'redis' => [ 'driver' => 'redis', 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), 'queue' => env('REDIS_QUEUE', 'default'), 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), 'block_for' => null, 'after_commit' => false, ], ], /* |-------------------------------------------------------------------------- | Job Batching |-------------------------------------------------------------------------- | | The following options configure the database and table that store job | batching information. These options can be updated to any database | connection and table which has been defined by your application. | */ 'batching' => [ 'database' => env('DB_CONNECTION', 'sqlite'), 'table' => 'job_batches', ], /* |-------------------------------------------------------------------------- | Failed Queue Jobs |-------------------------------------------------------------------------- | | These options configure the behavior of failed queue job logging so you | can control how and where failed jobs are stored. Laravel ships with | support for storing failed jobs in a simple file or in a database. | | Supported drivers: "database-uuids", "dynamodb", "file", "null" | */ 'failed' => [ 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 'database' => env('DB_CONNECTION', 'sqlite'), 'table' => 'failed_jobs', ], ]; ================================================ FILE: backend/config/recaptchav3.php ================================================ env('RECAPTCHAV3_ORIGIN', 'https://www.google.com/recaptcha'), 'sitekey' => env('RECAPTCHAV3_SITEKEY'), 'secret' => env('RECAPTCHAV3_SECRET'), 'locale' => env('RECAPTCHAV3_LOCALE') ]; ================================================ FILE: backend/config/sanctum.php ================================================ explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( '%s%s%s', 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : '', env('FRONTEND_URL') ? ','.parse_url(env('FRONTEND_URL'), PHP_URL_HOST) : '' ))), /* |-------------------------------------------------------------------------- | Sanctum Guards |-------------------------------------------------------------------------- | | This array contains the authentication guards that will be checked when | Sanctum is trying to authenticate a request. If none of these guards | are able to authenticate the request, Sanctum will use the bearer | token that's present on an incoming request for authentication. | */ 'guard' => ['web'], /* |-------------------------------------------------------------------------- | Expiration Minutes |-------------------------------------------------------------------------- | | This value controls the number of minutes until an issued token will be | considered expired. This will override any values set in the token's | "expires_at" attribute, but first-party sessions are not affected. | */ 'expiration' => null, /* |-------------------------------------------------------------------------- | Sanctum Middleware |-------------------------------------------------------------------------- | | When authenticating your first-party SPA with Sanctum you may need to | customize some of the middleware Sanctum uses while processing the | request. You may change the middleware listed below as required. | */ 'middleware' => [ 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class, 'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class, ], ]; ================================================ FILE: backend/config/sentry.php ================================================ env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')), // @see https://spotlightjs.com/ // 'spotlight' => env('SENTRY_SPOTLIGHT', false), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger // 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')` // The release version of your application // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) 'release' => env('SENTRY_RELEASE'), // When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`) 'environment' => env('SENTRY_ENVIRONMENT'), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample-rate 'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces-sample-rate 'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles-sample-rate 'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send-default-pii 'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false), // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore-exceptions // 'ignore_exceptions' => [], // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore-transactions 'ignore_transactions' => [ // Ignore Laravel's default health URL '/up', ], // Breadcrumb specific configuration 'breadcrumbs' => [ // Capture Laravel logs as breadcrumbs 'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true), // Capture Laravel cache events (hits, writes etc.) as breadcrumbs 'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true), // Capture Livewire components like routes as breadcrumbs 'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true), // Capture SQL queries as breadcrumbs 'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true), // Capture SQL query bindings (parameters) in SQL query breadcrumbs 'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false), // Capture queue job information as breadcrumbs 'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true), // Capture command information as breadcrumbs 'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true), // Capture HTTP client request information as breadcrumbs 'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), // Capture send notifications as breadcrumbs 'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), ], // Performance monitoring specific configuration 'tracing' => [ // Trace queue jobs as their own transactions (this enables tracing for queue jobs) 'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true), // Capture queue jobs as spans when executed on the sync driver 'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true), // Capture SQL queries as spans 'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true), // Capture SQL query bindings (parameters) in SQL query spans 'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false), // Capture where the SQL query originated from on the SQL query spans 'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true), // Define a threshold in milliseconds for SQL queries to resolve their origin 'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100), // Capture views rendered as spans 'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true), // Capture Livewire components as spans 'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true), // Capture HTTP client requests as spans 'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true), // Capture Laravel cache events (hits, writes etc.) as spans 'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true), // Capture Redis operations as spans (this enables Redis events in Laravel) 'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false), // Capture where the Redis command originated from on the Redis command spans 'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), // Capture send notifications as spans 'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), // Enable tracing for requests without a matching route (404's) 'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), // Configures if the performance trace should continue after the response has been sent to the user until the application terminates // This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example 'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true), // Enable the tracing integrations supplied by Sentry (recommended) 'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true), ], ]; ================================================ FILE: backend/config/services.php ================================================ [ 'token' => env('POSTMARK_TOKEN'), ], 'ses' => [ 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], 'resend' => [ 'key' => env('RESEND_KEY'), ], 'slack' => [ 'notifications' => [ 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), ], ], 'outlook' => [ 'client_id' => env('OUTLOOK_CLIENT_ID'), 'client_secret' => env('OUTLOOK_CLIENT_SECRET'), 'redirect' => env('OUTLOOK_REDIRECT_URI'), ], 'google' => [ 'enabled' => env('GOOGLE_CLIENT_ID') !== null, 'client_id' => env('GOOGLE_CLIENT_ID'), 'client_secret' => env('GOOGLE_CLIENT_SECRET'), 'redirect' => env('GOOGLE_REDIRECT_URI', 'https://'.env('DOMAIN').'/auth/google/callback'), 'calendar_redirect' => env('GOOGLE_CALENDAR_REDIRECT_URI', 'https://'.env('DOMAIN').'/google-accounts/callback'), 'webhook_url' => env('GOOGLE_WEBHOOK_URL', 'https://'.env('DOMAIN').'/api/webhook/google'), ], 'azure_ad' => [ 'enabled' => env('AZURE_AD_CLIENT_ID') !== null, 'client_id' => env('AZURE_AD_CLIENT_ID'), 'client_secret' => env('AZURE_AD_CLIENT_SECRET'), 'redirect' => env('AZURE_AD_REDIRECT_URI', 'https://'.env('DOMAIN').'/outlook-accounts/callback'), 'tenant_id' => env('AZURE_AD_TENANT_ID', 'common'), 'webhook_url' => env('OUTLOOK_WEBHOOK_URL', 'https://'.env('DOMAIN').'/api/webhook/outlook') ], 'microsoft' => [ 'enabled' => env('MICROSOFT_CLIENT_ID', env('AZURE_AD_CLIENT_ID')) !== null, 'client_id' => env('MICROSOFT_CLIENT_ID', env('AZURE_AD_CLIENT_ID')), 'client_secret' => env('MICROSOFT_CLIENT_SECRET', env('AZURE_AD_CLIENT_SECRET')), 'redirect' => env('MICROSOFT_REDIRECT_URI', 'https://'.env('DOMAIN').'/auth/microsoft/callback'), 'proxy' => env('PROXY') // Optional, will be used for all requests ], 'caldav' => [ 'enabled' => env('CALDAV_ENABLED', true), 'default_timezone' => env('CALDAV_DEFAULT_TIMEZONE', 'UTC'), ], 'events' => [ 'cache_enabled' => env('EVENTS_CACHE_ENABLED', true), ], 'clarity' => [ 'tag_code' => env('CLARITY_TAG_CODE'), ], 'google_conversion' => [ 'send_to' => env('GOOGLE_CONVERSION_SEND_TO'), 'value' => env('GOOGLE_CONVERSION_VALUE', 1.0), 'currency' => env('GOOGLE_CONVERSION_CURRENCY', 'EUR'), ], ]; ================================================ FILE: backend/config/session.php ================================================ env('SESSION_DRIVER', 'database'), /* |-------------------------------------------------------------------------- | Session Lifetime |-------------------------------------------------------------------------- | | Here you may specify the number of minutes that you wish the session | to be allowed to remain idle before it expires. If you want them | to expire immediately when the browser is closed then you may | indicate that via the expire_on_close configuration option. | */ 'lifetime' => env('SESSION_LIFETIME', 120), 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), /* |-------------------------------------------------------------------------- | Session Encryption |-------------------------------------------------------------------------- | | This option allows you to easily specify that all of your session data | should be encrypted before it's stored. All encryption is performed | automatically by Laravel and you may use the session like normal. | */ 'encrypt' => env('SESSION_ENCRYPT', false), /* |-------------------------------------------------------------------------- | Session File Location |-------------------------------------------------------------------------- | | When utilizing the "file" session driver, the session files are placed | on disk. The default storage location is defined here; however, you | are free to provide another location where they should be stored. | */ 'files' => storage_path('framework/sessions'), /* |-------------------------------------------------------------------------- | Session Database Connection |-------------------------------------------------------------------------- | | When using the "database" or "redis" session drivers, you may specify a | connection that should be used to manage these sessions. This should | correspond to a connection in your database configuration options. | */ 'connection' => env('SESSION_CONNECTION'), /* |-------------------------------------------------------------------------- | Session Database Table |-------------------------------------------------------------------------- | | When using the "database" session driver, you may specify the table to | be used to store sessions. Of course, a sensible default is defined | for you; however, you're welcome to change this to another table. | */ 'table' => env('SESSION_TABLE', 'sessions'), /* |-------------------------------------------------------------------------- | Session Cache Store |-------------------------------------------------------------------------- | | When using one of the framework's cache driven session backends, you may | define the cache store which should be used to store the session data | between requests. This must match one of your defined cache stores. | | Affects: "apc", "dynamodb", "memcached", "redis" | */ 'store' => env('SESSION_STORE'), /* |-------------------------------------------------------------------------- | Session Sweeping Lottery |-------------------------------------------------------------------------- | | Some session drivers must manually sweep their storage location to get | rid of old sessions from storage. Here are the chances that it will | happen on a given request. By default, the odds are 2 out of 100. | */ 'lottery' => [2, 100], /* |-------------------------------------------------------------------------- | Session Cookie Name |-------------------------------------------------------------------------- | | Here you may change the name of the session cookie that is created by | the framework. Typically, you should not need to change this value | since doing so does not grant a meaningful security improvement. | */ 'cookie' => env( 'SESSION_COOKIE', Str::slug(env('APP_NAME', 'laravel'), '_').'_session' ), /* |-------------------------------------------------------------------------- | Session Cookie Path |-------------------------------------------------------------------------- | | The session cookie path determines the path for which the cookie will | be regarded as available. Typically, this will be the root path of | your application, but you're free to change this when necessary. | */ 'path' => env('SESSION_PATH', '/'), /* |-------------------------------------------------------------------------- | Session Cookie Domain |-------------------------------------------------------------------------- | | This value determines the domain and subdomains the session cookie is | available to. By default, the cookie will be available to the root | domain and all subdomains. Typically, this shouldn't be changed. | */ 'domain' => env('SESSION_DOMAIN'), /* |-------------------------------------------------------------------------- | HTTPS Only Cookies |-------------------------------------------------------------------------- | | By setting this option to true, session cookies will only be sent back | to the server if the browser has a HTTPS connection. This will keep | the cookie from being sent to you when it can't be done securely. | */ 'secure' => env('SESSION_SECURE_COOKIE'), /* |-------------------------------------------------------------------------- | HTTP Access Only |-------------------------------------------------------------------------- | | Setting this value to true will prevent JavaScript from accessing the | value of the cookie and the cookie will only be accessible through | the HTTP protocol. It's unlikely you should disable this option. | */ 'http_only' => env('SESSION_HTTP_ONLY', true), /* |-------------------------------------------------------------------------- | Same-Site Cookies |-------------------------------------------------------------------------- | | This option determines how your cookies behave when cross-site requests | take place, and can be used to mitigate CSRF attacks. By default, we | will set this value to "lax" to permit secure cross-site requests. | | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value | | Supported: "lax", "strict", "none", null | */ 'same_site' => env('SESSION_SAME_SITE', 'lax'), /* |-------------------------------------------------------------------------- | Partitioned Cookies |-------------------------------------------------------------------------- | | Setting this value to true will tie the cookie to the top-level site for | a cross-site context. Partitioned cookies are accepted by the browser | when flagged "secure" and the Same-Site attribute is set to "none". | */ 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), ]; ================================================ FILE: backend/config/settings.php ================================================ env('SELF_HOSTED', true), 'registration_webhook_url' => env('REGISTRATION_WEBHOOK_URL'), 'onboarding_complete_webhook_url' => env('ONBOARDING_COMPLETE_WEBHOOK_URL'), 'order_created_webhook_url' => env('ORDER_CREATED_WEBHOOK_URL'), 'user_not_activated_after_24h_webhook_url' => env('USER_NOT_ACTIVATED_AFTER_24H_WEBHOOK_URL'), 'user_activated_after_24h_webhook_url' => env('USER_ACTIVATED_AFTER_24H_WEBHOOK_URL'), 'trial_expired_or_cancelled_webhook_url' => env('TRIAL_EXPIRED_OR_CANCELLED_WEBHOOK_URL'), 'user_passive_webhook_url' => env('USER_PASSIVE_WEBHOOK_URL'), 'user_inactive_webhook_url' => env('USER_INACTIVE_WEBHOOK_URL'), 'license_server' => env('LICENSE_SERVER', 'https://app.spacepad.io'), 'cloud_hosted_pro_plan_id' => env('CLOUD_HOSTED_PRO_PLAN_ID'), 'version' => env('SPACEPAD_VERSION'), 'disable_email_login' => env('DISABLE_EMAIL_LOGIN', false), 'allowed_logins' => array_filter(array_map('trim', explode(',', env('ALLOWED_LOGINS', '')))), // Comma-separated list of allowed domains or emails ]; ================================================ FILE: backend/config/wave.php ================================================ 60, /* |-------------------------------------------------------------------------- | Reconnection Time |-------------------------------------------------------------------------- | | This value determines how long (in milliseconds) to wait before | attempting a reconnect to the server after a connection has been lost. | By default, the client attempts to reconnect immediately. For more | information, please refer to the Mozilla developer's guide on event | stream format. | https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format | */ 'retry' => null, /* |-------------------------------------------------------------------------- | Ping |-------------------------------------------------------------------------- | | A ping event is automatically sent on every SSE connection request if the | last event occurred before the set `frequency` value (in seconds). This | ensures the connection remains persistent. | | By setting the `eager_env` option, a ping event will be sent with each | request. This is useful for development or for applications that do not | frequently expect events. The `eager_env` option can be set as an `array` or `null`. | | For manual control of the ping event with the `sse:ping` command, you can | disable this option. | */ 'ping' => [ 'enable' => true, 'frequency' => 30, 'eager_env' => 'local', // null or array ], /* |-------------------------------------------------------------------------- | Routes Path |-------------------------------------------------------------------------- | | This path is used to register the necessary routes for establishing the | Wave connection, storing presence channel users, and handling simple whisper events. | */ 'path' => 'wave', /* |-------------------------------------------------------------------------- | Route Middleware |-------------------------------------------------------------------------- | | Define which middleware Wave should assign to the routes that it registers. | You may modify these middleware as needed. However, the default value is | typically sufficient. | */ 'middleware' => [ 'web', ], /* |-------------------------------------------------------------------------- | Auth & Guard |-------------------------------------------------------------------------- | | Define the default authentication middleware and guard type for | authenticating users for presence channels and whisper events. | */ 'auth_middleware' => 'auth', 'guard' => 'web', ]; ================================================ FILE: backend/database/.gitignore ================================================ *.sqlite* ================================================ FILE: backend/database/factories/BoardFactory.php ================================================ */ class BoardFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ protected $model = Board::class; /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'workspace_id' => Workspace::factory(), 'user_id' => User::factory(), 'name' => $this->faker->words(3, true), 'title' => null, 'subtitle' => null, 'show_all_displays' => true, 'theme' => 'dark', 'logo' => null, 'show_title' => true, 'show_booker' => true, 'show_next_event' => true, 'show_transitioning' => true, 'transitioning_minutes' => 10, 'font_family' => 'Inter', 'language' => 'en', 'view_mode' => 'card', 'show_meeting_title' => true, ]; } } ================================================ FILE: backend/database/factories/CalDAVAccountFactory.php ================================================ */ class CalDAVAccountFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ protected $model = CalDAVAccount::class; /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'user_id' => User::factory(), 'name' => $this->faker->name(), 'email' => $this->faker->unique()->safeEmail(), 'avatar' => $this->faker->imageUrl(), 'status' => AccountStatus::CONNECTED, 'url' => $this->faker->url(), 'username' => $this->faker->userName(), 'password' => $this->faker->password(), ]; } } ================================================ FILE: backend/database/factories/CalendarFactory.php ================================================ */ class CalendarFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ protected $model = Calendar::class; /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'user_id' => User::factory(), 'calendar_id' => $this->faker->uuid(), 'name' => $this->faker->word(), 'is_primary' => false, ]; } /** * Indicate that the calendar is primary. */ public function primary(): static { return $this->state(fn (array $attributes) => [ 'is_primary' => true, ]); } /** * Indicate that the calendar belongs to an Outlook account. */ public function outlook(): static { return $this->state(fn (array $attributes) => [ 'outlook_account_id' => OutlookAccount::factory(), ]); } /** * Indicate that the calendar belongs to a Google account. */ public function google(): static { return $this->state(fn (array $attributes) => [ 'google_account_id' => GoogleAccount::factory(), ]); } /** * Indicate that the calendar belongs to a CalDAV account. */ public function caldav(): static { return $this->state(fn (array $attributes) => [ 'caldav_account_id' => CalDAVAccount::factory(), ]); } } ================================================ FILE: backend/database/factories/DeviceFactory.php ================================================ */ class DeviceFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ protected $model = Device::class; /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'user_id' => User::factory(), 'name' => $this->faker->word(), 'uid' => Str::random(32), ]; } } ================================================ FILE: backend/database/factories/DisplayFactory.php ================================================ */ class DisplayFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ protected $model = Display::class; /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'user_id' => User::factory(), 'calendar_id' => Calendar::factory(), 'name' => $this->faker->word(), 'display_name' => $this->faker->word(), 'status' => DisplayStatus::READY, ]; } /** * Indicate that the display is active. */ public function active(): static { return $this->state(fn (array $attributes) => [ 'status' => DisplayStatus::ACTIVE, ]); } /** * Indicate that the display is deactivated. */ public function deactivated(): static { return $this->state(fn (array $attributes) => [ 'status' => DisplayStatus::DEACTIVATED, ]); } } ================================================ FILE: backend/database/factories/EventSubscriptionFactory.php ================================================ $this->faker->uuid, 'resource' => 'me/events', 'expiration' => now()->addDays(3), 'notification_url' => config('services.azure_ad.webhook_url'), 'display_id' => Display::factory(), 'outlook_account_id' => null, 'google_account_id' => null, ]; } public function outlook(OutlookAccount $account): self { return $this->state(function (array $attributes) use ($account) { return [ 'outlook_account_id' => $account->id, 'google_account_id' => null, ]; }); } public function google(GoogleAccount $account): self { return $this->state(function (array $attributes) use ($account) { return [ 'outlook_account_id' => null, 'google_account_id' => $account->id, ]; }); } } ================================================ FILE: backend/database/factories/GoogleAccountFactory.php ================================================ */ class GoogleAccountFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ protected $model = GoogleAccount::class; /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'user_id' => User::factory(), 'name' => $this->faker->name(), 'email' => $this->faker->unique()->safeEmail(), 'avatar' => $this->faker->imageUrl(), 'hosted_domain' => null, 'status' => AccountStatus::CONNECTED, 'google_id' => $this->faker->uuid(), 'token' => $this->faker->uuid(), 'refresh_token' => $this->faker->uuid(), 'token_expires_at' => now()->addHour(), ]; } /** * Indicate that the account is a business account. */ public function business(): static { return $this->state(fn (array $attributes) => [ 'hosted_domain' => $this->faker->domainName(), ]); } } ================================================ FILE: backend/database/factories/InstanceFactory.php ================================================ */ class InstanceFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ protected $model = Instance::class; /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'instance_key' => $this->faker->sha1(), 'license_key' => null, 'license_valid' => false, 'license_expires_at' => null, 'is_self_hosted' => true, 'displays_count' => $this->faker->numberBetween(0, 10), 'rooms_count' => $this->faker->numberBetween(0, 5), 'boards_count' => null, 'users' => [ [ 'email' => $this->faker->safeEmail(), 'usage_type' => 'personal', ], ], 'version' => '1.0.0', 'last_validated_at' => now(), 'last_heartbeat_at' => now(), ]; } } ================================================ FILE: backend/database/factories/OutlookAccountFactory.php ================================================ */ class OutlookAccountFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ protected $model = OutlookAccount::class; /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'user_id' => User::factory(), 'name' => $this->faker->name(), 'email' => $this->faker->unique()->safeEmail(), 'avatar' => $this->faker->imageUrl(), 'tenant_id' => $this->faker->uuid(), 'status' => AccountStatus::CONNECTED, 'outlook_id' => $this->faker->uuid(), 'token' => $this->faker->uuid(), 'refresh_token' => $this->faker->uuid(), 'token_expires_at' => now()->addHour(), ]; } /** * Indicate that the account is a business account. */ public function business(): static { return $this->state(fn (array $attributes) => [ 'tenant_id' => $this->faker->uuid(), ]); } } ================================================ FILE: backend/database/factories/RoomFactory.php ================================================ */ class RoomFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ protected $model = Room::class; /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'user_id' => User::factory(), 'name' => $this->faker->word(), 'email_address' => $this->faker->unique()->safeEmail(), 'calendar_id' => null, ]; } } ================================================ FILE: backend/database/factories/UserFactory.php ================================================ */ class UserFactory extends Factory { /** * The current password being used by the factory. */ protected static ?string $password; /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'name' => $this->faker->name(), 'email' => $this->faker->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), 'status' => UserStatus::ONBOARDING, 'is_unlimited' => false, 'terms_accepted_at' => null, ]; } /** * Indicate that the model's email address should be unverified. */ public function unverified(): static { return $this->state(fn (array $attributes) => [ 'email_verified_at' => null, ]); } /** * Indicate that the user is active and has an Outlook account. */ public function active(): static { return $this->state(fn (array $attributes) => [ 'status' => UserStatus::ACTIVE, 'usage_type' => UsageType::PERSONAL, 'terms_accepted_at' => now(), ])->afterCreating(function ($user) { OutlookAccount::factory()->create(['user_id' => $user->id]); }); } } ================================================ FILE: backend/database/factories/WorkspaceFactory.php ================================================ */ class WorkspaceFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ protected $model = Workspace::class; /** * Define the model's default state. * * @return array */ public function definition(): array { return [ 'name' => $this->faker->company() . ' Workspace', ]; } } ================================================ FILE: backend/database/migrations/2014_10_12_000000_create_users_table.php ================================================ ulid('id')->primary(); $table->string('name'); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password')->nullable(); $table->string('microsoft_id')->nullable(); $table->string('status')->nullable(); $table->rememberToken(); $table->timestamps(); $table->softDeletes(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('users'); } }; ================================================ FILE: backend/database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php ================================================ string('email')->primary(); $table->string('token'); $table->timestamp('created_at')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('password_reset_tokens'); } }; ================================================ FILE: backend/database/migrations/2017_07_06_000000_create_table_magic_links.php ================================================ uuid('id')->primary(); $table->string('token', 255); $table->text('action'); $table->unsignedTinyInteger('num_visits')->default(0); $table->unsignedTinyInteger('max_visits')->nullable(); $table->timestamp('available_at')->nullable(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists(config('magiclink.magiclink_table', 'magic_links')); } } ================================================ FILE: backend/database/migrations/2019_08_19_000000_create_failed_jobs_table.php ================================================ id(); $table->string('uuid')->unique(); $table->text('connection'); $table->text('queue'); $table->longText('payload'); $table->longText('exception'); $table->timestamp('failed_at')->useCurrent(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('failed_jobs'); } }; ================================================ FILE: backend/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php ================================================ ulid('id')->primary(); $table->ulidMorphs('tokenable'); $table->string('name'); $table->string('token', 64)->unique(); $table->text('abilities')->nullable(); $table->timestamp('last_used_at')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('personal_access_tokens'); } }; ================================================ FILE: backend/database/migrations/2021_03_06_211907_add_access_code_to_magic_links_table.php ================================================ string('access_code')->nullable(); }); } /** * Reverse the migrations. * * @return void */ public function down() { if (Schema::hasColumn('magic_links', 'access_code')) { Schema::table('magic_links', function (Blueprint $table) { $table->dropColumn('access_code'); }); } } } ================================================ FILE: backend/database/migrations/2024_03_19_000000_add_usage_type_to_users_table.php ================================================ string('usage_type')->nullable()->after('status'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('users', function (Blueprint $table) { $table->dropColumn('usage_type'); }); } }; ================================================ FILE: backend/database/migrations/2024_10_08_193424_create_outlook_accounts_table.php ================================================ ulid('id')->primary(); $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); // Link to users table $table->string('outlook_id')->unique(); // Unique ID from Microsoft (Outlook) $table->string('email')->unique(); // The user's Outlook email $table->string('name')->nullable(); // Optional: The user's display name $table->text('avatar')->nullable(); // Optional: The user's avatar image $table->text('token'); // OAuth access token $table->text('refresh_token')->nullable(); // Optional: OAuth refresh token $table->timestamp('token_expires_at')->nullable(); // Expiry time for the token $table->timestamps(); // Laravel default: created_at and updated_at }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('outlook_accounts'); } }; ================================================ FILE: backend/database/migrations/2024_10_08_193455_create_calendars_table.php ================================================ ulid('id')->primary(); $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); // Link to the user who owns the calendar $table->foreignUlid('outlook_account_id')->nullable()->constrained()->onDelete('cascade'); // Link to OutlookAccount $table->string('calendar_id')->unique(); // External calendar ID $table->string('name'); // Name of the calendar (e.g., "Work", "Personal") $table->boolean('is_primary')->default(false); // Whether it's the user's primary calendar $table->timestamps(); // Laravel default: created_at and updated_at }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('calendars'); } }; ================================================ FILE: backend/database/migrations/2024_10_12_203020_create_displays_table.php ================================================ ulid('id')->primary(); $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); // Link to the user performing the sync $table->foreignUlid('calendar_id')->constrained('calendars')->onDelete('cascade'); // Link to the calendar being synced $table->string('name'); $table->string('display_name'); $table->string('status')->nullable(); $table->timestamp('last_sync_at', 6)->nullable(); $table->timestamp('last_event_at', 6)->nullable(); $table->timestamps(); // Laravel default: created_at and updated_at }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('displays'); } }; ================================================ FILE: backend/database/migrations/2024_10_17_212003_create_event_subscriptions_table.php ================================================ ulid('id')->primary(); $table->string('subscription_id')->unique(); // Unique ID of the subscription from Microsoft Graph $table->string('resource'); // The resource the subscription is for (e.g., 'me/events') $table->timestamp('expiration')->nullable(); // Expiration time of the subscription $table->string('notification_url'); // URL where the notifications will be sent $table->foreignUlid('display_id')->constrained()->onDelete('cascade'); $table->foreignUlid('outlook_account_id')->constrained()->onDelete('cascade'); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('event_subscriptions'); } }; ================================================ FILE: backend/database/migrations/2025_01_12_122905_create_devices_table.php ================================================ ulid('id')->primary(); $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); // Link to the user performing the sync $table->foreignUlid('display_id')->nullable()->constrained()->onDelete('cascade'); // Link to the calendar being synced $table->string('name'); $table->timestamps(); // Laravel default: created_at and updated_at }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('devices'); } }; ================================================ FILE: backend/database/migrations/2025_01_12_190259_create_rooms_table.php ================================================ ulid('id')->primary(); $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); $table->foreignUlid('calendar_id')->constrained()->onDelete('cascade'); $table->string('name'); $table->string('email_address'); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('rooms'); } }; ================================================ FILE: backend/database/migrations/2025_05_04_204354_remove_unique_from_outlook_accounts.php ================================================ dropUnique(['outlook_id']); $table->dropUnique(['email']); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('outlook_accounts', function (Blueprint $table) { $table->unique('outlook_id'); $table->unique('email'); }); } }; ================================================ FILE: backend/database/migrations/2025_05_07_181029_create_sessions_table.php ================================================ string('id')->primary(); $table->foreignId('user_id')->nullable()->index(); $table->string('ip_address', 45)->nullable(); $table->text('user_agent')->nullable(); $table->longText('payload'); $table->integer('last_activity')->index(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('sessions'); } }; ================================================ FILE: backend/database/migrations/2025_05_07_181034_create_cache_table.php ================================================ string('key')->primary(); $table->mediumText('value'); $table->integer('expiration'); }); Schema::create('cache_locks', function (Blueprint $table) { $table->string('key')->primary(); $table->string('owner'); $table->integer('expiration'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('cache'); Schema::dropIfExists('cache_locks'); } }; ================================================ FILE: backend/database/migrations/2025_05_17_130507_create_google_accounts_table.php ================================================ ulid('id')->primary(); $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); // Link to users table $table->string('google_id')->unique(); $table->string('name'); $table->string('email')->unique(); $table->string('avatar')->nullable(); $table->text('token'); $table->text('refresh_token'); $table->timestamp('token_expires_at'); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('google_accounts'); } }; ================================================ FILE: backend/database/migrations/2025_05_17_153857_add_google_account_id_to_calendars_table.php ================================================ foreignUlid('google_account_id')->nullable()->after('outlook_account_id')->constrained()->onDelete('cascade'); // Link to GoogleAccount }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('calendars', function (Blueprint $table) { $table->dropForeign(['outlook_account_id']); $table->dropColumn('outlook_account_id'); }); } }; ================================================ FILE: backend/database/migrations/2025_05_18_010101_remove_unique_from_google_accounts.php ================================================ dropUnique(['google_id']); $table->dropUnique(['email']); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('google_accounts', function (Blueprint $table) { $table->unique('google_id'); $table->unique('email'); }); } }; ================================================ FILE: backend/database/migrations/2025_05_18_010201_remove_unique_from_calendars.php ================================================ dropUnique(['calendar_id']); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('calendars', function (Blueprint $table) { $table->unique('calendar_id'); }); } }; ================================================ FILE: backend/database/migrations/2025_05_18_114502_add_status_to_accounts.php ================================================ string('status')->default(AccountStatus::CONNECTED)->after('email'); }); Schema::table('outlook_accounts', function (Blueprint $table) { $table->string('status')->default(AccountStatus::CONNECTED)->after('email'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('google_accounts', function (Blueprint $table) { $table->dropColumn('status'); }); Schema::table('outlook_accounts', function (Blueprint $table) { $table->dropColumn('status'); }); } }; ================================================ FILE: backend/database/migrations/2025_05_21_000000_add_google_account_id_to_event_subscriptions.php ================================================ foreignUlid('google_account_id')->nullable()->after('outlook_account_id') ->constrained()->onDelete('cascade'); }); Schema::table('event_subscriptions', function (Blueprint $table) { $table->foreignUlid('outlook_account_id')->nullable(true)->change(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('event_subscriptions', function (Blueprint $table) { $table->dropForeign(['google_account_id']); $table->dropColumn('google_account_id'); }); } }; ================================================ FILE: backend/database/migrations/2025_05_23_000000_create_caldav_accounts_table.php ================================================ ulid('id')->primary(); $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); $table->string('name'); $table->string('email'); $table->string('avatar')->nullable(); $table->string('status')->default(AccountStatus::CONNECTED); $table->string('url'); $table->string('username'); $table->string('password'); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('caldav_accounts'); } }; ================================================ FILE: backend/database/migrations/2025_05_23_000001_add_caldav_account_id_to_calendars_table.php ================================================ foreignUlid('caldav_account_id')->nullable()->after('google_account_id')->constrained()->onDelete('cascade'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('calendars', function (Blueprint $table) { $table->dropForeign(['caldav_account_id']); $table->dropColumn('caldav_account_id'); }); } }; ================================================ FILE: backend/database/migrations/2025_05_23_201433_add_google_id_to_users_table.php ================================================ string('google_id')->after('microsoft_id')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('users', function (Blueprint $table) { $table->dropColumn('google_id'); }); } }; ================================================ FILE: backend/database/migrations/2025_05_27_203928_add_last_activity_at_to_users_table.php ================================================ timestamp('last_activity_at')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('users', function (Blueprint $table) { $table->dropColumn('last_activity_at'); }); } }; ================================================ FILE: backend/database/migrations/2025_05_27_204843_add_last_activity_at_to_devices_table.php ================================================ timestamp('last_activity_at')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('devices', function (Blueprint $table) { $table->dropColumn('last_activity_at'); }); } }; ================================================ FILE: backend/database/migrations/2025_05_28_193657_add_is_billing_exempt_to_users_table.php ================================================ boolean('is_billing_exempt')->default(false); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('users', function (Blueprint $table) { $table->dropColumn('is_billing_exempt'); }); } }; ================================================ FILE: backend/database/migrations/2025_05_28_194845_add_is_unlimited_to_users_table.php ================================================ boolean('is_unlimited')->default(false); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('users', function (Blueprint $table) { $table->dropColumn('is_unlimited'); }); } }; ================================================ FILE: backend/database/migrations/2025_06_08_000001_add_terms_accepted_at_to_users_table.php ================================================ timestamp('terms_accepted_at')->nullable(); }); } public function down(): void { Schema::table('users', function (Blueprint $table) { $table->dropColumn('terms_accepted_at'); }); } }; ================================================ FILE: backend/database/migrations/2025_06_09_115819_drop_is_billing_exempt_from_users_table.php ================================================ dropColumn('is_billing_exempt'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('users', function (Blueprint $table) { $table->boolean('is_billing_exempt')->default(false); }); } }; ================================================ FILE: backend/database/migrations/2025_06_09_122516_add_hosted_domain_to_google_accounts_table.php ================================================ string('hosted_domain')->after('avatar')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('google_accounts', function (Blueprint $table) { $table->dropColumn('hosted_domain'); }); } }; ================================================ FILE: backend/database/migrations/2025_06_09_122702_add_tenant_id_to_outlook_accounts_table.php ================================================ string('tenant_id')->after('avatar')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('outlook_accounts', function (Blueprint $table) { $table->dropColumn('tenant_id'); }); } }; ================================================ FILE: backend/database/migrations/2025_06_09_125231_add_uid_to_devices_table.php ================================================ string('uid')->after('name')->nullable(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('devices', function (Blueprint $table) { $table->dropColumn('uid'); }); } }; ================================================ FILE: backend/database/migrations/2025_06_09_150001_create_instances_table.php ================================================ ulid('id'); $table->string('instance_key')->unique(); $table->string('license_key')->nullable(); $table->boolean('license_valid')->nullable(); $table->timestamp('license_expires_at')->nullable(); $table->boolean('is_self_hosted')->nullable(); $table->integer('displays_count')->nullable(); $table->integer('rooms_count')->nullable(); $table->json('users')->nullable(); $table->string('version')->nullable(); $table->timestamp('last_validated_at')->nullable(); $table->timestamp('last_heartbeat_at')->nullable(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('instances'); } }; ================================================ FILE: backend/database/migrations/2025_06_15_000000_create_settings_table.php ================================================ ulid('id')->primary(); $table->string('key')->unique(); $table->text('value'); $table->string('type')->default('string'); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('settings'); } }; ================================================ FILE: backend/database/migrations/2025_06_15_120000_change_billable_id_to_ulid_on_lemonsqueezy_tables.php ================================================ getDriverName(); if ($driver === 'sqlite') { // Customers table Schema::table('lemon_squeezy_customers', function (Blueprint $table) { $table->dropUnique('lemon_squeezy_customers_billable_id_billable_type_unique'); }); Schema::table('lemon_squeezy_customers', function (Blueprint $table) { $table->dropColumn('billable_id'); }); Schema::table('lemon_squeezy_customers', function (Blueprint $table) { $table->ulid('billable_id')->after('id'); }); // Subscriptions table Schema::table('lemon_squeezy_subscriptions', function (Blueprint $table) { $table->dropColumn('billable_id'); }); Schema::table('lemon_squeezy_subscriptions', function (Blueprint $table) { $table->ulid('billable_id')->after('id'); }); // Orders table Schema::table('lemon_squeezy_orders', function (Blueprint $table) { $table->dropColumn('billable_id'); }); Schema::table('lemon_squeezy_orders', function (Blueprint $table) { $table->ulid('billable_id')->after('id'); }); } else { // Customers table Schema::table('lemon_squeezy_customers', function (Blueprint $table) { $table->ulid('billable_id')->change(); }); // Subscriptions table Schema::table('lemon_squeezy_subscriptions', function (Blueprint $table) { $table->ulid('billable_id')->change(); }); // Orders table Schema::table('lemon_squeezy_orders', function (Blueprint $table) { $table->ulid('billable_id')->change(); }); } } /** * Reverse the migrations. */ public function down(): void { if (config('settings.is_self_hosted')) { return; } // Customers table Schema::table('lemon_squeezy_customers', function (Blueprint $table) { $table->unsignedBigInteger('billable_id')->change(); }); // Subscriptions table Schema::table('lemon_squeezy_subscriptions', function (Blueprint $table) { $table->unsignedBigInteger('billable_id')->change(); }); // Orders table Schema::table('lemon_squeezy_orders', function (Blueprint $table) { $table->unsignedBigInteger('billable_id')->change(); }); } }; ================================================ FILE: backend/database/migrations/2025_06_16_000000_create_events_table.php ================================================ ulid('id')->primary(); $table->foreignUlid('display_id')->constrained()->onDelete('cascade'); $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); $table->foreignUlid('calendar_id')->nullable()->constrained()->onDelete('cascade'); $table->string('status'); $table->dateTime('start'); $table->dateTime('end'); $table->text('summary')->nullable(); $table->string('location')->nullable(); $table->text('description')->nullable(); $table->string('timezone'); $table->string('source'); $table->string('external_id')->nullable(); // Check-in functionality $table->timestamp('checked_in_at')->nullable(); // Audit logging $table->timestamps(); // Indexes for performance $table->index(['display_id', 'start', 'end']); $table->index(['external_id', 'source']); $table->index(['calendar_id', 'start']); }); } public function down(): void { Schema::dropIfExists('events'); } }; ================================================ FILE: backend/database/migrations/2025_07_05_000000_create_display_settings_table.php ================================================ ulid('id')->primary(); $table->foreignUlid('display_id')->constrained()->onDelete('cascade'); $table->string('key'); $table->text('value'); $table->string('type')->default('string'); $table->timestamps(); // Ensure unique settings per display $table->unique(['display_id', 'key']); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('display_settings'); } }; ================================================ FILE: backend/database/migrations/2025_07_05_000001_alter_avatar_column_on_google_accounts_table.php ================================================ text('avatar')->change(); }); } public function down(): void { Schema::table('google_accounts', function (Blueprint $table) { $table->string('avatar', 255)->change(); }); } }; ================================================ FILE: backend/database/migrations/2025_07_27_000000_add_is_admin_to_users_table.php ================================================ boolean('is_admin')->default(false)->nullable()->after('is_unlimited'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('users', function (Blueprint $table) { $table->dropColumn('is_admin'); }); } }; ================================================ FILE: backend/database/migrations/2025_11_28_000000_add_permission_type_to_outlook_accounts_table.php ================================================ string('permission_type')->default(PermissionType::READ)->after('status'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('outlook_accounts', function (Blueprint $table) { $table->dropColumn('permission_type'); }); } }; ================================================ FILE: backend/database/migrations/2025_11_28_000001_add_permission_type_to_google_accounts_table.php ================================================ string('permission_type')->default(PermissionType::READ)->after('status'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('google_accounts', function (Blueprint $table) { $table->dropColumn('permission_type'); }); } }; ================================================ FILE: backend/database/migrations/2025_11_28_000002_add_permission_type_to_caldav_accounts_table.php ================================================ string('permission_type')->default(PermissionType::WRITE)->after('status'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('caldav_accounts', function (Blueprint $table) { $table->dropColumn('permission_type'); }); } }; ================================================ FILE: backend/database/migrations/2025_12_03_000000_add_service_account_file_path_to_google_accounts_table.php ================================================ string('service_account_file_path')->nullable()->after('permission_type'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('google_accounts', function (Blueprint $table) { $table->dropColumn('service_account_file_path'); }); } }; ================================================ FILE: backend/database/migrations/2025_12_04_000000_add_booking_method_to_google_accounts_table.php ================================================ string('booking_method')->nullable()->after('permission_type'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('google_accounts', function (Blueprint $table) { $table->dropColumn('booking_method'); }); } }; ================================================ FILE: backend/database/migrations/2025_12_05_000000_encrypt_existing_tokens_in_google_and_outlook_accounts.php ================================================ get(); foreach ($googleAccounts as $account) { $updates = []; // Encrypt token if not already encrypted if (!empty($account->token) && !$this->isEncrypted($account->token)) { $updates['token'] = Crypt::encryptString($account->token); } // Encrypt refresh_token if not already encrypted if (!empty($account->refresh_token) && !$this->isEncrypted($account->refresh_token)) { $updates['refresh_token'] = Crypt::encryptString($account->refresh_token); } // Update only if there are changes if (!empty($updates)) { DB::table('google_accounts') ->where('id', $account->id) ->update($updates); } } // Encrypt Outlook accounts tokens $outlookAccounts = DB::table('outlook_accounts')->get(); foreach ($outlookAccounts as $account) { $updates = []; // Encrypt token if not already encrypted if (!empty($account->token) && !$this->isEncrypted($account->token)) { $updates['token'] = Crypt::encryptString($account->token); } // Encrypt refresh_token if not already encrypted if (!empty($account->refresh_token) && !$this->isEncrypted($account->refresh_token)) { $updates['refresh_token'] = Crypt::encryptString($account->refresh_token); } // Update only if there are changes if (!empty($updates)) { DB::table('outlook_accounts') ->where('id', $account->id) ->update($updates); } } } /** * Reverse the migrations. * * WARNING: This will decrypt all tokens. Only use this if you need to rollback * and understand the security implications. */ public function down(): void { // Decrypt Google accounts tokens $googleAccounts = DB::table('google_accounts')->get(); foreach ($googleAccounts as $account) { $updates = []; // Decrypt token if encrypted if (!empty($account->token)) { try { $decrypted = Crypt::decryptString($account->token); $updates['token'] = $decrypted; } catch (\Exception $e) { // Already decrypted or invalid, skip } } // Decrypt refresh_token if encrypted if (!empty($account->refresh_token)) { try { $decrypted = Crypt::decryptString($account->refresh_token); $updates['refresh_token'] = $decrypted; } catch (\Exception $e) { // Already decrypted or invalid, skip } } // Update only if there are changes if (!empty($updates)) { DB::table('google_accounts') ->where('id', $account->id) ->update($updates); } } // Decrypt Outlook accounts tokens $outlookAccounts = DB::table('outlook_accounts')->get(); foreach ($outlookAccounts as $account) { $updates = []; // Decrypt token if encrypted if (!empty($account->token)) { try { $decrypted = Crypt::decryptString($account->token); $updates['token'] = $decrypted; } catch (\Exception $e) { // Already decrypted or invalid, skip } } // Decrypt refresh_token if encrypted if (!empty($account->refresh_token)) { try { $decrypted = Crypt::decryptString($account->refresh_token); $updates['refresh_token'] = $decrypted; } catch (\Exception $e) { // Already decrypted or invalid, skip } } // Update only if there are changes if (!empty($updates)) { DB::table('outlook_accounts') ->where('id', $account->id) ->update($updates); } } } }; ================================================ FILE: backend/database/migrations/2025_12_06_000003_add_first_name_and_last_name_to_users_table.php ================================================ string('first_name')->nullable()->after('name'); }); Schema::table('users', function (Blueprint $table) { $table->string('last_name')->nullable()->after('first_name'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('users', function (Blueprint $table) { $table->dropColumn(['first_name', 'last_name']); }); } }; ================================================ FILE: backend/database/migrations/2025_12_30_000000_create_workspaces_table.php ================================================ ulid('id')->primary(); $table->string('name'); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('workspaces'); } }; ================================================ FILE: backend/database/migrations/2025_12_30_000001_create_workspace_members_table.php ================================================ ulid('id')->primary(); $table->foreignUlid('workspace_id')->constrained()->onDelete('cascade'); $table->foreignUlid('user_id')->constrained()->onDelete('cascade'); $table->string('role')->default('member'); // Uses WorkspaceRole enum: 'owner', 'admin', 'member' $table->timestamps(); // Ensure a user can only be a member once per workspace $table->unique(['workspace_id', 'user_id']); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('workspace_members'); } }; ================================================ FILE: backend/database/migrations/2025_12_30_000002_add_workspace_id_to_tables.php ================================================ foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); }); // Add workspace_id to devices Schema::table('devices', function (Blueprint $table) { $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); }); // Add workspace_id to calendars Schema::table('calendars', function (Blueprint $table) { $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); }); // Add workspace_id to rooms Schema::table('rooms', function (Blueprint $table) { $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('displays', function (Blueprint $table) { $table->dropForeign(['workspace_id']); $table->dropColumn('workspace_id'); }); Schema::table('devices', function (Blueprint $table) { $table->dropForeign(['workspace_id']); $table->dropColumn('workspace_id'); }); Schema::table('calendars', function (Blueprint $table) { $table->dropForeign(['workspace_id']); $table->dropColumn('workspace_id'); }); Schema::table('rooms', function (Blueprint $table) { $table->dropForeign(['workspace_id']); $table->dropColumn('workspace_id'); }); } }; ================================================ FILE: backend/database/migrations/2025_12_30_000003_add_workspace_id_to_accounts_tables.php ================================================ foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); }); // Add workspace_id to google_accounts Schema::table('google_accounts', function (Blueprint $table) { $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); }); // Add workspace_id to caldav_accounts Schema::table('caldav_accounts', function (Blueprint $table) { $table->foreignUlid('workspace_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('outlook_accounts', function (Blueprint $table) { $table->dropForeign(['workspace_id']); $table->dropColumn('workspace_id'); }); Schema::table('google_accounts', function (Blueprint $table) { $table->dropForeign(['workspace_id']); $table->dropColumn('workspace_id'); }); Schema::table('caldav_accounts', function (Blueprint $table) { $table->dropForeign(['workspace_id']); $table->dropColumn('workspace_id'); }); } }; ================================================ FILE: backend/database/migrations/2025_12_30_000004_create_workspaces_for_existing_users.php ================================================ workspaces()->exists()) { continue; } // Wrap per-user migration logic in a transaction for atomicity DB::transaction(function () use ($user) { // Create workspace for user $workspace = Workspace::create([ 'name' => $user->name . "'s Workspace", ]); // Add user as owner member (use WorkspaceMember::create to generate ULID) WorkspaceMember::create([ 'workspace_id' => $workspace->id, 'user_id' => $user->id, 'role' => WorkspaceRole::OWNER, ]); // Migrate displays to workspace Display::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); // Migrate devices to workspace Device::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); // Migrate calendars to workspace Calendar::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); // Migrate rooms to workspace Room::where('user_id', $user->id)->update(['workspace_id' => $workspace->id]); // Migrate Outlook accounts to workspace OutlookAccount::where('user_id', $user->id) ->whereNull('workspace_id') ->update(['workspace_id' => $workspace->id]); // Migrate Google accounts to workspace GoogleAccount::where('user_id', $user->id) ->whereNull('workspace_id') ->update(['workspace_id' => $workspace->id]); // Migrate CalDAV accounts to workspace CalDAVAccount::where('user_id', $user->id) ->whereNull('workspace_id') ->update(['workspace_id' => $workspace->id]); }); } }); } /** * Reverse the migrations. */ public function down(): void { // This migration cannot be fully reversed as we don't know which workspace // data belongs to which user after potential member additions. // In practice, you'd need to keep the user_id relationships intact. } }; ================================================ FILE: backend/database/migrations/2026_02_28_000000_increase_events_description_column_size.php ================================================ mediumText('description')->nullable()->change(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('events', function (Blueprint $table) { $table->text('description')->nullable()->change(); }); } }; ================================================ FILE: backend/database/migrations/2026_02_28_000001_increase_caldav_accounts_password_column_size.php ================================================ text('password')->change(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('caldav_accounts', function (Blueprint $table) { $table->string('password')->change(); }); } }; ================================================ FILE: backend/database/migrations/2026_02_28_120000_create_boards_table.php ================================================ ulid('id')->primary(); $table->foreignUlid('workspace_id')->constrained()->onDelete('cascade'); $table->foreignUlid('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('name'); $table->boolean('show_all_displays')->default(false); $table->timestamps(); $table->index('workspace_id'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('boards'); } }; ================================================ FILE: backend/database/migrations/2026_02_28_120001_create_board_displays_table.php ================================================ ulid('id')->primary(); $table->foreignUlid('board_id')->constrained('boards')->onDelete('cascade'); $table->foreignUlid('display_id')->constrained()->onDelete('cascade'); $table->timestamps(); $table->unique(['board_id', 'display_id']); $table->index('board_id'); $table->index('display_id'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('board_displays'); } }; ================================================ FILE: backend/database/migrations/2026_02_28_120002_add_theme_to_boards_table.php ================================================ string('theme')->default('dark')->after('show_all_displays'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('boards', function (Blueprint $table) { $table->dropColumn('theme'); }); } }; ================================================ FILE: backend/database/migrations/2026_02_28_120003_add_logo_to_boards_table.php ================================================ string('logo')->nullable()->after('theme'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('boards', function (Blueprint $table) { $table->dropColumn('logo'); }); } }; ================================================ FILE: backend/database/migrations/2026_02_28_120004_add_display_options_to_boards_table.php ================================================ boolean('show_title')->default(true)->after('logo'); $table->boolean('show_booker')->default(true)->after('show_title'); $table->boolean('show_next_event')->default(true)->after('show_booker'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('boards', function (Blueprint $table) { $table->dropColumn(['show_title', 'show_booker', 'show_next_event']); }); } }; ================================================ FILE: backend/database/migrations/2026_02_28_120005_add_additional_settings_to_boards_table.php ================================================ boolean('show_transitioning')->default(true)->after('show_next_event'); $table->integer('transitioning_minutes')->default(10)->after('show_transitioning'); $table->string('font_family')->default('Inter')->after('transitioning_minutes'); $table->string('language')->default('en')->after('font_family'); $table->boolean('show_meeting_title')->default(true)->after('language'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('boards', function (Blueprint $table) { $table->dropColumn([ 'show_transitioning', 'transitioning_minutes', 'font_family', 'language', 'show_meeting_title', ]); }); } }; ================================================ FILE: backend/database/migrations/2026_02_28_120007_add_title_and_subtitle_to_boards_table.php ================================================ string('title')->nullable()->after('name'); $table->string('subtitle')->nullable()->after('title'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('boards', function (Blueprint $table) { $table->dropColumn(['title', 'subtitle']); }); } }; ================================================ FILE: backend/database/migrations/2026_02_28_120008_add_view_mode_to_boards_table.php ================================================ string('view_mode')->default('card')->after('language'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('boards', function (Blueprint $table) { $table->dropColumn('view_mode'); }); } }; ================================================ FILE: backend/database/migrations/2026_02_28_140000_add_boards_count_to_instances_table.php ================================================ integer('boards_count')->nullable()->after('rooms_count'); }); } /** * Reverse the migrations. */ public function down(): void { Schema::table('instances', function (Blueprint $table) { $table->dropColumn('boards_count'); }); } }; ================================================ FILE: backend/database/seeders/DatabaseSeeder.php ================================================ create(); // \App\Models\User::factory()->create([ // 'name' => 'Test User', // 'email' => 'test@example.com', // ]); } } ================================================ FILE: backend/docs/CODING_STANDARDS.md ================================================ # Coding Standards This document outlines coding standards and best practices for the Spacepad backend codebase. ## Import Statements **Always use import statements at the top of files instead of inline fully qualified class names.** ### ✅ Correct ```php get(); $user = User::find(1); $isValid = InstanceService::hasValidLicense(); } } ``` ### ❌ Incorrect ```php get(); $user = \App\Models\User::find(1); $isValid = \App\Services\InstanceService::hasValidLicense(); } } ``` ### Why? - **Readability**: Import statements make it clear which classes are used in a file - **Maintainability**: Easier to refactor and understand dependencies - **IDE Support**: Better autocomplete and navigation - **PSR Standards**: Follows PSR-12 coding standard - **Consistency**: Matches Laravel conventions ### When to Use Fully Qualified Names Only use fully qualified class names (`\App\Models\...`) when: - There's a naming conflict that requires disambiguation - You're using a class from a different namespace that's not commonly imported In all other cases, use `use` statements at the top of the file. ================================================ FILE: backend/docs/WORKSPACE_SETUP.md ================================================ # Workspace System Documentation ## Overview The workspace system allows multiple users to collaborate on managing displays, devices, calendars, and rooms. Each user automatically gets their own workspace, and Pro users can invite colleagues to join their workspace. ## Architecture ### Models 1. **Workspace** - Represents a team/workspace - Has an `owner` (User) - Has many `members` (Users with roles) - Contains displays, devices, calendars, rooms 2. **WorkspaceMember** - Pivot table linking users to workspaces - Roles: `owner`, `admin`, `member` - `owner` role is implicit for the workspace owner ### Relationships - **User** → **Workspace** (one-to-many: owned workspaces) - **User** ↔ **Workspace** (many-to-many: member workspaces) - **Workspace** → **Display** (one-to-many) - **Workspace** → **Device** (one-to-many) - **Workspace** → **Calendar** (one-to-many) - **Workspace** → **Room** (one-to-many) ## Migration Strategy 1. **Existing Users**: Each user automatically gets a workspace created with their name 2. **Existing Data**: All displays, devices, calendars, and rooms are migrated to the user's workspace 3. **Backward Compatibility**: The `user_id` field is kept for backward compatibility ## Permissions ### Workspace Roles - **Owner**: Full control (can delete workspace, manage all members) - **Admin**: Can manage members and workspace settings - **Member**: Can view and use workspace resources ### Display Access - Users can access displays they own directly (`user_id`) - Users can access displays in workspaces they're members of (`workspace_id`) - Device authentication checks workspace membership ## Usage ### Adding a Colleague 1. Navigate to workspace settings (requires Pro) 2. Enter colleague's email address 3. Select role (admin or member) 4. Colleague receives access to all workspace resources ### Managing Members - **Add Member**: Only owners/admins can add members - **Update Role**: Change member role between admin/member - **Remove Member**: Remove access from workspace ## API Changes ### DisplayController - `index()` now returns displays from user's workspace(s) - Access checks include workspace membership ### DisplayService - `validateDisplayPermission()` checks workspace membership - Pro features check workspace owner's Pro status ## Frontend Changes Needed 1. **Workspace Management UI** - List workspaces - View workspace members - Add/remove members - Update member roles 2. **Display Creation** - Automatically assign to user's primary workspace - Allow selecting workspace (if user has multiple) 3. **Device Connection** - Connect code should work with workspace - Devices inherit workspace from user ## Migration Commands Run migrations in order: ```bash php artisan migrate ``` The migration `2025_12_30_000003_create_workspaces_for_existing_users.php` will: 1. Create a workspace for each existing user 2. Migrate all user's displays, devices, calendars, and rooms to their workspace 3. Add the user as an owner member ## Notes - Pro subscription is required to add team members - Workspace owner cannot be removed - All existing functionality remains backward compatible - `user_id` fields are kept for direct ownership tracking ================================================ FILE: backend/lang/de/boards.php ================================================ 'Übersicht der Besprechungsräume', 'busy' => 'Belegt', 'transitioning' => 'Übergang', 'check_in' => 'Einchecken', 'error' => 'Fehler', 'available' => 'Verfügbar', 'available_until' => 'Verfügbar bis :time', 'available_until_end_of_day' => 'Verfügbar bis Tagesende', 'next' => 'Nächste', 'room' => 'Raum', 'status' => 'Status', 'current' => 'Aktuell', 'no_displays' => 'Keine Displays für dieses Board verfügbar.', 'transitioning_minutes' => 'Übergang (:minutes min)', ]; ================================================ FILE: backend/lang/en/boards.php ================================================ 'Meeting Room Overview', 'busy' => 'In use', 'transitioning' => 'Transitioning', 'check_in' => 'Check-in', 'error' => 'Error', 'available' => 'Available', 'available_until' => 'Available until :time', 'available_until_end_of_day' => 'Available until end of day', 'next' => 'Next', 'room' => 'Room', 'status' => 'Status', 'current' => 'Current', 'no_displays' => 'No displays available for this board.', 'transitioning_minutes' => 'Transitioning (:minutes min)', ]; ================================================ FILE: backend/lang/en/validation.php ================================================ 'The :attribute must be accepted.', 'accepted_if' => 'The :attribute must be accepted when :other is :value.', 'active_url' => 'The :attribute is not a valid URL.', 'after' => 'The :attribute must be a date after :date.', 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', 'alpha' => 'The :attribute must only contain letters.', 'alpha_dash' => 'The :attribute must only contain letters, numbers, dashes and underscores.', 'alpha_num' => 'The :attribute must only contain letters and numbers.', 'array' => 'The :attribute must be an array.', 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'between' => [ 'array' => 'The :attribute must have between :min and :max items.', 'file' => 'The :attribute must be between :min and :max kilobytes.', 'numeric' => 'The :attribute must be between :min and :max.', 'string' => 'The :attribute must be between :min and :max characters.', ], 'boolean' => 'The :attribute field must be true or false.', 'confirmed' => 'The :attribute confirmation does not match.', 'current_password' => 'The password is incorrect.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', 'declined' => 'The :attribute must be declined.', 'declined_if' => 'The :attribute must be declined when :other is :value.', 'different' => 'The :attribute and :other must be different.', 'digits' => 'The :attribute must be :digits digits.', 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', 'doesnt_end_with' => 'The :attribute may not end with one of the following: :values.', 'doesnt_start_with' => 'The :attribute may not start with one of the following: :values.', 'email' => 'The :attribute must be a valid email address.', 'ends_with' => 'The :attribute must end with one of the following: :values.', 'enum' => 'The selected :attribute is invalid.', 'exists' => 'The selected :attribute is invalid.', 'file' => 'The :attribute must be a file.', 'filled' => 'The :attribute field must have a value.', 'gt' => [ 'array' => 'The :attribute must have more than :value items.', 'file' => 'The :attribute must be greater than :value kilobytes.', 'numeric' => 'The :attribute must be greater than :value.', 'string' => 'The :attribute must be greater than :value characters.', ], 'gte' => [ 'array' => 'The :attribute must have :value items or more.', 'file' => 'The :attribute must be greater than or equal to :value kilobytes.', 'numeric' => 'The :attribute must be greater than or equal to :value.', 'string' => 'The :attribute must be greater than or equal to :value characters.', ], 'image' => 'The :attribute must be an image.', 'in' => 'The selected :attribute is invalid.', 'in_array' => 'The :attribute field does not exist in :other.', 'integer' => 'The :attribute must be an integer.', 'ip' => 'The :attribute must be a valid IP address.', 'ipv4' => 'The :attribute must be a valid IPv4 address.', 'ipv6' => 'The :attribute must be a valid IPv6 address.', 'json' => 'The :attribute must be a valid JSON string.', 'lt' => [ 'array' => 'The :attribute must have less than :value items.', 'file' => 'The :attribute must be less than :value kilobytes.', 'numeric' => 'The :attribute must be less than :value.', 'string' => 'The :attribute must be less than :value characters.', ], 'lte' => [ 'array' => 'The :attribute must not have more than :value items.', 'file' => 'The :attribute must be less than or equal to :value kilobytes.', 'numeric' => 'The :attribute must be less than or equal to :value.', 'string' => 'The :attribute must be less than or equal to :value characters.', ], 'mac_address' => 'The :attribute must be a valid MAC address.', 'max' => [ 'array' => 'The :attribute must not have more than :max items.', 'file' => 'The :attribute must not be greater than :max kilobytes.', 'numeric' => 'The :attribute must not be greater than :max.', 'string' => 'The :attribute must not be greater than :max characters.', ], 'max_digits' => 'The :attribute must not have more than :max digits.', 'mimes' => 'The :attribute must be a file of type: :values.', 'mimetypes' => 'The :attribute must be a file of type: :values.', 'min' => [ 'array' => 'The :attribute must have at least :min items.', 'file' => 'The :attribute must be at least :min kilobytes.', 'numeric' => 'The :attribute must be at least :min.', 'string' => 'The :attribute must be at least :min characters.', ], 'min_digits' => 'The :attribute must have at least :min digits.', 'multiple_of' => 'The :attribute must be a multiple of :value.', 'not_in' => 'The selected :attribute is invalid.', 'not_regex' => 'The :attribute format is invalid.', 'numeric' => 'The :attribute must be a number.', 'password' => [ 'letters' => 'The :attribute must contain at least one letter.', 'mixed' => 'The :attribute must contain at least one uppercase and one lowercase letter.', 'numbers' => 'The :attribute must contain at least one number.', 'symbols' => 'The :attribute must contain at least one symbol.', 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', ], 'present' => 'The :attribute field must be present.', 'prohibited' => 'The :attribute field is prohibited.', 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', 'prohibits' => 'The :attribute field prohibits :other from being present.', 'regex' => 'The :attribute format is invalid.', 'required' => 'The :attribute field is required.', 'required_array_keys' => 'The :attribute field must contain entries for: :values.', 'required_if' => 'The :attribute field is required when :other is :value.', 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', 'required_unless' => 'The :attribute field is required unless :other is in :values.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_with_all' => 'The :attribute field is required when :values are present.', 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', 'same' => 'The :attribute and :other must match.', 'size' => [ 'array' => 'The :attribute must contain :size items.', 'file' => 'The :attribute must be :size kilobytes.', 'numeric' => 'The :attribute must be :size.', 'string' => 'The :attribute must be :size characters.', ], 'starts_with' => 'The :attribute must start with one of the following: :values.', 'string' => 'The :attribute must be a string.', 'timezone' => 'The :attribute must be a valid timezone.', 'unique' => 'The :attribute has already been taken.', 'uploaded' => 'The :attribute failed to upload.', 'url' => 'The :attribute must be a valid URL.', 'uuid' => 'The :attribute must be a valid UUID.', 'custom' => [ 'g-recaptcha-response' => [ 'recaptchav3' => 'Please wait a moment and try again. This helps us protect against automated submissions.', ], ], 'attributes' => [], ]; ================================================ FILE: backend/lang/es/boards.php ================================================ 'Resumen de Salas de Reuniones', 'busy' => 'En uso', 'transitioning' => 'Transición', 'check_in' => 'Registro', 'error' => 'Error', 'available' => 'Disponible', 'available_until' => 'Disponible hasta :time', 'available_until_end_of_day' => 'Disponible hasta el final del día', 'next' => 'Siguiente', 'room' => 'Sala', 'status' => 'Estado', 'current' => 'Actual', 'no_displays' => 'No hay pantallas disponibles para este tablero.', 'transitioning_minutes' => 'Transición (:minutes min)', ]; ================================================ FILE: backend/lang/fr/boards.php ================================================ 'Vue d\'ensemble des Salles de Réunion', 'busy' => 'Occupé', 'transitioning' => 'Transition', 'check_in' => 'Enregistrement', 'error' => 'Erreur', 'available' => 'Disponible', 'available_until' => 'Disponible jusqu\'à :time', 'available_until_end_of_day' => 'Disponible jusqu\'à la fin de la journée', 'next' => 'Suivant', 'room' => 'Salle', 'status' => 'Statut', 'current' => 'Actuel', 'no_displays' => 'Aucun écran disponible pour ce tableau.', 'transitioning_minutes' => 'Transition (:minutes min)', ]; ================================================ FILE: backend/lang/nl/boards.php ================================================ 'Overzicht Vergaderruimtes', 'busy' => 'In gebruik', 'transitioning' => 'Overgang', 'check_in' => 'Inchecken', 'error' => 'Fout', 'available' => 'Beschikbaar', 'available_until' => 'Beschikbaar tot :time', 'available_until_end_of_day' => 'Beschikbaar tot einde dag', 'next' => 'Volgende', 'room' => 'Ruimte', 'status' => 'Status', 'current' => 'Huidig', 'no_displays' => 'Geen displays beschikbaar voor dit bord.', 'transitioning_minutes' => 'Overgang (:minutes min)', ]; ================================================ FILE: backend/lang/sv/boards.php ================================================ 'Översikt av Mötesrum', 'busy' => 'I användning', 'transitioning' => 'Övergång', 'check_in' => 'Checka in', 'error' => 'Fel', 'available' => 'Tillgänglig', 'available_until' => 'Tillgänglig till :time', 'available_until_end_of_day' => 'Tillgänglig till dagens slut', 'next' => 'Nästa', 'room' => 'Rum', 'status' => 'Status', 'current' => 'Nuvarande', 'no_displays' => 'Inga skärmar tillgängliga för denna tavla.', 'transitioning_minutes' => 'Övergång (:minutes min)', ]; ================================================ FILE: backend/package.json ================================================ { "private": true, "type": "module", "scripts": { "build": "vite build", "dev": "vite" }, "devDependencies": { "autoprefixer": "^10.4.21", "axios": "^1.9.0", "concurrently": "^9.1.2", "laravel-echo": "^1.19.0", "vite": "^6.3.5" }, "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", "@tailwindcss/vite": "^4.1.8", "alpinejs": "^3.14.9", "htmx.org": "^1.9.12", "laravel-vite-plugin": "^1.3.0", "tailwindcss": "^4.1.8" } } ================================================ FILE: backend/phpunit.xml ================================================ tests/Unit tests/Feature app ================================================ FILE: backend/public/.htaccess ================================================ Options -MultiViews -Indexes RewriteEngine On # Handle Authorization Header RewriteCond %{HTTP:Authorization} . RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] # Redirect Trailing Slashes If Not A Folder... RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_URI} (.+)/$ RewriteRule ^ %1 [L,R=301] # Send Requests To Front Controller... RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^ index.php [L] ================================================ FILE: backend/public/images/backgrounds/README.md ================================================ # Default Background Images This directory contains the default background images that users can select for their displays. ## Required Images Place the following 8 default background images in this directory: 1. `default_1.jpg` - First default background option 2. `default_2.jpg` - Second default background option 3. `default_3.jpg` - Third default background option 4. `default_4.jpg` - Fourth default background option 5. `default_5.jpg` - Fifth default background option 6. `default_6.jpg` - Sixth default background option 7. `default_7.jpg` - Seventh default background option 8. `default_8.jpg` - Eighth default background option ## Image Specifications - **Format**: JPEG (JPG) recommended for file size optimization - **Dimensions**: 1920x1080px or similar 16:9 aspect ratio - **File Size**: Aim for under 500KB per image for optimal loading - **Quality**: Use high-quality images that look good on large displays ## Design Recommendations Good background images for room displays should: - Have soft, muted colors that don't overpower text - Avoid busy patterns that reduce readability - Work well with white text overlay - Be professional and appropriate for office environments - Consider using: - Abstract gradients - Soft nature scenes (mountains, forests, water) - Minimalist geometric patterns - Blurred cityscapes - Subtle textures (wood, fabric, stone) ## Adding New Default Backgrounds To add more default backgrounds: 1. Add the image file to this directory 2. Update `backend/app/Services/ImageService.php`: - Add the new background to the `DEFAULT_BACKGROUNDS` constant 3. Update `backend/app/Http/Requests/UpdateDisplayCustomizationRequest.php`: - Add the new key to the `default_background` validation rule Example: ```php public const DEFAULT_BACKGROUNDS = [ 'default_1' => 'images/backgrounds/default_1.jpg', 'default_2' => 'images/backgrounds/default_2.jpg', 'default_3' => 'images/backgrounds/default_3.jpg', 'default_4' => 'images/backgrounds/default_4.jpg', 'default_5' => 'images/backgrounds/default_5.jpg', 'default_6' => 'images/backgrounds/default_6.jpg', 'default_7' => 'images/backgrounds/default_7.jpg', 'default_8' => 'images/backgrounds/default_8.jpg', 'default_9' => 'images/backgrounds/default_9.jpg', // New background ]; ``` ================================================ FILE: backend/public/index.php ================================================ handleRequest(Request::capture()); ================================================ FILE: backend/public/robots.txt ================================================ User-agent: * Disallow: ================================================ FILE: backend/public/site.webmanifest ================================================ { "name": "MyWebSite", "short_name": "MySite", "icons": [ { "src": "/web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "/web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" } ================================================ FILE: backend/requests/.gitignore ================================================ http-client.env.json ================================================ FILE: backend/requests/api/activate.http ================================================ POST {{baseUrl}}/api/v1/instances/activate Authorization: Bearer {{bearerToken}} Content-Type: application/json Accept: application/json { "instance_key": "", "license_key": "" } ================================================ FILE: backend/requests/api/auth/login.http ================================================ POST {{baseUrl}}/api/auth/login Content-Type: application/json Accept: application/json { "code": "919640", "uid": "uniqueid1", "name": "Tablet 1" } ================================================ FILE: backend/requests/api/book-room.http ================================================ POST {{baseUrl}}/api/displays/01JXT3S52HKWH41138BJ8TEWTX/book Authorization: Bearer {{bearerToken}} Content-Type: application/json Accept: application/json { "duration": 15 } ================================================ FILE: backend/requests/api/cancel-event.http ================================================ DELETE {{baseUrl}}/api/displays/01JXT3S52HKWH41138BJ8TEWTX/events/01K053J5167XGD88JA175CGCCT Authorization: Bearer {{bearerToken}} Content-Type: application/json Accept: application/json ================================================ FILE: backend/requests/api/change-display.http ================================================ PUT {{baseUrl}}/api/devices/display Authorization: Bearer {{bearerToken}} Content-Type: application/json Accept: application/json { "display_id": "01JVQPZW49T4CCT77TBCENDSGK" } ================================================ FILE: backend/requests/api/check-in-event.http ================================================ POST {{baseUrl}}/api/events/01JZR8Z1455S6ZVBYFN0SX7C0A/check-in Authorization: Bearer {{bearerToken}} Content-Type: application/json Accept: application/json ================================================ FILE: backend/requests/api/get-display-data.http ================================================ GET {{baseUrl}}/api/displays/01JXT3S52HKWH41138BJ8TEWTX/data Authorization: Bearer {{bearerToken}} Content-Type: application/json Accept: application/json ================================================ FILE: backend/requests/api/get-displays.http ================================================ GET {{baseUrl}}/api/displays Authorization: Bearer {{bearerToken}} Content-Type: application/json Accept: application/json ================================================ FILE: backend/requests/api/get-events.http ================================================ GET {{baseUrl}}/api/events Authorization: Bearer {{bearerToken}} Content-Type: application/json Accept: application/json ================================================ FILE: backend/requests/api/get-me.http ================================================ GET {{baseUrl}}/api/devices/me Authorization: Bearer {{bearerToken}} Content-Type: application/json Accept: application/json ================================================ FILE: backend/requests/api/heartbeat.http ================================================ ### Send heartbeat to Spacepad server POST {{baseUrl}}/api/v1/instances/heartbeat Content-Type: application/json Accept: application/json { "instance_key": "", "license_key": "", "license_valid": true, "license_expires_at": "2024-12-31T23:59:59Z", "is_self_hosted": true, "displays_count": 2, "rooms_count": 1, "version": "1.0.0", "users": [ { "email": "user@example.com", "usage_type": "personal", "is_unlimited": false, "terms_accepted_at": "2024-01-01T00:00:00Z" } ] } ================================================ FILE: backend/requests/api/outlook/get-outlook-calendars.http ================================================ ### Get Outlook Calendars GET http://localhost:8000/api/outlook/calendars Authorization: Bearer YOUR_ACCESS_TOKEN Content-Type: application/json ================================================ FILE: backend/requests/api/outlook/outlook-auth.http ================================================ ### Redirect to Outlook OAuth URL GET http://localhost:8000/outlook/auth ================================================ FILE: backend/requests/graph/get-calendar-by-email.http ================================================ GET https://graph.microsoft.com/v1.0/users/HorizonM@magweter.com/calendarview Authorization: Bearer {{outlookToken}} Content-Type: application/json ================================================ FILE: backend/requests/graph/get-calendars.http ================================================ GET https://graph.microsoft.com/v1.0/me/calendars Authorization: Bearer {{outlookToken}} Content-Type: application/json ================================================ FILE: backend/requests/graph/get-events.http ================================================ GET https://graph.microsoft.com/v1.0/me/calendars/AQMkAGMwOTg0N2IyLTMxM2YtNGM3OC1iZmUxLTlkODlkYTZkM2Y5NQBGAAADmjZsXgRn_0ulz0qs3WatIAcAranw5q4G4E6C7jhn9RjFbAAAAgEGAAAAranw5q4G4E6C7jhn9RjFbAAAAr84AAAA/calendarview?StartDateTime=2025-04-04T00:00:00&EndDateTime=2026-01-01T23:59:59 Authorization: Bearer {{outlookToken}} Content-Type: application/json ================================================ FILE: backend/requests/graph/get-rooms.http ================================================ GET https://graph.microsoft.com/v1.0/places/microsoft.graph.room Authorization: Bearer {{outlookToken}} Content-Type: application/json ================================================ FILE: backend/requests/webhook-tests.http ================================================ ### Test Onboarding Complete Webhook POST {{onboarding_webhook_url}} Content-Type: application/json { "user_id": 123, "email": "john.doe@example.com", "name": "John Doe", "display": "Office Display", "event": "onboarding_complete" } ### Test Registration Webhook POST {{registration_webhook_url}} Content-Type: application/json { "user_id": 456, "email": "jane.smith@example.com", "name": "Jane Smith", "display": "Main Office Display", "event": "onboarding_complete" } ================================================ FILE: backend/resources/css/app.css ================================================ @import 'tailwindcss'; @import '@fortawesome/fontawesome-free/css/all.css'; @source "../views"; .bg-oxford { background: #14213D; } .bg-orange { background: #FCA311; } .text-orange { color: #FCA311; } .bg-platinum { background: #E5E5E5; } .grecaptcha-badge { visibility: hidden !important; } [x-cloak] { display: none !important; } button { cursor: pointer; } ================================================ FILE: backend/resources/js/app.js ================================================ import './bootstrap'; import Alpine from 'alpinejs'; window.Alpine = Alpine; Alpine.start(); ================================================ FILE: backend/resources/js/bootstrap.js ================================================ import axios from 'axios'; import htmx from 'htmx.org'; window.axios = axios; window.htmx = htmx; window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; ================================================ FILE: backend/resources/js/echo.js ================================================ import Echo from 'laravel-echo'; import { WaveConnector } from 'laravel-wave'; window.Echo = new Echo({ broadcaster: WaveConnector, }); ================================================ FILE: backend/resources/views/.gitkeep ================================================ ================================================ FILE: backend/resources/views/auth/login.blade.php ================================================ @extends('layouts.blank') @section('title', 'Sign in') @section('page')
Logo

Welcome to Spacepad

Please sign in to continue

@if(! config('settings.disable_email_login'))
@csrf {!! RecaptchaV3::field('login') !!}
Or continue with
@endif
@if(config('services.microsoft.enabled')) Microsoft @endif @if(config('services.google.enabled')) Google @endif @if(config('settings.disable_email_login') && ! config('services.microsoft.enabled') && ! config('services.google.enabled'))
No email login or authentication providers configured.
@endif

Don't have an account? Create one

@endsection ================================================ FILE: backend/resources/views/auth/register.blade.php ================================================ @extends('layouts.blank') @section('title', 'Register') @section('page')
Logo

Welcome to Spacepad

Register to start using your display today

@if(session('registered'))

Please check your email

You should receive an e-mail with a login link shortly.

@else @if(! config('settings.disable_email_login'))
@csrf {!! RecaptchaV3::field('register') !!}
Or continue with
@endif
@if(config('services.microsoft.enabled')) Microsoft @endif @if(config('services.google.enabled')) Google @endif @if(config('settings.disable_email_login') && ! config('services.microsoft.enabled') && ! config('services.google.enabled'))
No email registration or authentication provider configured.
@endif
@endif
@endsection ================================================ FILE: backend/resources/views/components/alerts/alert.blade.php ================================================ @props([ 'type' => 'info', 'title' => null, 'message' => null, 'dismissible' => false, 'autoDismiss' => true, 'autoDismissDelay' => 5000, 'errors' => null, ]) @php // Set type and title based on session messages if (session('success')) { $type = 'success'; $title = 'Success!'; } elseif (session('error')) { $type = 'error'; $title = 'Something went wrong'; } elseif (session('warning')) { $type = 'warning'; $title = 'Heads up'; } elseif (session('info')) { $type = 'info'; $title = 'Please note:'; } $hasErrors = $errors->any() && !$errors->has('license_key'); if ($hasErrors) { $type = 'error'; $title = 'There were errors with your submission'; } $alertClasses = [ 'success' => 'bg-green-50 ring-green-600', 'error' => 'bg-red-50 ring-red-600', 'warning' => 'bg-yellow-50 ring-yellow-600', 'info' => 'bg-blue-50 ring-blue-600', ][$type] ?? 'bg-blue-50 ring-blue-600'; $titleClasses = [ 'success' => 'text-green-700', 'error' => 'text-red-700', 'warning' => 'text-yellow-700', 'info' => 'text-blue-700', ][$type] ?? 'text-blue-700'; $messageClasses = [ 'success' => 'text-green-700', 'error' => 'text-red-700', 'warning' => 'text-yellow-700', 'info' => 'text-blue-700', ][$type] ?? 'text-blue-700'; @endphp @if(session('success') || session('error') || session('warning') || session('info') || $hasErrors)
@if($title)

{{ $title }}

@endif
@if($message)

{{ $message }}

@endif @if($hasErrors)
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
@endif @if(session('success'))

{{ session('success') }}

@endif @if(session('error'))

{{ session('error') }}

@endif @if(session('warning'))

{{ session('warning') }}

@endif @if(session('info'))

{{ session('info') }}

@endif
@if($autoDismiss) @push('scripts') @endpush @endif @endif ================================================ FILE: backend/resources/views/components/calendars/picker.blade.php ================================================
@if(isset($error))

Something went wrong

{{ $error }}

@endif ================================================ FILE: backend/resources/views/components/cards/card.blade.php ================================================ @props(['class' => ''])
{{ $slot }}
================================================ FILE: backend/resources/views/components/displays/table-row.blade.php ================================================ @props(['display'])
{{ $display->name }}
{{ $display->display_name }}
@if($display->calendar->outlookAccount)
{{ $display->calendar->outlookAccount->name }}
@endif @if($display->calendar->googleAccount)
{{ $display->calendar->googleAccount->name }}
@endif @if($display->calendar->caldavAccount)
{{ $display->calendar->caldavAccount->name }}
@endif
{{ $display->calendar->name }}
{{ $display->status->label() }}
@if($display->devices->isNotEmpty())
@else
No devices @endif
@if($display->last_sync_at)
Last sync {{ $display->last_sync_at->diffForHumans() }}
@endif
@csrf @method('PATCH')
@if(auth()->user()->hasProForCurrentWorkspace()) @else @endif
@csrf @method('DELETE')
================================================ FILE: backend/resources/views/components/icons/arrow-left.blade.php ================================================ ================================================ FILE: backend/resources/views/components/icons/brush.blade.php ================================================ ================================================ FILE: backend/resources/views/components/icons/building.blade.php ================================================ @props(['class' => '']) merge(['class' => $class]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> ================================================ FILE: backend/resources/views/components/icons/caldav.blade.php ================================================ ================================================ FILE: backend/resources/views/components/icons/calendar.blade.php ================================================ ================================================ FILE: backend/resources/views/components/icons/display.blade.php ================================================ ================================================ FILE: backend/resources/views/components/icons/external.blade.php ================================================ @props(['class' => '']) merge(['class' => $class]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> ================================================ FILE: backend/resources/views/components/icons/google.blade.php ================================================ ================================================ FILE: backend/resources/views/components/icons/information.blade.php ================================================ @props(['class' => '']) merge(['class' => $class]) }} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> ================================================ FILE: backend/resources/views/components/icons/logout.blade.php ================================================ @props(['class' => '']) merge(['class' => $class]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> ================================================ FILE: backend/resources/views/components/icons/microsoft.blade.php ================================================ ================================================ FILE: backend/resources/views/components/icons/pause.blade.php ================================================ @props(['class' => '']) merge(['class' => $class]) }} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> ================================================ FILE: backend/resources/views/components/icons/play.blade.php ================================================ @props(['class' => '']) merge(['class' => $class]) }} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> ================================================ FILE: backend/resources/views/components/icons/plus.blade.php ================================================ ================================================ FILE: backend/resources/views/components/icons/room.blade.php ================================================ ================================================ FILE: backend/resources/views/components/icons/settings.blade.php ================================================ ================================================ FILE: backend/resources/views/components/icons/trash.blade.php ================================================ @props(['class' => '']) merge(['class' => $class]) }} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> ================================================ FILE: backend/resources/views/components/icons/users.blade.php ================================================ @props(['class' => '']) merge(['class' => $class]) }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> ================================================ FILE: backend/resources/views/components/impersonation-banner.blade.php ================================================ @if(session('impersonating'))
⚠️ You are impersonating {{ auth()->user()->email }}
@csrf
@endif ================================================ FILE: backend/resources/views/components/modals/google-service-account.blade.php ================================================ ================================================ FILE: backend/resources/views/components/modals/license-key.blade.php ================================================ @props(['show' => false]) ================================================ FILE: backend/resources/views/components/modals/manage-subscription.blade.php ================================================ @props(['show' => false]) ================================================ FILE: backend/resources/views/components/modals/select-google-booking-method.blade.php ================================================ ================================================ FILE: backend/resources/views/components/modals/select-permission.blade.php ================================================ @props(['provider' => 'outlook']) ================================================ FILE: backend/resources/views/components/rooms/picker.blade.php ================================================
@if(isset($error))

Something went wrong

{{ $error }}

@endif @if($type === \App\Enums\Provider::GOOGLE) @endif ================================================ FILE: backend/resources/views/components/scripts/clarity.blade.php ================================================ ================================================ FILE: backend/resources/views/components/scripts/faro.blade.php ================================================ @if(config('faro.enabled')) @endif ================================================ FILE: backend/resources/views/errors/403.blade.php ================================================ @extends('layouts.error') @section('title', 'Access Denied') @section('content')

403

Access Denied

You don't have permission to access this page.

@endsection ================================================ FILE: backend/resources/views/errors/404.blade.php ================================================ @extends('layouts.error') @section('title', 'Page Not Found') @section('content')

404

Page Not Found

The page you're looking for doesn't exist or has been moved.

@endsection ================================================ FILE: backend/resources/views/errors/419.blade.php ================================================ @extends('layouts.error') @section('title', 'Page Expired') @section('content')

419

Page Expired

Your session has expired. Please refresh the page and try again.

@endsection ================================================ FILE: backend/resources/views/errors/429.blade.php ================================================ @extends('layouts.error') @section('title', 'Too Many Requests') @section('content')

429

Too Many Requests

You've made too many requests. Please wait a moment and try again.

@endsection ================================================ FILE: backend/resources/views/errors/500.blade.php ================================================ @extends('layouts.error') @section('title', 'Server Error') @section('content')

500

Server Error

Something went wrong on our end. Please try again later.

@endsection ================================================ FILE: backend/resources/views/layouts/base.blade.php ================================================ @extends('layouts.blank') @section('page')

@yield('title')

@yield('actions')
@yield('content')
@include('components.modals.manage-subscription') @endsection ================================================ FILE: backend/resources/views/layouts/blank.blade.php ================================================ @yield('title', config('app.name')) @includeWhen(config('googletagmanager.enabled') && config('googletagmanager.id'), 'googletagmanager::head') @vite(['resources/css/app.css', 'resources/js/app.js']) {!! RecaptchaV3::initJs() !!} @stack('styles') @lemonJS @includeWhen(config('services.clarity.tag_code'), 'components.scripts.clarity') @include('components.scripts.faro') @includeWhen(config('googletagmanager.enabled') && config('googletagmanager.id'), 'googletagmanager::body') @stack('modals') @include('components.impersonation-banner')
@yield('page')
@stack('scripts') ================================================ FILE: backend/resources/views/layouts/error.blade.php ================================================ {{ $title ?? 'Error' }} - Spacepad @vite(['resources/css/app.css', 'resources/js/app.js']) @include('components.scripts.faro')
Logo
@yield('content')
================================================ FILE: backend/resources/views/pages/admin/user.blade.php ================================================ @extends('layouts.base') @section('title', 'User Details - ' . $user->email) @section('container_class', 'max-w-4xl') @section('content')

User Details

View and manage user account information

@if(session('error'))
{{ session('error') }}
@endif @if(session('success'))
{{ session('success') }}
@endif

Account Information

Email
{{ $user->email }}
Name
{{ $user->name }}
User ID
{{ $user->id }}
Usage Type
{{ $user->usage_type?->label() ?? 'Not set' }}
Created
{{ $user->created_at->format('Y-m-d H:i:s') }}
Last Activity
{{ $user->last_activity_at ? $user->last_activity_at->format('Y-m-d H:i:s') : 'Never' }}
@if($user->hasPro() || $subscriptionInfo)

Subscription Information

Plan
@if($user->is_unlimited) Unlimited @elseif($subscriptionInfo) Pro @else Free @endif
@if($subscriptionInfo)
Status
@php $status = $subscriptionInfo['status']; $statusLabel = ucwords(str_replace('_', ' ', $status)); $statusColors = match($status) { 'active' => 'bg-green-50 text-green-700 ring-green-600/20', 'past_due' => 'bg-yellow-50 text-yellow-700 ring-yellow-600/20', 'unpaid' => 'bg-red-50 text-red-700 ring-red-600/20', 'cancelled' => 'bg-gray-50 text-gray-700 ring-gray-600/20', 'on_trial' => 'bg-blue-50 text-blue-700 ring-blue-600/20', 'paused' => 'bg-orange-50 text-orange-700 ring-orange-600/20', default => 'bg-gray-50 text-gray-700 ring-gray-600/20', }; @endphp {{ $statusLabel }}
Monthly Price
${{ number_format($subscriptionInfo['price'], 2) }}
MRR
${{ number_format($subscriptionInfo['mrr'], 2) }}
@if($subscriptionInfo['ends_at'])
Subscription Ends
{{ \Carbon\Carbon::parse($subscriptionInfo['ends_at'])->format('Y-m-d') }}
@endif @endif
@endif

Data Summary

Outlook Accounts
{{ $user->outlookAccounts->count() }}
Google Accounts
{{ $user->googleAccounts->count() }}
CalDAV Accounts
{{ $user->caldavAccounts->count() }}
Displays
{{ $user->displays->count() }}
Devices
{{ $user->devices->count() }}
Workspaces
{{ $user->workspaces->count() }}
@if($user->id !== auth()->id())

⚠️ Delete User Account

This action cannot be undone. All data associated with this user will be permanently deleted:

  • All connected accounts (Outlook, Google, CalDAV)
  • All displays and their settings
  • All devices
  • All calendars and events
  • All rooms
  • All workspace memberships
  • All personal access tokens
@csrf @method('DELETE')
@error('confirm_email')

{{ $message }}

@enderror
@else

⚠️ Notice

You cannot delete your own account. Please ask another admin to perform this action if needed.

@endif
@endsection ================================================ FILE: backend/resources/views/pages/admin.blade.php ================================================ @extends('layouts.base') @section('title', 'Admin dashboard') @section('content')
Active Instances
{{ $activeInstancesCount }}
Total Instances
{{ $totalInstances }}

Active Self-Hosted Instances

@forelse($activeInstances as $instance) @empty @endforelse
Instance Key Users Displays Boards Rooms Last Heartbeat Paid
{{ $instance->instance_key }} @if(is_array($instance->users)) @foreach($instance->users as $user)
{{ $user['email'] ?? '' }} @if(!empty($user['usage_type'])) ({{ ucfirst($user['usage_type']) }}) @endif
@endforeach @endif
{{ $instance->displays_count }} {{ $instance->boards_count ?? '-' }} {{ $instance->rooms_count }} {{ $instance->last_heartbeat_at?->diffForHumans() ?? '-' }} {{ $instance->is_paid ? 'Yes' : 'No' }}
No active self-hosted instances found.
Active Displays
{{ $activeDisplaysCount }}
Total Displays
{{ $totalDisplays }}
Total Boards
{{ $totalBoards }}

Active Cloud-Hosted Users

@forelse($activeDisplays as $user) @empty @endforelse
Name Email Usage Type Displays Boards Last Display Activity Paid
{{ $user->name }} {{ $user->email }} {{ $user->usage_type?->label() }} {{ $user->displays_count }} {{ $user->boards_count ?? 0 }} {{ $user->last_display_activity ? \Carbon\Carbon::parse($user->last_display_activity)->diffForHumans() : '-' }} {{ $user->is_paid ? 'Yes' : 'No' }}
No active cloud-hosted users found.
Paying Users
{{ $payingUsersCount }}
Total MRR
${{ number_format($totalMRR, 2) }}
Forecasted MRR
${{ number_format($forecastedMRR, 2) }}

Paying Cloud-Hosted Users ({{ $payingUsersCount }})

@forelse($payingUsers as $user) @empty @endforelse
Name Email Usage Type Displays Boards Subscription Status LS Status Price MRR Subscription Ends Registered
{{ $user->name }} {{ $user->email }} {{ $user->usage_type?->label() }} {{ $user->displays_count }} {{ $user->subscription_status }} @if($user->lemon_squeezy_status) @php $status = $user->lemon_squeezy_status; $statusLabel = ucwords(str_replace('_', ' ', $status)); $statusColors = match($status) { 'active' => 'bg-green-50 text-green-700 ring-green-600/20', 'past_due' => 'bg-yellow-50 text-yellow-700 ring-yellow-600/20', 'unpaid' => 'bg-red-50 text-red-700 ring-red-600/20', 'cancelled' => 'bg-gray-50 text-gray-700 ring-gray-600/20', 'on_trial' => 'bg-blue-50 text-blue-700 ring-blue-600/20', 'paused' => 'bg-orange-50 text-orange-700 ring-orange-600/20', default => 'bg-gray-50 text-gray-700 ring-gray-600/20', }; @endphp {{ $statusLabel }} @else - @endif @if($user->price > 0) ${{ number_format($user->price, 2) }} @else - @endif @if($user->mrr > 0) ${{ number_format($user->mrr, 2) }} @else - @endif @if($user->subscription_ends_at) {{ \Carbon\Carbon::parse($user->subscription_ends_at)->format('Y-m-d') }} @else - @endif {{ $user->created_at->format('Y-m-d') }}
No paying cloud-hosted users found.
Total Users
{{ $allUsers->count() }}
Users with Displays
{{ $allUsers->filter(fn($u) => $u->displays_count > 0)->count() }}
Pro Users
{{ $allUsers->filter(fn($u) => $u->hasPro())->count() }}

All Users

@forelse($allUsers as $user) @empty @endforelse
Name Email Usage Type Displays Boards Pro Registered Last Activity Actions
{{ $user->name }} {{ $user->email }} {{ $user->usage_type?->label() ?? '-' }} {{ $user->displays_count }} {{ $user->boards_count ?? 0 }} @if($user->hasPro()) Yes @else No @endif {{ $user->created_at->format('Y-m-d') }} {{ $user->last_activity_at ? $user->last_activity_at->format('Y-m-d') : 'Never' }}
View
@csrf
@if(request('search')) No users found matching "{{ request('search') }}" @else No users found @endif
@if($allUsers->hasPages())
{{ $allUsers->links('vendor.pagination.tailwind') }}
@endif
@endsection ================================================ FILE: backend/resources/views/pages/boards/form.blade.php ================================================ @extends('layouts.base') @section('title', $board ? 'Edit Board' : 'Create Board') @section('container_class', 'max-w-3xl') @section('content') {{-- Session Status Alert --}}
@csrf @if($board) @method('PUT') @endif

Give your board a descriptive name for your own reference. This will not be displayed to your users.

The large title displayed top left in the board. If left empty, the title will default to "Meeting Room Overview" in the selected language.

A smaller subtitle displayed below the title. Optional, for example to indicate which floor you're on.

show_all_displays ? '1' : '0') : '1') === '1' ? 'checked' : '' }} onchange="toggleDisplaySelection()">

All active displays in this workspace will be shown on the board.

show_all_displays ? '1' : '0') : '1') === '0' ? 'checked' : '' }} onchange="toggleDisplaySelection()">

Choose which displays to show on this board.

@if($displays->isEmpty())

No active displays available in this workspace.

@else
@foreach($displays as $display)
show_all_displays ? $board->displays->pluck('id')->toArray() : []) && in_array($display->id, old('display_ids', $board && !$board->show_all_displays ? $board->displays->pluck('id')->toArray() : [])) ? 'checked' : '' }}>
@endforeach

Select one or more displays to include in this board.

@endif
@if($board && $board->logo)
Current logo

Current logo

@endif

Upload a logo to display in the top left corner of the board. Recommended size: 200x60px or similar aspect ratio.

show_title ?? true) ? 'checked' : '' }}>

Display the meeting title/event name on the board.

show_booker ?? true) ? 'checked' : '' }}>

Display the name of the person who booked the meeting.

show_next_event ?? true) ? 'checked' : '' }}>

Display upcoming events when a room is currently available.

show_transitioning ?? true) ? 'checked' : '' }}>

Display the transitioning state when a meeting is ending or starting soon.

Display rooms as transitioning (orange) when a meeting is ending or starting within this many minutes. Default: 10 minutes.

Choose a font family for the board text.

Choose how displays are displayed on the board.

Choose the language for date and time formatting on the board.

show_meeting_title ?? true) ? 'checked' : '' }}>

If unchecked, meeting titles will be hidden for privacy-sensitive environments.

theme ?? 'dark') === 'dark' ? 'checked' : '' }}>

Dark background with light text for better visibility in low-light environments.

theme ?? 'dark') === 'light' ? 'checked' : '' }}>

Light background with dark text for better visibility in bright environments.

theme ?? 'dark') === 'system' ? 'checked' : '' }}>

Automatically match your device's dark/light mode preference.

Cancel
@endsection @push('scripts') @endpush ================================================ FILE: backend/resources/views/pages/boards/index.blade.php ================================================ @extends('layouts.base') @section('title', 'Boards') @section('content')

Boards

Overview of your boards and their configuration.

{{-- Session Status Alert --}}
@forelse($boards as $board) @empty @endforelse
Name Displays Type Created by Actions
{{ $board->name }} {{ $board->display_count }} {{ $board->display_count === 1 ? 'display' : 'displays' }} @if($board->show_all_displays) Show all @else Selected @endif {{ $board->user->name }}
@can('update', $board) @endcan @can('delete', $board)
@csrf @method('DELETE')
@endcan

No boards yet

Create your first board to display room availability on a big screen.

Create new board
@endsection ================================================ FILE: backend/resources/views/pages/boards/show.blade.php ================================================ @extends('layouts.blank') @section('title', $board->name . ' - ' . config('app.name')) @php $theme = $board->theme ?? 'dark'; // For system theme, don't add initial class - let JavaScript handle it to prevent flash // For dark/light themes, we can add the class directly since we know it's correct $initialThemeClass = $theme === 'system' ? '' : 'board-' . $theme; // Map font names to Google Fonts format $fontFamily = $board->font_family ?? 'Inter'; $googleFontMap = [ 'Inter' => 'Inter:wght@400;500;600;700', 'Roboto' => 'Roboto:wght@400;500;700', 'Open Sans' => 'Open+Sans:wght@400;600;700', 'Lato' => 'Lato:wght@400;700', 'Poppins' => 'Poppins:wght@400;600;700', 'Montserrat' => 'Montserrat:wght@400;600;700', ]; $googleFontUrl = $googleFontMap[$fontFamily] ?? 'Inter:wght@400;500;600;700'; // Set locale for translations $boardLanguage = $board->language ?? 'en'; $originalLocale = app()->getLocale(); app()->setLocale($boardLanguage); // Helper function to get translation in board language $t = function($key, $replace = []) use ($boardLanguage) { return \Illuminate\Support\Facades\Lang::get($key, $replace, $boardLanguage); }; @endphp @push('styles') @endpush @section('page')
{{-- Header --}}
@if($board->logo)
Board logo
@else
{{ strtoupper(substr($board->name, 0, 1)) }}
@endif

{{ $board->title ?: $t('boards.meeting_room_overview') }}

@if($board->subtitle)

{{ $board->subtitle }}

@endif
{{-- Room List --}} @php $viewMode = $board->view_mode ?? 'card'; @endphp @if($viewMode === 'table') {{-- Row View --}}
@if($board->show_next_event ?? true) @endif @forelse($displays as $displayData) @php $display = $displayData['display']; $status = $displayData['status']; $statusText = $displayData['statusText']; $currentEvent = $displayData['currentEvent']; $nextEvent = $displayData['nextEvent']; $transitioningMinutes = $displayData['transitioningMinutes'] ?? null; // Status colors $statusBarColor = match($status) { 'busy' => 'bg-red-500', 'transitioning' => 'bg-amber-500', 'error' => 'bg-gray-500', default => 'bg-green-500', }; // Update status text with minutes if transitioning $statusTextParts = []; if ($status === 'transitioning' && $transitioningMinutes !== null) { $statusTextParts = [ 'label' => $t('boards.transitioning'), 'minutes' => '(' . $transitioningMinutes . ' min)' ]; } else { $statusTextParts = ['label' => $statusText]; } $statusBadgeClass = match($status) { 'busy' => 'bg-red-500/10 text-red-400 border-red-500/20', 'transitioning' => 'bg-amber-500/10 text-amber-400 border-amber-500/20', 'error' => 'bg-gray-500/10 text-gray-400 border-gray-500/20', default => 'bg-green-500/10 text-green-400 border-green-500/20', }; @endphp @if($board->show_next_event ?? true) @endif @empty @endforelse
{{ $t('boards.room') }} {{ $t('boards.status') }} {{ $t('boards.current') }}{{ $t('boards.next') }}
{{ $display->display_name ?: $display->name }}
@if(isset($statusTextParts['minutes'])) {{ $statusTextParts['label'] }} {{ $statusTextParts['minutes'] }} @else {{ $statusText }} @endif @if($currentEvent)
@if($board->show_title ?? true)
{{ $currentEvent['summary'] }}
@endif
@else @if($nextEvent) {{ $t('boards.available_until', ['time' => '']) }} @else {{ $t('boards.available_until_end_of_day') }} @endif @endif
@if($nextEvent)
@if($board->show_title ?? true)
{{ $nextEvent['summary'] }}
@endif
@else @endif

{{ $t('boards.no_displays') }}

@elseif($viewMode === 'grid') {{-- Grid View --}}
@forelse($displays as $displayData) @php $display = $displayData['display']; $status = $displayData['status']; $statusText = $displayData['statusText']; $currentEvent = $displayData['currentEvent']; $nextEvent = $displayData['nextEvent']; $transitioningMinutes = $displayData['transitioningMinutes'] ?? null; // Status colors $statusBarColor = match($status) { 'busy' => 'bg-red-500', 'transitioning' => 'bg-amber-500', 'error' => 'bg-gray-500', default => 'bg-green-500', }; // Update status text with minutes if transitioning $statusTextParts = []; if ($status === 'transitioning' && $transitioningMinutes !== null) { $statusTextParts = [ 'label' => $t('boards.transitioning'), 'minutes' => '(' . $transitioningMinutes . ' min)' ]; } else { $statusTextParts = ['label' => $statusText]; } @endphp
{{-- Status Indicator Bar - Full height on left edge --}}
{{-- Status Badge and Room Name --}}
{{-- Status Badge - Above Title --}}
@php $statusBadgeClass = match($status) { 'busy' => 'bg-red-500/10 text-red-400 border-red-500/20', 'transitioning' => 'bg-amber-500/10 text-amber-400 border-amber-500/20', 'error' => 'bg-gray-500/10 text-gray-400 border-gray-500/20', default => 'bg-green-500/10 text-green-400 border-green-500/20', }; @endphp @if(isset($statusTextParts['minutes'])) {{ $statusTextParts['label'] }} {{ $statusTextParts['minutes'] }} @else {{ $statusText }} @endif
{{-- Room Name --}}

{{ $display->display_name ?: $display->name }}

{{-- Event Info --}}
@if($currentEvent) {{-- Current Event --}}
@if($board->show_title ?? true)
{{ $currentEvent['summary'] }}
@endif
@if(($board->show_booker ?? true) && $currentEvent['organizer'] !== 'Unknown')
{{ $currentEvent['organizer'] }}
@endif
@else {{-- Available --}} @if($nextEvent) {{ $t('boards.available_until', ['time' => '']) }} @else {{ $t('boards.available_until_end_of_day') }} @endif @endif {{-- Next Up Event - Below Current Event --}} @if($nextEvent && ($board->show_next_event ?? true))
@if($board->show_title ?? true)
{{ $nextEvent['summary'] }}
{{ $t('boards.next') }}
@endif
@if(($board->show_booker ?? true) && isset($nextEvent['organizer']) && $nextEvent['organizer'] !== 'Unknown')
{{ $nextEvent['organizer'] }}
@endif
@endif
@empty

{{ $t('boards.no_displays') }}

@endforelse
@else {{-- Card View (Default) --}}
@forelse($displays as $displayData) @php $display = $displayData['display']; $status = $displayData['status']; $statusText = $displayData['statusText']; $currentEvent = $displayData['currentEvent']; $nextEvent = $displayData['nextEvent']; $transitioningMinutes = $displayData['transitioningMinutes'] ?? null; // Status colors $statusBarColor = match($status) { 'busy' => 'bg-red-500', 'transitioning' => 'bg-amber-500', 'error' => 'bg-gray-500', default => 'bg-green-500', }; // Update status text with minutes if transitioning $statusTextParts = []; if ($status === 'transitioning' && $transitioningMinutes !== null) { $statusTextParts = [ 'label' => $t('boards.transitioning'), 'minutes' => '(' . $transitioningMinutes . ' min)' ]; } else { $statusTextParts = ['label' => $statusText]; } @endphp
{{-- Status Indicator Bar - Full height on left edge --}}
{{-- Status Badge --}}
@php $statusBadgeClass = match($status) { 'busy' => 'bg-red-500/10 text-red-400 border-red-500/20', 'transitioning' => 'bg-amber-500/10 text-amber-400 border-amber-500/20', 'error' => 'bg-gray-500/10 text-gray-400 border-gray-500/20', default => 'bg-green-500/10 text-green-400 border-green-500/20', }; @endphp @if(isset($statusTextParts['minutes'])) {{ $statusTextParts['label'] }} {{ $statusTextParts['minutes'] }} @else {{ $statusText }} @endif
{{-- Room Name and Current Event Info --}}

{{ $display->display_name ?: $display->name }}

@if($currentEvent) {{-- Current Event --}}
@if($board->show_title ?? true)
{{ $currentEvent['summary'] }}
@endif
@if(($board->show_booker ?? true) && $currentEvent['organizer'] !== 'Unknown')
{{ $currentEvent['organizer'] }}
@endif
@else {{-- Available --}} @if($nextEvent) {{ $t('boards.available_until', ['time' => '']) }} @else {{ $t('boards.available_until_end_of_day') }} @endif @endif
{{-- Next Up Event - Right Side --}} @if($nextEvent && ($board->show_next_event ?? true))
{{ $t('boards.next') }}
@if($board->show_title ?? true)
{{ $nextEvent['summary'] }}
@endif
@endif
@empty

{{ $t('boards.no_displays') }}

@endforelse
@endif
@endsection @php // Restore original locale app()->setLocale($originalLocale); @endphp @push('scripts') @endpush ================================================ FILE: backend/resources/views/pages/caldav-accounts/create.blade.php ================================================ @extends('layouts.base') @section('title', 'Create a new CalDAV Account') @section('container_class', 'max-w-5xl') @section('content') {{-- Session Status Alert --}}

Enter your credentials

We'll connect to your server to access your calendars.

@csrf

The URL of your CalDAV server.

Cancel
@endsection ================================================ FILE: backend/resources/views/pages/dashboard.blade.php ================================================ @extends('layouts.base') @section('title', 'Management dashboard') @section('actions') {{-- Connect Code --}} @if((auth()->user()->hasAnyDisplay() || auth()->user()->workspaces()->count() > 1) && $connectCode)

Connect code

{{ chunk_split($connectCode, 3, ' ') }}

@endif @endsection @section('content') @php $isSelfHosted = config('settings.is_self_hosted'); $checkout = auth()->user()->getCheckoutUrl(route('billing.thanks')); $showLicenseModal = $isSelfHosted && !auth()->user()->hasPro(); @endphp {{-- Session Status Alert --}} {{-- Google Workspace Booking Method Selection Warnings --}} @foreach($googleAccounts as $googleAccount) @if($googleAccount->permission_type === \App\Enums\PermissionType::WRITE && $googleAccount->isBusiness() && $googleAccount->booking_method === null)

Booking Method Required

Please select a booking method for your Google Workspace account {{ $googleAccount->name }} ({{ $googleAccount->email }}).

@endif @endforeach {{-- Service Account Warnings --}} @foreach($googleAccounts as $googleAccount) @if($googleAccount->isBusiness() && $googleAccount->booking_method === \App\Enums\GoogleBookingMethod::SERVICE_ACCOUNT && !$googleAccount->service_account_file_path)

Service Account Required

The Google Workspace account {{ $googleAccount->name }} ({{ $googleAccount->email }}) is configured to use service account booking but the service account file has not been uploaded yet. Please upload your service account file to enable room bookings.

@endif @endforeach {{-- License Key Modal --}} {{-- Commercial Banner --}} @if(! auth()->user()->hasProForCurrentWorkspace() && auth()->user()->hasAnyDisplay())

Unlock all features

Upgrade to Pro to unlock all features, including multiple displays, creating boards, book on-display, personalize displays, enable check-in and more!

See all Pro features or see pricing.

@if($isSelfHosted) @else Try Pro 14 days for free @endif
@endif
{{-- Tabs --}}
{{-- Displays Tab Content --}}

Displays

Overview of your displays and their status.

@if(auth()->user()->hasAnyDisplay() || auth()->user()->workspaces()->count() > 1) @endif @if(auth()->user()->can('create', \App\Models\Display::class)) @if(auth()->user()->shouldUpgradeForCurrentWorkspace()) Create new display Pro @else Create new display @endif @endif
{{-- Connect Instructions Modal --}}
@forelse($displays as $display) @empty @endforelse
Name Account Status Activity Actions

One more step and you're set up

Pick the calendar or room you would like to synchronize. You are able to connect multiple tablets to one display.

@if(! $isSelfHosted && auth()->user()->shouldUpgradeForCurrentWorkspace()) Create new display Pro @elseif($isSelfHosted && auth()->user()->shouldUpgradeForCurrentWorkspace()) Create new display Pro @else Create new display @endif
{{-- Boards Tab Content --}} @if(auth()->user()->hasProForCurrentWorkspace()) @endif

Accounts

The accounts used to connect to calendars and rooms.

@if(config('services.microsoft.enabled')) @endif @if(config('services.google.enabled')) @endif @if(config('services.caldav.enabled')) CalDAV @endif
Connected accounts
@if($outlookAccounts->isEmpty() && $googleAccounts->isEmpty() && $caldavAccounts->isEmpty())

No accounts connected yet

Connect a calendar account above to get started. You can connect Microsoft, Google, or CalDAV accounts.

@else
@foreach($outlookAccounts as $outlookAccount)
@if($outlookAccount->calendars->isEmpty())
@csrf @method('DELETE')
@else @endif

{{ $outlookAccount->name }}

{{ $outlookAccount->email }}

Connected

{{ $outlookAccount->isBusiness() ? 'Microsoft 365' : 'Personal' }}

@if($outlookAccount->permission_type)

{{ $outlookAccount->permission_type->label() }}

@endif
@endforeach @foreach($googleAccounts as $googleAccount)
@if($googleAccount->calendars->isEmpty())
@csrf @method('DELETE')
@else @endif

{{ $googleAccount->name }}

{{ $googleAccount->email }}

Connected

{{ $googleAccount->isBusiness() ? 'Workspace' : 'Personal' }}

@if($googleAccount->permission_type)

{{ $googleAccount->permission_type->label() }}

@endif @if($googleAccount->isBusiness() && $googleAccount->booking_method) @endif
@endforeach @foreach($caldavAccounts as $caldavAccount)
@if($caldavAccount->calendars->isEmpty())
@csrf @method('DELETE')
@else @endif

{{ $caldavAccount->name }}

{{ $caldavAccount->email }}

Connected

@if($caldavAccount->permission_type)

{{ $caldavAccount->permission_type->label() }}

@endif
@endforeach
@endif
{{-- Server Info (Self-hosted only) --}} @if($isSelfHosted)
Self-hosted @if($version) v{{ $version }} @endif @if($appUrl) {{ parse_url($appUrl, PHP_URL_HOST) }} ({{ $appEnv }}) @endif
@endif @endsection @push('scripts') @endpush @push('modals') @endpush ================================================ FILE: backend/resources/views/pages/displays/create.blade.php ================================================ @extends('layouts.base') @section('title', 'Create a new display') @section('container_class', 'max-w-5xl') @section('content') @php $isSelfHosted = config('settings.is_self_hosted'); $checkout = auth()->user()->getCheckoutUrl(route('displays.create')); $userHasPro = auth()->user()->hasProForCurrentWorkspace(); @endphp {{-- License Key Modal --}} {{-- Session Status Alert --}}
@csrf

This name is only used in the dashboard and for your identification.

This name will be displayed on the top right corner of the display.

@if($workspaces->count() > 1)

Select which workspace this display should belong to.

@else @endif

1. Select a calendar account

Choose the service you want to connect to.

Microsoft 365

@if(count($outlookAccounts) > 0) Connect to Outlook calendars @else Connect an account first @endif

Google

@if(count($googleAccounts) > 0) Connect to Google calendars @else Connect an account first @endif

CalDAV

@if(count($caldavAccounts) > 0) Connect to CalDAV calendars @else Connect an account first @endif

Cancel
@endsection @push('scripts') @endpush ================================================ FILE: backend/resources/views/pages/displays/customization.blade.php ================================================ @extends('layouts.base') @section('title', 'Display Customization - ' . $display->name) @section('container_class', 'max-w-2xl') @section('content') @if(!auth()->user()->hasProForCurrentWorkspace())

Pro Feature

Display customization is only available for Pro users. Upgrade to Pro to customize your display texts.

Back to Dashboard
@else

Display Customization

Customize the texts and privacy options for "{{ $display->name }}"

{{-- Session Status Alert --}}
@csrf @method('PUT')

State Texts

Visual Customization

{{-- Logo Upload --}}
@if(\App\Helpers\DisplaySettings::getLogo($display))
Current logo

Current logo

@endif
No file chosen

Upload a logo image (PNG, JPG, GIF). Recommended size: 200x100px or similar aspect ratio.

{{-- Background Image Upload --}}
{{-- Current Background --}} @if(\App\Helpers\DisplaySettings::getBackgroundImage($display))
Current background

Current background

@endif {{-- Default Backgrounds Selection --}}

Default backgrounds

@php $currentBackground = \App\Helpers\DisplaySettings::getBackgroundImage($display); $isDefaultBackground = $currentBackground && isset(\App\Services\ImageService::DEFAULT_BACKGROUNDS[$currentBackground]); @endphp @foreach(\App\Services\ImageService::DEFAULT_BACKGROUNDS as $key => $path) @endforeach
{{-- Custom Upload --}}

Or upload custom background

No file chosen

Upload a custom background image (PNG, JPG, GIF). Recommended size: 1920x1080px or similar aspect ratio.

Typography

Choose a font family for the display text. Changes will be applied immediately.

Privacy

If unchecked, meeting titles will be hidden for privacy-sensitive environments.

Cancel
@endif @endsection ================================================ FILE: backend/resources/views/pages/displays/settings.blade.php ================================================ @extends('layouts.base') @section('title', 'Display Settings - ' . $display->name) @section('container_class', 'max-w-2xl') @section('content') @if(!auth()->user()->hasProForCurrentWorkspace())

Pro Feature

Display settings are only available for Pro users. Upgrade to Pro to customize your display settings.

Back to Dashboard
@else

Display Settings

Configure settings for "{{ $display->name }}"

{{-- Session Status Alert --}} {{-- Pro Features Notice --}}

Pro Features

Display settings are Pro features that allow you to customize how users interact with your displays. These settings control check-in and booking functionality.

@csrf @method('PUT')

Check-in Settings

Allow users to check in to meetings on this display

isCheckInEnabled() ? 'checked' : '' }} class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600">

When enabled, users can check in to meetings directly from this display. This feature allows attendees to mark their attendance for meetings.

@php $checkInMinutes = $display->getCheckInMinutes(); @endphp

How many minutes before the meeting users can check in. Default: 15 minutes.

@php $gracePeriod = $display->getCheckInGracePeriod(); @endphp

How many minutes after the meeting starts users can still check in. Default: 15 minutes.

Booking Settings

Allow users to book rooms directly from this display

isBookingEnabled() ? 'checked' : '' }} class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600">

When enabled, users can book the room for immediate use directly from this display. This is a Pro feature that allows quick room reservations.

Calendar Settings

Allow users to view today's schedule on this display

isCalendarEnabled() ? 'checked' : '' }} class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600">

When enabled, users can view today's schedule in a calendar view directly from this display. This allows users to see all meetings scheduled for the day.

Admin Actions

Hide administrative actions (like switch room and logout) on this display

isAdminActionsHidden() ? 'checked' : '' }} class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600">

When enabled, administrative actions such as the switch room button will be hidden on this display. This is useful for public-facing displays where you don't want users to be able to switch rooms.

Hidden Access: When admin actions are hidden, administrators can still access them by long-pressing the room name in the top-right corner. The admin actions will appear temporarily for 30 seconds, then automatically hide again.

Cancel Permission

Control which events can be cancelled via the tablet

Choose which events users can cancel directly from this display.

Border Thickness

Control the thickness of borders and panels on this display

Adjust the visual thickness of borders and panels to match your design preferences.

Display Information

Display Name
{{ $display->display_name }}
Calendar
{{ $display->calendar->name }}
Status
@if($display->status === \App\Enums\DisplayStatus::ACTIVE) Active @else Inactive @endif
Last Sync
@if($display->last_sync_at) {{ $display->last_sync_at->diffForHumans() }} @else Never @endif
Cancel
@endif @endsection @push('scripts') @endpush ================================================ FILE: backend/resources/views/pages/onboarding.blade.php ================================================ @extends('layouts.blank') @section('title', 'Welcome, ' . auth()->user()->name . '!') @section('page')
@csrf
Logo

Let's get you set up! 🥳

We'll walk you through setting up a display
in just a few minutes

@if(!$hasUsageType)

How will you use Spacepad?

This helps us understand our user base and provide appropriate features and pricing.

@csrf
@elseif(!$hasAcceptedTerms)

Self-hosted License Agreement

In order to be the perfect all-encompassing room display solution for SMB's it is mandatory Spacepad is sustainable.

That's why as a self-hosted user, we need your agreement on two important points:

1. Fair Use

We collect your email address to verify licensing and ensure fair use. This is not shared externally and is only used for administrative purposes.

2. License Agreement

By using Spacepad, you agree to our licensing terms. Personal users get full access, while business users need a valid license key for multiple displays and Pro features.

@csrf
@elseif(!$hasAnyAccount)

Connect your first account

You'll be able to connect multiple accounts from different providers and display events from the calendars and rooms of the account.

@if(config('services.microsoft.enabled')) @endif @if(config('services.google.enabled')) @endif @if(config('services.caldav.enabled')) Connect to a CalDAV server @endif
@endif
@endsection @push('modals') @endpush @push('scripts') @endpush ================================================ FILE: backend/resources/views/pages/usage/index.blade.php ================================================ @extends('layouts.base') @section('title', 'Usage & Billing') @section('content')
{{-- Usage Breakdown --}}

Current Usage

Displays
{{ $usageBreakdown['displays'] }}
Boards
{{ $usageBreakdown['boards'] }}
Total Usage
{{ $usageBreakdown['total'] }}
{{-- Breakdown Details --}}

Usage Breakdown

Displays
{{ $usageBreakdown['displays'] }} active display(s)
{{ $usageBreakdown['displays'] }} unit(s)
Boards
{{ $usageBreakdown['boards'] }} board(s) × 2
{{ $usageBreakdown['board_usage'] }} unit(s)
Total Usage
Billed to your subscription
{{ $usageBreakdown['total'] }} unit(s)
{{-- What Counts Towards Usage --}}

What counts towards usage?

Displays
Each active display counts as 1 usage unit. Every display in your workspace is included in your usage count.
Boards
Each board counts as 2 usage units. This pricing model ensures fairness for users who don't use boards, keeping the base product accessible.
@endsection ================================================ FILE: backend/resources/views/vendor/googletagmanager/body.blade.php ================================================ $pushData */ ?> @if($enabled) @endif ================================================ FILE: backend/resources/views/vendor/pagination/tailwind.blade.php ================================================ @if ($paginator->hasPages()) @endif ================================================ FILE: backend/routes/api.php ================================================ group(function () { Route::post('login', [AuthController::class, 'login']); }); Route::middleware(['auth:sanctum', 'user.update-last-activity'])->group(function () { Route::get('devices/me', [DeviceController::class, 'me']); Route::put('devices/display', [DeviceController::class, 'changeDisplay']); # Deprecated > v1.2.0 Route::get('displays', [DisplayController::class, 'index']); Route::get('displays/{display}/data', [DisplayController::class, 'getData']); Route::post('displays/{display}/book', [DisplayController::class, 'book']); Route::post('displays/{display}/events/{eventId}/check-in', [DisplayController::class, 'checkIn']); Route::delete('displays/{display}/events/{eventId}', [DisplayController::class, 'cancel']); Route::get('events', [EventController::class, 'index']); # Deprecated > v1.2.0 // Display image serving for mobile app Route::get('displays/{display}/images/{type}', [DisplayController::class, 'serveImage']); }); // Webhook endpoints with rate limiting (100 requests per minute per IP) Route::middleware(['throttle:100,1'])->group(function () { Route::post('webhook/outlook', [OutlookWebhookController::class, 'handleNotification']); Route::post('webhook/google', [GoogleWebhookController::class, 'handleNotification']); }); // Instance management endpoints with rate limiting (60 requests per minute per IP) Route::prefix('v1')->middleware(['throttle:60,1'])->group(function () { Route::post('/instances/activate', [InstanceController::class, 'activate']); Route::post('/instances/heartbeat', [InstanceController::class, 'heartbeat']); Route::post('/instances/validate', [InstanceController::class, 'validateInstance']); }); ================================================ FILE: backend/routes/channels.php ================================================ id === (int) $id; }); ================================================ FILE: backend/routes/console.php ================================================ everyMinute() ->withoutOverlapping(); Schedule::command(SendHeartbeat::class) ->when(fn() => config('settings.is_self_hosted')) ->hourlyAt($heartbeatMinute) ->withoutOverlapping(); Schedule::command(ValidateLicense::class) ->when(fn() => config('settings.is_self_hosted') && InstanceService::hasLicense()) ->hourlyAt($validateMinute) ->withoutOverlapping(); Schedule::command(CleanupExpiredEvents::class) ->hourly() ->withoutOverlapping(); Schedule::command(UpdateLemonSqueezySubscriptions::class) ->when(fn() => ! config('settings.is_self_hosted')) ->hourly() ->withoutOverlapping(); Schedule::command(CheckMarketingTriggers::class) ->when(fn() => ! config('settings.is_self_hosted')) ->hourly() ->withoutOverlapping(); ================================================ FILE: backend/routes/web.php ================================================ middleware('guest') ->name('login'); Route::post('/login', [LoginController::class, 'store']) ->middleware('guest') ->name('login.store'); Route::get('/register', [RegisterController::class, 'create']) ->middleware('guest') ->name('register'); Route::post('/register', [RegisterController::class, 'store']) ->middleware('guest') ->name('register.store'); Route::post('/logout', [LoginController::class, 'destroy']) ->middleware('auth') ->name('logout'); Route::prefix('auth')->group(function () { Route::get('/microsoft/redirect', [MicrosoftController::class, 'redirect'])->name('auth.microsoft.redirect'); Route::get('/microsoft/callback', [MicrosoftController::class, 'callback']); Route::get('/google/redirect', [GoogleController::class, 'redirect'])->name('auth.google.redirect'); Route::get('/google/callback', [GoogleController::class, 'callback']); }); Route::middleware(['auth', 'user.update-last-activity', 'gtm'])->group(function () { Route::get('/', DashboardController::class)->name('dashboard')->middleware('user.active'); Route::get('/onboarding', [OnboardingController::class, 'index'])->name('onboarding')->middleware('user.onboarding'); Route::post('/onboarding/usage-type', [OnboardingController::class, 'updateUsageType'])->name('onboarding.usage-type'); Route::post('/onboarding/terms', [OnboardingController::class, 'acceptTerms'])->name('onboarding.terms'); Route::post('/outlook-accounts/auth', [OutlookAccountsController::class, 'auth'])->name('outlook-accounts.auth'); Route::get('/outlook-accounts/callback', [OutlookAccountsController::class, 'callback']); Route::get('/outlook-accounts/calendars', [OutlookAccountsController::class, 'getCalendars']); Route::delete('/outlook-accounts/{outlookAccount}', [OutlookAccountsController::class, 'delete'])->name('outlook-accounts.delete'); Route::post('/google-accounts/booking-method', [GoogleAccountsController::class, 'setBookingMethod'])->name('google-accounts.set-booking-method'); Route::post('/google-accounts/auth', [GoogleAccountsController::class, 'auth'])->name('google-accounts.auth'); Route::post('/google-accounts/service-account', [GoogleAccountsController::class, 'uploadServiceAccount'])->name('google-accounts.service-account'); Route::get('/google-accounts/callback', [GoogleAccountsController::class, 'callback']); Route::get('/google-accounts/calendars', [GoogleAccountsController::class, 'getCalendars']); Route::delete('/google-accounts/{googleAccount}', [GoogleAccountsController::class, 'delete'])->name('google-accounts.delete'); Route::get('/caldav-accounts/create', [CalDAVAccountsController::class, 'create'])->name('caldav-accounts.create'); Route::post('/caldav-accounts', [CalDAVAccountsController::class, 'store'])->name('caldav-accounts.store'); Route::delete('/caldav-accounts/{caldavAccount}', [CalDAVAccountsController::class, 'delete'])->name('caldav-accounts.delete'); Route::get('/displays/create', [DisplayController::class, 'create']) ->name('displays.create'); Route::post('/displays', [DisplayController::class, 'store'])->name('displays.store'); Route::patch('/displays/{display}/status', [DisplayController::class, 'updateStatus']) ->name('displays.updateStatus'); Route::delete('/displays/{display}', [DisplayController::class, 'delete'])->name('displays.delete'); // Display settings routes Route::get('/displays/{display}/settings', [DisplaySettingsController::class, 'index']) ->name('displays.settings.index'); Route::put('/displays/{display}/settings', [DisplaySettingsController::class, 'update']) ->name('displays.settings.update'); // Display customization routes Route::get('/displays/{display}/customization', [DisplaySettingsController::class, 'customization']) ->name('displays.customization'); Route::put('/displays/{display}/customization', [DisplaySettingsController::class, 'updateCustomization']) ->name('displays.customization.update'); Route::get('/calendars/outlook/{id}', [CalendarController::class, 'outlook']) ->name('calendars.outlook'); Route::get('/calendars/google/{id}', [CalendarController::class, 'google']) ->name('calendars.google'); Route::get('/calendars/caldav/{id}', [CalendarController::class, 'caldav']) ->name('calendars.caldav'); Route::get('/rooms/outlook/{id}', [RoomController::class, 'outlook']) ->name('rooms.outlook'); Route::get('/rooms/google/{id}', [RoomController::class, 'google']) ->name('rooms.google'); Route::post('/license/validate', [LicenseController::class, 'validateLicense'])->name('license.validate'); Route::post('/workspaces/switch', [WorkspaceController::class, 'switch'])->name('workspaces.switch'); Route::get('/billing/thanks', function () { \Spatie\GoogleTagManager\GoogleTagManagerFacade::flashPush([ 'event' => 'purchase', ]); if (config('services.google_conversion.send_to')) { \Spatie\GoogleTagManager\GoogleTagManagerFacade::flashPush([ 'event' => 'conversion', 'send_to' => config('services.google_conversion.send_to'), 'value' => config('services.google_conversion.value'), 'currency' => config('services.google_conversion.currency'), 'transaction_id' => '', ]); } return redirect()->route('dashboard'); })->name('billing.thanks'); Route::get('/admin', [AdminController::class, 'index'])->name('admin.index'); Route::get('/admin/users/{user}', [AdminController::class, 'showUser'])->name('admin.users.show'); Route::delete('/admin/users/{user}', [AdminController::class, 'deleteUser'])->name('admin.users.delete'); Route::post('/admin/users/{user}/impersonate', [AdminController::class, 'impersonate'])->name('admin.users.impersonate'); Route::post('/admin/stop-impersonating', [AdminController::class, 'stopImpersonating'])->name('admin.stop-impersonating'); // Display image serving route Route::get('/displays/{display}/images/{type}', [DisplaySettingsController::class, 'serveImage']) ->name('displays.images'); // Boards routes Route::get('/boards/create', [BoardController::class, 'create'])->name('boards.create'); Route::post('/boards', [BoardController::class, 'store'])->name('boards.store'); Route::get('/boards/{board}', [BoardController::class, 'show'])->name('boards.show'); Route::get('/boards/{board}/edit', [BoardController::class, 'edit'])->name('boards.edit'); Route::put('/boards/{board}', [BoardController::class, 'update'])->name('boards.update'); Route::delete('/boards/{board}', [BoardController::class, 'destroy'])->name('boards.destroy'); Route::get('/boards/{board}/images/logo', [BoardController::class, 'serveLogo'])->name('boards.images.logo'); // Usage routes Route::get('/usage', [UsageController::class, 'index'])->name('usage.index'); }); ================================================ FILE: backend/storage/app/.gitignore ================================================ * !private/ !public/ !.gitignore ================================================ FILE: backend/storage/framework/.gitignore ================================================ compiled.php config.php down events.scanned.php maintenance.php routes.php routes.scanned.php schedule-* services.json ================================================ FILE: backend/storage/framework/cache/.gitignore ================================================ * !data/ !.gitignore ================================================ FILE: backend/storage/framework/sessions/.gitignore ================================================ * !.gitignore ================================================ FILE: backend/storage/framework/testing/.gitignore ================================================ * !.gitignore ================================================ FILE: backend/storage/framework/views/.gitignore ================================================ * !.gitignore ================================================ FILE: backend/storage/logs/.gitignore ================================================ * !.gitignore ================================================ FILE: backend/tailwind.config.js ================================================ import defaultTheme from 'tailwindcss/defaultTheme'; /** @type {import('tailwindcss').Config} */ export default { content: [ './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', './storage/framework/views/*.php', './resources/**/*.blade.php', './resources/**/*.js', './resources/**/*.vue', ], theme: { extend: { fontFamily: { sans: ['Figtree', ...defaultTheme.fontFamily.sans], }, }, }, plugins: [], }; ================================================ FILE: backend/tests/Feature/API/AuthControllerTest.php ================================================ create(); $code = $user->getConnectCode(); $uid = 'test-device-uid-123'; $name = 'Test Device'; $response = $this->postJson('/api/auth/login', [ 'code' => $code, 'uid' => $uid, 'name' => $name, ]); $response->assertOk() ->assertJsonStructure([ 'data' => [ 'token', 'device' => [ 'id', 'name', 'user', 'display', ], ], ]); // Verify device was created $device = Device::where('uid', $uid)->first(); expect($device)->not->toBeNull() ->and($device->user_id)->toBe($user->id) ->and($device->name)->toBe($name) ->and($device->workspace_id)->toBe($user->primaryWorkspace()?->id); }); it('returns error when connect code is invalid', function () { $response = $this->postJson('/api/auth/login', [ 'code' => '999999', 'uid' => 'test-device-uid-123', 'name' => 'Test Device', ]); $response->assertStatus(400) ->assertJson([ 'message' => 'Code is incorrect.', 'errors' => [ 'code' => ['incorrect'], ], ]); }); it('can only use a connect code once', function () { $user = User::factory()->create(); $code = $user->getConnectCode(); $uid1 = 'test-device-uid-1'; $uid2 = 'test-device-uid-2'; // First use - should succeed $response1 = $this->postJson('/api/auth/login', [ 'code' => $code, 'uid' => $uid1, 'name' => 'Device 1', ]); $response1->assertOk(); // Second use with same code - should fail $response2 = $this->postJson('/api/auth/login', [ 'code' => $code, 'uid' => $uid2, 'name' => 'Device 2', ]); $response2->assertStatus(400) ->assertJson([ 'message' => 'Code is incorrect.', ]); // Verify only first device was created expect(Device::where('uid', $uid1)->exists())->toBeTrue() ->and(Device::where('uid', $uid2)->exists())->toBeFalse(); }); it('removes connect code from cache after use', function () { $user = User::factory()->create(); $code = $user->getConnectCode(); // Verify code exists in cache expect(Cache::has("connect-code:$code"))->toBeTrue(); // Use the code $this->postJson('/api/auth/login', [ 'code' => $code, 'uid' => 'test-device-uid', 'name' => 'Test Device', ])->assertOk(); // Verify code is removed from cache expect(Cache::has("connect-code:$code"))->toBeFalse(); expect(Cache::has("user:{$user->id}:connect-code"))->toBeFalse(); }); it('handles expired connect codes', function () { $user = User::factory()->create(); $code = $user->getConnectCode(); // Manually remove the code from cache to simulate expiration Cache::forget("connect-code:$code"); Cache::forget("user:{$user->id}:connect-code"); $response = $this->postJson('/api/auth/login', [ 'code' => $code, 'uid' => 'test-device-uid', 'name' => 'Test Device', ]); $response->assertStatus(400) ->assertJson([ 'message' => 'Code is incorrect.', ]); }); it('creates new device if device with same uid does not exist', function () { $user = User::factory()->create(); $code = $user->getConnectCode(); $uid = 'test-device-uid'; $this->postJson('/api/auth/login', [ 'code' => $code, 'uid' => $uid, 'name' => 'New Device', ])->assertOk(); $device = Device::where('uid', $uid)->first(); expect($device)->not->toBeNull() ->and($device->name)->toBe('New Device'); }); it('updates existing device when connecting with same uid', function () { $user = User::factory()->create(); $existingDevice = Device::factory()->create([ 'user_id' => $user->id, 'uid' => 'test-device-uid', 'name' => 'Old Device Name', 'workspace_id' => null, ]); $code = $user->getConnectCode(); $response = $this->postJson('/api/auth/login', [ 'code' => $code, 'uid' => 'test-device-uid', 'name' => 'Updated Device Name', ])->assertOk(); // Verify device was updated, not duplicated $devices = Device::where('uid', 'test-device-uid')->get(); expect($devices)->toHaveCount(1); $existingDevice->refresh(); expect($existingDevice->name)->toBe('Updated Device Name') ->and($existingDevice->workspace_id)->toBe($user->primaryWorkspace()?->id); }); it('works with different users having different codes', function () { $user1 = User::factory()->create(); $user2 = User::factory()->create(); $code1 = $user1->getConnectCode(); $code2 = $user2->getConnectCode(); // Verify codes are different expect($code1)->not->toBe($code2); // Connect device 1 with user 1's code $response1 = $this->postJson('/api/auth/login', [ 'code' => $code1, 'uid' => 'device-1', 'name' => 'Device 1', ])->assertOk(); // Connect device 2 with user 2's code $response2 = $this->postJson('/api/auth/login', [ 'code' => $code2, 'uid' => 'device-2', 'name' => 'Device 2', ])->assertOk(); // Verify devices are connected to correct users $device1 = Device::where('uid', 'device-1')->first(); $device2 = Device::where('uid', 'device-2')->first(); expect($device1->user_id)->toBe($user1->id) ->and($device2->user_id)->toBe($user2->id); }); it('returns same code when getConnectCode is called multiple times before expiration', function () { $user = User::factory()->create(); $code1 = $user->getConnectCode(); $code2 = $user->getConnectCode(); expect($code1)->toBe($code2); }); it('generates new code after previous code is used', function () { $user = User::factory()->create(); $code1 = $user->getConnectCode(); // Use the code $this->postJson('/api/auth/login', [ 'code' => $code1, 'uid' => 'test-device-uid', 'name' => 'Test Device', ])->assertOk(); // Get a new code - should be different $code2 = $user->getConnectCode(); expect($code2)->not->toBe($code1); }); it('validates required fields', function () { // Missing code $this->postJson('/api/auth/login', [ 'uid' => 'test-device-uid', 'name' => 'Test Device', ])->assertStatus(422) ->assertJsonValidationErrors(['code']); // Missing uid $this->postJson('/api/auth/login', [ 'code' => '123456', 'name' => 'Test Device', ])->assertStatus(422) ->assertJsonValidationErrors(['uid']); // Missing name $this->postJson('/api/auth/login', [ 'code' => '123456', 'uid' => 'test-device-uid', ])->assertStatus(422) ->assertJsonValidationErrors(['name']); }); it('handles case when user associated with code no longer exists', function () { $user = User::factory()->create(); $code = $user->getConnectCode(); // Manually set cache with a non-existent user ID Cache::put("connect-code:$code", 'non-existent-user-id', now()->addMinutes(30)); $response = $this->postJson('/api/auth/login', [ 'code' => $code, 'uid' => 'test-device-uid', 'name' => 'Test Device', ]); $response->assertStatus(400) ->assertJson([ 'message' => 'Code is incorrect.', ]); }); it('returns token that can be used for authenticated requests', function () { $user = User::factory()->create(); $code = $user->getConnectCode(); $response = $this->postJson('/api/auth/login', [ 'code' => $code, 'uid' => 'test-device-uid', 'name' => 'Test Device', ])->assertOk(); $token = $response->json('data.token'); expect($token)->not->toBeNull(); // Verify token can be used for authenticated requests $device = Device::where('uid', 'test-device-uid')->first(); $this->withHeader('Authorization', "Bearer $token") ->getJson('/api/devices/me') ->assertOk(); }); ================================================ FILE: backend/tests/Feature/API/EventControllerTest.php ================================================ user = User::factory()->create(); // User boot method automatically creates a workspace, get the primary workspace $this->workspace = $this->user->primaryWorkspace(); $this->device = Device::factory()->create([ 'user_id' => $this->user->id, 'workspace_id' => $this->workspace->id, ]); // Create calendar first $this->calendar = Calendar::factory()->create([ 'user_id' => $this->user->id, 'workspace_id' => $this->workspace->id, 'calendar_id' => 'test@example.com', 'name' => 'Test Calendar' ]); // Then create display with calendar $this->display = Display::factory()->create([ 'user_id' => $this->user->id, 'workspace_id' => $this->workspace->id, 'calendar_id' => $this->calendar->id, 'status' => 'active' ]); $this->device->update(['display_id' => $this->display->id]); }); it('returns 400 when device is not connected to a display', function () { $this->device->update(['display_id' => null]); $this->actingAs($this->device) ->getJson('/api/events') ->assertStatus(404) ->assertJson(['message' => 'Display not found']); }); it('returns 400 when display is deactivated', function () { $this->display->update(['status' => 'deactivated']); $this->actingAs($this->device) ->getJson('/api/events') ->assertStatus(400) ->assertJson(['message' => 'Display is deactivated']); }); it('returns outlook events in the correct format', function () { // Create accounts and link them to the calendar $outlookAccount = OutlookAccount::factory()->create(['user_id' => $this->user->id]); Room::factory()->create([ 'user_id' => $this->user->id, 'workspace_id' => $this->workspace->id, 'calendar_id' => $this->calendar->id, 'email_address' => 'test@example.com' ]); $this->calendar->update([ 'outlook_account_id' => $outlookAccount->id ]); // Mock Outlook service response $outlookEvents = [ [ 'id' => 'outlook-1', 'subject' => 'Test Outlook Event', 'body' => ['content' => 'Test Description'], 'bodyPreview' => 'Test Description', 'isAllDay' => false, 'location' => ['displayName' => 'Test Location'], 'start' => [ 'dateTime' => now()->addHour()->toIso8601String(), 'timeZone' => 'UTC' ], 'end' => [ 'dateTime' => now()->addHours(2)->toIso8601String(), 'timeZone' => 'UTC' ] ] ]; // Mock the service $outlookService = Mockery::mock(OutlookService::class); $outlookService->shouldReceive('fetchEventsByUser') ->once() ->andReturn($outlookEvents); $this->app->instance(OutlookService::class, $outlookService); $response = $this->actingAs($this->device) ->getJson('/api/events') ->assertOk() ->assertJsonStructure([ 'data' => [ '*' => [ 'id', 'summary', 'location', 'description', 'start', 'end', 'timezone', ] ] ]); $events = $response->json('data'); expect($events)->toHaveCount(1); // Verify Outlook event format $event = $events[0]; expect($event)->toBeArray() ->and($event['summary'])->toBe('Test Outlook Event') ->and($event['location'])->toBe('Test Location') ->and($event['description'])->toBe('Test Description') ->and($event['timezone'])->toBe('UTC'); }); it('returns google events in the correct format', function () { // Create accounts and link them to the calendar $googleAccount = GoogleAccount::factory()->create(['user_id' => $this->user->id]); Room::factory()->create([ 'user_id' => $this->user->id, 'workspace_id' => $this->workspace->id, 'calendar_id' => $this->calendar->id, 'email_address' => 'test@example.com' ]); $this->calendar->update([ 'google_account_id' => $googleAccount->id ]); // Mock Google service response $googleEvent = new \Google\Service\Calendar\Event(); $googleEvent->setId('google-1'); $googleEvent->setSummary('Test Google Event'); $googleEvent->setDescription('Test Description'); $googleEvent->setLocation('Test Location'); $googleEvent->setStart(new \Google\Service\Calendar\EventDateTime([ 'dateTime' => now()->addHour()->toIso8601String(), 'timeZone' => 'UTC' ])); $googleEvent->setEnd(new \Google\Service\Calendar\EventDateTime([ 'dateTime' => now()->addHours(2)->toIso8601String(), 'timeZone' => 'UTC' ])); // Mock the service $googleService = Mockery::mock(GoogleService::class); $googleService->shouldReceive('fetchEvents') ->once() ->with( Mockery::type(GoogleAccount::class), 'test@example.com', Mockery::type(\Carbon\Carbon::class), Mockery::type(\Carbon\Carbon::class) ) ->andReturn([$googleEvent]); $this->app->instance(GoogleService::class, $googleService); $response = $this->actingAs($this->device) ->getJson('/api/events') ->assertOk() ->assertJsonStructure([ 'data' => [ '*' => [ 'id', 'summary', 'location', 'description', 'start', 'end', 'timezone' ] ] ]); $events = $response->json('data'); expect($events)->toHaveCount(1); // Verify Google event format $event = $events[0]; expect($event)->toBeArray() ->and($event['summary'])->toBe('Test Google Event') ->and($event['location'])->toBe('Test Location') ->and($event['description'])->toBe('Test Description') ->and($event['timezone'])->toBe('UTC'); }); it('does not cache events when no event subscription exists', function () { // Create accounts and link them to the calendar $outlookAccount = OutlookAccount::factory()->create(['user_id' => $this->user->id]); $room = Room::factory()->create([ 'user_id' => $this->user->id, 'calendar_id' => $this->calendar->id, 'email_address' => 'test@example.com' ]); $this->calendar->update([ 'outlook_account_id' => $outlookAccount->id ]); // Mock Outlook service response $outlookEvents = [ [ 'id' => 'outlook-1', 'subject' => 'Test Outlook Event', 'body' => ['content' => 'Test Description'], 'bodyPreview' => 'Test Description', 'isAllDay' => false, 'location' => ['displayName' => 'Test Location'], 'start' => [ 'dateTime' => now()->addHour()->toIso8601String(), 'timeZone' => 'UTC' ], 'end' => [ 'dateTime' => now()->addHours(2)->toIso8601String(), 'timeZone' => 'UTC' ] ] ]; // Mock the service $outlookService = Mockery::mock(OutlookService::class); $outlookService->shouldReceive('fetchEventsByUser') ->twice() // Should be called twice since no caching ->andReturn($outlookEvents); $this->app->instance(OutlookService::class, $outlookService); // First request $this->actingAs($this->device) ->getJson('/api/events') ->assertOk(); // Second request $this->actingAs($this->device) ->getJson('/api/events') ->assertOk(); // Verify no cache exists expect(cache()->has($this->display->getEventsCacheKey()))->toBeFalse(); }); it('caches events when event subscription exists', function () { // Create accounts and link them to the calendar $outlookAccount = OutlookAccount::factory()->create(['user_id' => $this->user->id]); Room::factory()->create([ 'user_id' => $this->user->id, 'workspace_id' => $this->workspace->id, 'calendar_id' => $this->calendar->id, 'email_address' => 'test@example.com' ]); $this->calendar->update([ 'outlook_account_id' => $outlookAccount->id ]); // Create event subscription for the Outlook account $test = EventSubscription::factory() ->outlook($outlookAccount) ->create([ 'display_id' => $this->display->id ]); // Mock Outlook service response $outlookEvents = [ [ 'id' => 'outlook-1', 'subject' => 'Test Outlook Event', 'body' => ['content' => 'Test Description'], 'bodyPreview' => 'Test Description', 'isAllDay' => false, 'location' => ['displayName' => 'Test Location'], 'start' => [ 'dateTime' => now()->addHour()->toIso8601String(), 'timeZone' => 'UTC' ], 'end' => [ 'dateTime' => now()->addHours(2)->toIso8601String(), 'timeZone' => 'UTC' ] ] ]; // Mock the service $outlookService = Mockery::mock(OutlookService::class); $outlookService->shouldReceive('fetchEventsByUser') ->once() // Should be called only once due to caching ->andReturn($outlookEvents); $this->app->instance(OutlookService::class, $outlookService); // First request should call the service $this->actingAs($this->device) ->getJson('/api/events') ->assertOk(); // Second request should use cache $this->actingAs($this->device) ->getJson('/api/events') ->assertOk(); // Verify cache exists expect(cache()->has($this->display->getEventsCacheKey()))->toBeTrue(); }); it('handles errors gracefully', function () { // Create accounts and link them to the calendar $outlookAccount = OutlookAccount::factory()->create(['user_id' => $this->user->id]); $room = Room::factory()->create([ 'user_id' => $this->user->id, 'calendar_id' => $this->calendar->id, 'email_address' => 'test@example.com' ]); $this->calendar->update([ 'outlook_account_id' => $outlookAccount->id ]); // Mock the service to throw an exception $outlookService = Mockery::mock(OutlookService::class); $outlookService->shouldReceive('fetchEventsByUser') ->once() ->andThrow(new \Exception('Service error')); $this->app->instance(OutlookService::class, $outlookService); $this->actingAs($this->device) ->getJson('/api/events') ->assertStatus(500) ->assertJson(['message' => 'Service error']); }); ================================================ FILE: backend/tests/Feature/AdminBoardsTest.php ================================================ false]); $this->admin = User::factory()->create([ 'is_admin' => true, 'is_unlimited' => true, ]); // Set selected workspace for admin $workspace = $this->admin->primaryWorkspace(); session()->put('selected_workspace_id', $workspace->id); }); test('admin can see total boards count', function () { // Create boards without triggering subscription queries $user = User::factory()->create(['is_unlimited' => true]); $workspace = $user->primaryWorkspace(); Board::factory()->count(5)->create([ 'workspace_id' => $workspace->id, ]); $response = $this->actingAs($this->admin) ->get(route('admin.index')); $response->assertStatus(200); $response->assertSee('Total Boards'); $response->assertSee('5'); }); test('admin can see boards count per user in active users tab', function () { $user = User::factory()->active()->create(); $workspace = $user->primaryWorkspace(); Display::factory()->count(2)->create([ 'workspace_id' => $workspace->id, 'status' => DisplayStatus::ACTIVE, 'last_sync_at' => now(), ]); Board::factory()->count(3)->create([ 'workspace_id' => $workspace->id, 'user_id' => $user->id, ]); $response = $this->actingAs($this->admin) ->get(route('admin.index')); $response->assertStatus(200); $response->assertSee('Boards'); // Check that boards count appears in the table $response->assertSee('3'); }); test('admin can see boards count in paying users tab', function () { $user = User::factory()->active()->create([ 'is_unlimited' => true, ]); $workspace = $user->primaryWorkspace(); Board::factory()->count(2)->create([ 'workspace_id' => $workspace->id, 'user_id' => $user->id, ]); $response = $this->actingAs($this->admin) ->get(route('admin.index')); $response->assertStatus(200); // Should see boards count in paying users table $response->assertSee('Boards'); }); test('admin can see boards count in users overview tab', function () { $user = User::factory()->create(); $workspace = $user->primaryWorkspace(); Board::factory()->count(4)->create([ 'workspace_id' => $workspace->id, 'user_id' => $user->id, ]); $response = $this->actingAs($this->admin) ->get(route('admin.index')); $response->assertStatus(200); $response->assertSee('Boards'); // Should see boards count in users overview table }); test('admin can see boards count for self-hosted instances', function () { $instance = Instance::factory()->create([ 'is_self_hosted' => true, 'displays_count' => 5, 'rooms_count' => 2, 'boards_count' => 3, 'last_heartbeat_at' => now(), ]); $response = $this->actingAs($this->admin) ->get(route('admin.index')); $response->assertStatus(200); $response->assertSee('Boards'); // Should see boards count in instances table $response->assertSee('3'); }); ================================================ FILE: backend/tests/Feature/BoardControllerTest.php ================================================ user = User::factory()->active()->create([ 'is_unlimited' => true, // Make user pro for testing boards ]); $this->workspace = $this->user->primaryWorkspace(); // Set selected workspace in session session()->put('selected_workspace_id', $this->workspace->id); }); test('user can create a board', function () { Display::factory()->count(2)->create([ 'workspace_id' => $this->workspace->id, 'status' => DisplayStatus::ACTIVE, ]); $response = $this->actingAs($this->user) ->post(route('boards.store'), [ 'name' => 'Test Board', 'workspace_id' => $this->workspace->id, 'show_all_displays' => true, 'theme' => 'dark', 'show_title' => true, 'show_booker' => true, 'show_next_event' => true, 'show_transitioning' => true, 'transitioning_minutes' => 10, 'font_family' => 'Inter', 'language' => 'en', 'view_mode' => 'card', 'show_meeting_title' => true, ]); $response->assertRedirect(); $this->assertDatabaseHas('boards', [ 'name' => 'Test Board', 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, ]); }); test('user can view boards list', function () { Board::factory()->count(3)->create([ 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, ]); $response = $this->actingAs($this->user) ->get(route('dashboard') . '?tab=boards'); $response->assertStatus(200); $response->assertSee('Boards'); }); test('user can view a board', function () { $board = Board::factory()->create([ 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, ]); $response = $this->actingAs($this->user) ->get(route('boards.show', $board)); $response->assertStatus(200); $response->assertViewIs('pages.boards.show'); }); test('user can update a board', function () { $board = Board::factory()->create([ 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, ]); $response = $this->actingAs($this->user) ->put(route('boards.update', $board), [ 'name' => 'Updated Board Name', 'workspace_id' => $this->workspace->id, 'show_all_displays' => false, 'theme' => 'light', 'show_title' => true, 'show_booker' => true, 'show_next_event' => true, 'show_transitioning' => true, 'transitioning_minutes' => 10, 'font_family' => 'Inter', 'language' => 'en', 'view_mode' => 'card', 'show_meeting_title' => true, ]); $response->assertRedirect(); $this->assertDatabaseHas('boards', [ 'id' => $board->id, 'name' => 'Updated Board Name', 'theme' => 'light', ]); }); test('user can delete a board', function () { $board = Board::factory()->create([ 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, ]); $response = $this->actingAs($this->user) ->delete(route('boards.destroy', $board)); $response->assertRedirect(); $this->assertDatabaseMissing('boards', [ 'id' => $board->id, ]); }); ================================================ FILE: backend/tests/Feature/BoardUsageTest.php ================================================ active()->create(); $workspace = $user->primaryWorkspace(); Display::factory()->count(3)->create([ 'workspace_id' => $workspace->id, 'status' => DisplayStatus::ACTIVE, ]); Board::factory()->count(2)->create([ 'workspace_id' => $workspace->id, ]); $response = $this->actingAs($user) ->get(route('usage.index')); $response->assertStatus(200); $response->assertViewIs('pages.usage.index'); $response->assertViewHas('usageBreakdown', function ($breakdown) { return $breakdown['displays'] === 3 && $breakdown['boards'] === 2 && $breakdown['board_usage'] === 4 && $breakdown['total'] === 7; }); }); test('usage page shows correct data structure', function () { $user = User::factory()->active()->create([ 'is_unlimited' => true, ]); $workspace = $user->primaryWorkspace(); session()->put('selected_workspace_id', $workspace->id); Display::factory()->count(1)->create([ 'workspace_id' => $workspace->id, 'status' => DisplayStatus::ACTIVE, ]); Board::factory()->count(1)->create([ 'workspace_id' => $workspace->id, ]); $response = $this->actingAs($user) ->get(route('usage.index')); $response->assertStatus(200); $response->assertViewHas('usageBreakdown'); $response->assertViewHas('workspace'); }); ================================================ FILE: backend/tests/Feature/DisplaySettingsApiTest.php ================================================ create([ 'usage_type' => UsageType::PERSONAL, ]); $workspace = $user->primaryWorkspace(); $display = Display::factory()->create([ 'user_id' => $user->id, 'workspace_id' => $workspace->id, ]); $device = Device::factory()->create([ 'user_id' => $user->id, 'workspace_id' => $workspace->id, 'display_id' => $display->id, ]); // Set some display settings DisplaySettings::setCheckInEnabled($display, true); DisplaySettings::setBookingEnabled($display, false); $response = $this->actingAs($device) ->getJson('/api/displays'); $response->assertStatus(200); $response->assertJsonStructure([ 'data' => [ '*' => [ 'id', 'name', 'settings' => [ 'check_in_enabled', 'booking_enabled', ], ], ], ]); $displayData = $response->json('data.0'); $this->assertTrue($displayData['settings']['check_in_enabled']); $this->assertFalse($displayData['settings']['booking_enabled']); } public function test_display_api_includes_default_settings_when_none_set() { $user = User::factory()->create([ 'usage_type' => UsageType::PERSONAL, ]); $workspace = $user->primaryWorkspace(); $display = Display::factory()->create([ 'user_id' => $user->id, 'workspace_id' => $workspace->id, ]); $device = Device::factory()->create([ 'user_id' => $user->id, 'workspace_id' => $workspace->id, 'display_id' => $display->id, ]); $response = $this->actingAs($device) ->getJson('/api/displays'); $response->assertStatus(200); $displayData = $response->json('data.0'); $this->assertFalse($displayData['settings']['check_in_enabled']); $this->assertFalse($displayData['settings']['booking_enabled']); } public function test_display_settings_are_encrypted_in_database() { $user = User::factory()->create([ 'usage_type' => UsageType::PERSONAL, ]); $workspace = $user->primaryWorkspace(); $display = Display::factory()->create([ 'user_id' => $user->id, 'workspace_id' => $workspace->id, ]); Device::factory()->create([ 'user_id' => $user->id, 'workspace_id' => $workspace->id, 'display_id' => $display->id, ]); // Set display settings DisplaySettings::setCheckInEnabled($display, true); DisplaySettings::setBookingEnabled($display, true); // Check that the raw database values are encrypted $displaySetting = $display->settings()->where('key', 'check_in_enabled')->first(); $this->assertNotNull($displaySetting); $this->assertNotEquals('true', $displaySetting->getRawOriginal('value')); $this->assertTrue($displaySetting->value); // Decrypted value should be true } } ================================================ FILE: backend/tests/Feature/InstanceHeartbeatTest.php ================================================ postJson('/api/v1/instances/heartbeat', [ 'instance_key' => 'test-instance-key', 'license_key' => null, 'license_valid' => false, 'license_expires_at' => null, 'is_self_hosted' => true, 'displays_count' => 5, 'rooms_count' => 2, 'boards_count' => 3, 'version' => '1.0.0', 'users' => [ [ 'email' => 'test@example.com', 'usage_type' => 'personal', ], ], ]); $response->assertStatus(200); $instance = Instance::where('instance_key', 'test-instance-key')->first(); expect($instance)->not->toBeNull(); expect($instance->boards_count)->toBe(3); }); test('instance heartbeat works without boards_count for backward compatibility', function () { $response = $this->postJson('/api/v1/instances/heartbeat', [ 'instance_key' => 'test-instance-key-2', 'license_key' => null, 'license_valid' => false, 'license_expires_at' => null, 'is_self_hosted' => true, 'displays_count' => 5, 'rooms_count' => 2, 'version' => '1.0.0', 'users' => [ [ 'email' => 'test@example.com', 'usage_type' => 'personal', ], ], ]); $response->assertStatus(200); $instance = Instance::where('instance_key', 'test-instance-key-2')->first(); expect($instance)->not->toBeNull(); expect($instance->boards_count)->toBeNull(); }); test('instance heartbeat updates existing instance with boards_count', function () { $instance = Instance::factory()->create([ 'instance_key' => 'existing-instance', 'boards_count' => null, ]); $response = $this->postJson('/api/v1/instances/heartbeat', [ 'instance_key' => 'existing-instance', 'license_key' => null, 'license_valid' => false, 'license_expires_at' => null, 'is_self_hosted' => true, 'displays_count' => 5, 'rooms_count' => 2, 'boards_count' => 7, 'version' => '1.0.0', 'users' => [ [ 'email' => 'test@example.com', 'usage_type' => 'personal', ], ], ]); $response->assertStatus(200); $instance->refresh(); expect($instance->boards_count)->toBe(7); }); ================================================ FILE: backend/tests/Feature/PageReachabilityTest.php ================================================ get(route('dashboard')); $response->assertStatus(302); $response->assertRedirect(route('login')); }); test('it redirects to login page when visiting displays unauthenticated', function () { $response = $this->get(route('displays.create')); $response->assertStatus(302); $response->assertRedirect(route('login')); }); test('it loads login page successfully', function () { $response = $this->get(route('login')); $response->assertStatus(200); $response->assertViewIs('auth.login'); }); test('it loads register page successfully', function () { $response = $this->get(route('register')); $response->assertStatus(200); $response->assertViewIs('auth.register'); }); test('it loads dashboard when authenticated', function () { $user = User::factory()->active()->create(); $response = $this->actingAs($user) ->get(route('dashboard')); $response->assertStatus(200); $response->assertViewIs('pages.dashboard'); }); test('it loads displays page when authenticated', function () { $user = User::factory()->active()->create(); $response = $this->actingAs($user) ->get(route('displays.create')); $response->assertStatus(200); $response->assertViewIs('pages.displays.create'); }); test('it redirects to dashboard when visiting login while authenticated', function () { $user = User::factory()->create(); $response = $this->actingAs($user) ->get(route('login')); $response->assertStatus(302); $response->assertRedirect(route('dashboard')); }); test('it redirects to dashboard when visiting register while authenticated', function () { $user = User::factory()->create(); $response = $this->actingAs($user) ->get(route('register')); $response->assertStatus(302); $response->assertRedirect(route('dashboard')); }); test('it handles 404 for invalid routes', function () { $response = $this->get('/invalid-route'); $response->assertStatus(404); }); test('it handles 404 for invalid routes when authenticated', function () { $user = User::factory()->create(); $response = $this->actingAs($user) ->get('/invalid-route'); $response->assertStatus(404); }); ================================================ FILE: backend/tests/Pest.php ================================================ extend(Tests\TestCase::class) ->in('Feature', 'Unit'); /* |-------------------------------------------------------------------------- | Expectations |-------------------------------------------------------------------------- | | When you're writing tests, you often need to check that values meet certain conditions. The | "expect()" function gives you access to a set of "expectations" methods that you can use | to assert different things. Of course, you may extend the Expectation API at any time. | */ expect()->extend('toBeOne', function () { return $this->toBe(1); }); /* |-------------------------------------------------------------------------- | Functions |-------------------------------------------------------------------------- | | While Pest is very powerful out-of-the-box, you may have some testing code specific to your | project that you don't want to repeat in every file. Here you can also expose helpers as | global functions to help you to reduce the number of lines of code in your test files. | */ function something() { // .. } ================================================ FILE: backend/tests/TestCase.php ================================================ withoutVite(); } } ================================================ FILE: backend/tests/Unit/DisplaySettingsTest.php ================================================ create(); $workspace = $user->primaryWorkspace(); $display = Display::factory()->create([ 'user_id' => $user->id, 'workspace_id' => $workspace->id, ]); // Test setting check-in enabled expect(DisplaySettings::setCheckInEnabled($display, true))->toBeTrue(); expect(DisplaySettings::isCheckInEnabled($display))->toBeTrue(); // Test setting booking enabled expect(DisplaySettings::setBookingEnabled($display, true))->toBeTrue(); expect(DisplaySettings::isBookingEnabled($display))->toBeTrue(); // Test default values $newDisplay = Display::factory()->create([ 'user_id' => $user->id, 'workspace_id' => $workspace->id, ]); expect(DisplaySettings::isCheckInEnabled($newDisplay))->toBeFalse(); expect(DisplaySettings::isBookingEnabled($newDisplay))->toBeFalse(); }); test('display model convenience methods work correctly', function () { $user = User::factory()->create(); $workspace = $user->primaryWorkspace(); $display = Display::factory()->create([ 'user_id' => $user->id, 'workspace_id' => $workspace->id, ]); // Test default values expect($display->isCheckInEnabled())->toBeFalse(); expect($display->isBookingEnabled())->toBeFalse(); // Test setting values expect($display->setCheckInEnabled(true))->toBeTrue(); expect($display->setBookingEnabled(true))->toBeTrue(); // Test getting values expect($display->isCheckInEnabled())->toBeTrue(); expect($display->isBookingEnabled())->toBeTrue(); }); test('display settings can be retrieved as array', function () { $user = User::factory()->create(); $workspace = $user->primaryWorkspace(); $display = Display::factory()->create([ 'user_id' => $user->id, 'workspace_id' => $workspace->id, ]); // Set some settings DisplaySettings::setCheckInEnabled($display, true); DisplaySettings::setBookingEnabled($display, false); $allSettings = DisplaySettings::getAllSettings($display); expect($allSettings)->toBeArray() ->toHaveKey('check_in_enabled', true) ->toHaveKey('booking_enabled', false); }); ================================================ FILE: backend/tests/Unit/SettingsTest.php ================================================ toBeTrue(); // Test getting the value expect(Settings::getSetting($key))->toBe($value); // Test getting with default value expect(Settings::getSetting('non_existent_key', 'default'))->toBe('default'); }); test('settings helper can handle different types', function () { // Test boolean Settings::setSetting('test_bool', true, 'boolean'); expect(Settings::getSetting('test_bool'))->toBeTrue(); // Test integer Settings::setSetting('test_int', 42, 'integer'); expect(Settings::getSetting('test_int'))->toBe(42); // Test float Settings::setSetting('test_float', 3.14, 'float'); expect(Settings::getSetting('test_float'))->toBe(3.14); // Test array $array = ['key' => 'value']; Settings::setSetting('test_array', $array, 'array'); expect(Settings::getSetting('test_array'))->toBe($array); }); test('settings helper can delete settings', function () { $key = 'test_delete'; $value = 'to be deleted'; // Set a value Settings::setSetting($key, $value); expect(Settings::getSetting($key))->toBe($value); // Delete the value expect(Settings::deleteSetting($key))->toBeTrue(); expect(Settings::getSetting($key))->toBeNull(); // Test deleting non-existent key expect(Settings::deleteSetting('non_existent_key'))->toBeFalse(); }); test('settings helper can get all settings', function () { // Set multiple settings Settings::setSetting('key1', 'value1'); Settings::setSetting('key2', 'value2'); $allSettings = Settings::getAllSettings(); expect($allSettings)->toBeArray() ->toHaveCount(2) ->toHaveKey('key1', 'value1') ->toHaveKey('key2', 'value2'); }); ================================================ FILE: backend/tests/Unit/WorkspaceUsageTest.php ================================================ create(); $workspace = $user->primaryWorkspace(); Display::factory()->count(3)->create([ 'workspace_id' => $workspace->id, 'status' => DisplayStatus::ACTIVE, ]); $usage = $workspace->getTotalUsageCount(); expect($usage)->toBe(3); }); test('workspace calculates total usage correctly with only boards', function () { $user = User::factory()->create(); $workspace = $user->primaryWorkspace(); Board::factory()->count(2)->create([ 'workspace_id' => $workspace->id, ]); $usage = $workspace->getTotalUsageCount(); expect($usage)->toBe(4); // 2 boards * 2 = 4 }); test('workspace calculates total usage correctly with displays and boards', function () { $user = User::factory()->create(); $workspace = $user->primaryWorkspace(); Display::factory()->count(2)->create([ 'workspace_id' => $workspace->id, 'status' => DisplayStatus::ACTIVE, ]); Board::factory()->count(3)->create([ 'workspace_id' => $workspace->id, ]); $usage = $workspace->getTotalUsageCount(); expect($usage)->toBe(8); // 2 displays + (3 boards * 2) = 8 }); test('workspace usage breakdown includes all components', function () { $user = User::factory()->create(); $workspace = $user->primaryWorkspace(); Display::factory()->count(2)->create([ 'workspace_id' => $workspace->id, 'status' => DisplayStatus::ACTIVE, ]); Board::factory()->count(3)->create([ 'workspace_id' => $workspace->id, ]); $breakdown = $workspace->getUsageBreakdown(); expect($breakdown['displays'])->toBe(2); expect($breakdown['boards'])->toBe(3); expect($breakdown['board_usage'])->toBe(6); // 3 boards * 2 expect($breakdown['total'])->toBe(8); // 2 + 6 }); test('workspace usage counts all displays regardless of status', function () { $user = User::factory()->create(); $workspace = $user->primaryWorkspace(); Display::factory()->create([ 'workspace_id' => $workspace->id, 'status' => DisplayStatus::ACTIVE, ]); Display::factory()->create([ 'workspace_id' => $workspace->id, 'status' => DisplayStatus::READY, ]); Display::factory()->create([ 'workspace_id' => $workspace->id, 'status' => DisplayStatus::DEACTIVATED, ]); Board::factory()->count(1)->create([ 'workspace_id' => $workspace->id, ]); $usage = $workspace->getTotalUsageCount(); expect($usage)->toBe(5); // 3 displays + (1 board * 2) = 5 }); ================================================ FILE: backend/vite.config.js ================================================ import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true, }), tailwindcss(), ], }); ================================================ FILE: deployment/docker-compose.mariadb-redis.yml ================================================ name: spacepad-mariadb services: app: image: ghcr.io/magweter/spacepad:latest restart: unless-stopped platform: linux/amd64 ports: - "8080:8080" volumes: - storage_data:/var/www/html/storage - database_data:/var/www/html/database - .env:/var/www/html/.env:ro environment: PHP_OPCACHE_ENABLE: 1 AUTORUN_ENABLED: 'true' AUTORUN_LARAVEL_MIGRATION: 'true' depends_on: - mariadb scheduler: image: ghcr.io/magweter/spacepad:latest restart: unless-stopped command: ["php", "/var/www/html/artisan", "schedule:work"] platform: linux/amd64 volumes: - storage_data:/var/www/html/storage - database_data:/var/www/html/database - .env:/var/www/html/.env:ro environment: PHP_OPCACHE_ENABLE: 1 healthcheck: # This is our native healthcheck script for the scheduler test: ["CMD", "healthcheck-schedule"] start_period: 10s depends_on: - mariadb database: image: mariadb:lts restart: unless-stopped ports: - "3306:3306" networks: - backend volumes: - database_data:/var/lib/mysql environment: MARIADB_DATABASE: ${DB_DATABASE} MARIADB_USER: ${DB_USERNAME} MARIADB_PASSWORD: ${DB_PASSWORD} MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] start_period: 10s interval: 10s timeout: 5s retries: 3 volumes: storage_data: database_data: mariadb_data: ================================================ FILE: docker-compose.dev.yml ================================================ name: spacepad-dev services: app: image: spacepad/app:latest restart: unless-stopped build: context: ./backend dockerfile: ./Dockerfile networks: - app volumes: - ./backend:/var/www/html environment: PHP_OPCACHE_ENABLE: 0 AUTORUN_ENABLED: 'false' AUTORUN_LARAVEL_MIGRATION: 'false' # OpenTelemetry configuration OTEL_PHP_AUTOLOAD_ENABLED: true OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://host.docker.internal:4318} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} OTEL_RESOURCE_ATTRIBUTES: "service.name=spacepad-app,service.namespace=spacepad,service.version=${SPACEPAD_VERSION:-dev},deployment.environment=local" OTEL_SERVICE_NAME: spacepad-app OTEL_METRICS_EXPORTER: otlp OTEL_TRACES_EXPORTER: otlp OTEL_LOGS_EXPORTER: otlp OTEL_EXPERIMENTAL_METRIC_ENABLE: true ports: - "8000:8080" extra_hosts: - "host.docker.internal:host-gateway" scheduler: image: spacepad/app:latest restart: unless-stopped command: php artisan schedule:work networks: - app volumes: - ./backend:/var/www/html extra_hosts: - "host.docker.internal:host-gateway" environment: # OpenTelemetry configuration OTEL_PHP_AUTOLOAD_ENABLED: true OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://host.docker.internal:4318} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} OTEL_RESOURCE_ATTRIBUTES: "service.name=spacepad-scheduler,service.namespace=spacepad,service.version=${SPACEPAD_VERSION:-dev},deployment.environment=local" OTEL_SERVICE_NAME: spacepad-scheduler OTEL_METRICS_EXPORTER: otlp OTEL_TRACES_EXPORTER: otlp OTEL_LOGS_EXPORTER: otlp OTEL_EXPERIMENTAL_METRIC_ENABLE: true mariadb: image: mariadb:lts restart: unless-stopped networks: - app environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: spacepad MYSQL_USER: spacepad MYSQL_PASSWORD: spacepad volumes: - mariadb_data:/var/lib/mysql ports: - "3306:3306" redis: image: redis:alpine restart: unless-stopped networks: - app ports: - "6379:6379" volumes: - redis_data:/data mailhog: image: mailhog/mailhog:latest restart: unless-stopped networks: - app ports: - "1025:1025" # SMTP server - "8025:8025" # Web interface # k6 Load Generator (Continuous Traffic) k6-load: image: grafana/k6:latest volumes: - ./k6:/scripts command: run /scripts/load-test.js environment: - BACKEND_URL=http://app:8080 networks: - app depends_on: - app restart: unless-stopped networks: app: volumes: mariadb_data: redis_data: ================================================ FILE: docker-compose.prod.yml ================================================ name: spacepad services: traefik: image: traefik:latest restart: always command: - "--api.insecure=false" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.web.http.redirections.entryPoint.to=websecure" - "--entrypoints.web.http.redirections.entrypoint.scheme=https" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.mytlsresolver.acme.tlschallenge=true" - "--certificatesresolvers.mytlsresolver.acme.email=${ACME_EMAIL}" - "--certificatesresolvers.mytlsresolver.acme.storage=/letsencrypt/acme.json" ports: - "80:80" - "443:443" platform: linux/amd64 volumes: - ./traefik/letsencrypt:/letsencrypt - /var/run/docker.sock:/var/run/docker.sock:ro networks: - traefik labels: - "traefik.enable=true" app: image: ghcr.io/magweter/spacepad:latest restart: unless-stopped platform: linux/amd64 ports: - "8080:8080" networks: - traefik volumes: - storage_data:/var/www/html/storage - .env:/var/www/html/.env:ro environment: PHP_OPCACHE_ENABLE: 1 AUTORUN_ENABLED: 'true' AUTORUN_LARAVEL_MIGRATION: 'true' labels: - "traefik.enable=true" - "traefik.http.routers.spacepad.rule=Host(`${DOMAIN}`)" - "traefik.http.routers.spacepad.entrypoints=websecure" - "traefik.http.routers.spacepad.tls.certresolver=mytlsresolver" - "traefik.http.services.spacepad.loadbalancer.server.port=8080" scheduler: image: ghcr.io/magweter/spacepad:latest restart: unless-stopped command: ["php", "/var/www/html/artisan", "schedule:work"] platform: linux/amd64 volumes: - storage_data:/var/www/html/storage - .env:/var/www/html/.env:ro environment: PHP_OPCACHE_ENABLE: 1 healthcheck: # This is our native healthcheck script for the scheduler test: ["CMD", "healthcheck-schedule"] start_period: 10s networks: traefik: name: traefik driver: bridge volumes: storage_data: database_data: ================================================ FILE: docker-compose.yml ================================================ name: spacepad services: app: image: ghcr.io/magweter/spacepad:latest restart: unless-stopped platform: linux/amd64 ports: - "8080:8080" volumes: - storage_data:/var/www/html/storage - .env:/var/www/html/.env:ro environment: PHP_OPCACHE_ENABLE: 1 AUTORUN_ENABLED: 'true' AUTORUN_LARAVEL_MIGRATION: 'true' scheduler: image: ghcr.io/magweter/spacepad:latest restart: unless-stopped command: ["php", "/var/www/html/artisan", "schedule:work"] platform: linux/amd64 volumes: - storage_data:/var/www/html/storage - .env:/var/www/html/.env:ro environment: PHP_OPCACHE_ENABLE: 1 healthcheck: # This is our native healthcheck script for the scheduler test: ["CMD", "healthcheck-schedule"] start_period: 10s volumes: storage_data: database_data: ================================================ FILE: docs/REVERSE_PROXY.md ================================================ # Using Spacepad with Nginx and Apache This guide explains how to configure Spacepad behind Nginx or Apache reverse proxies. By default, Spacepad runs on port `8080` inside the container and can be accessed directly, but using a reverse proxy provides benefits like SSL termination, better performance, and easier domain management. ## Prerequisites - Spacepad container running (using `docker compose up -d` or `docker compose -f docker-compose.yml up -d`) - Nginx or Apache installed on your host system - Domain name pointing to your server (for SSL certificates) - Basic understanding of reverse proxy configuration ## General Configuration Notes - **Container Port**: Spacepad listens on port `8080` inside the container - **Proxy Protocol**: The application trusts all proxies, so it will correctly handle forwarded headers - **Health Check**: The application exposes a health endpoint at `/health` - **Static Files**: Laravel handles static assets through the application, so all requests should be proxied ## Nginx Configuration ### Basic HTTP Configuration Create or edit your Nginx configuration file (typically `/etc/nginx/sites-available/spacepad`): ```nginx server { listen 80; server_name your-domain.com; # Increase body size limit for file uploads client_max_body_size 100M; location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; # Headers for proper proxying proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; # WebSocket support (if needed) proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # Timeouts proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; # Buffering proxy_buffering off; proxy_request_buffering off; } } ``` ### HTTPS Configuration with Let's Encrypt For production use, you should enable HTTPS. Here's a complete configuration using Let's Encrypt: ```nginx # HTTP server - redirect to HTTPS server { listen 80; listen [::]:80; server_name your-domain.com; # Let's Encrypt challenge location /.well-known/acme-challenge/ { root /var/www/html; } # Redirect all other traffic to HTTPS location / { return 301 https://$server_name$request_uri; } } # HTTPS server server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name your-domain.com; # SSL certificates (adjust paths based on your certbot setup) ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; # SSL configuration ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; # Security headers add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; # Increase body size limit for file uploads client_max_body_size 100M; location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; # Headers for proper proxying proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; # WebSocket support (if needed) proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # Timeouts proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; # Buffering proxy_buffering off; proxy_request_buffering off; } # Health check endpoint location /health { proxy_pass http://127.0.0.1:8080/health; access_log off; } } ``` ### Enabling the Configuration 1. Create a symbolic link to enable the site: ```bash sudo ln -s /etc/nginx/sites-available/spacepad /etc/nginx/sites-enabled/ ``` 2. Test the configuration: ```bash sudo nginx -t ``` 3. Reload Nginx: ```bash sudo systemctl reload nginx ``` ### Obtaining SSL Certificates with Certbot If you haven't already obtained SSL certificates: ```bash # Install certbot sudo apt-get update sudo apt-get install certbot python3-certbot-nginx # Obtain certificate (Nginx will automatically configure SSL) sudo certbot --nginx -d your-domain.com # Test automatic renewal sudo certbot renew --dry-run ``` ## Apache Configuration ### Enable Required Modules First, enable the necessary Apache modules: ```bash sudo a2enmod proxy sudo a2enmod proxy_http sudo a2enmod headers sudo a2enmod ssl sudo a2enmod rewrite ``` ### Basic HTTP Configuration Create or edit your Apache virtual host configuration (typically `/etc/apache2/sites-available/spacepad.conf`): ```apache ServerName your-domain.com # Increase body size limit for file uploads LimitRequestBody 104857600 ProxyPreserveHost On ProxyRequests Off # Proxy all requests to Spacepad container ProxyPass / http://127.0.0.1:8080/ ProxyPassReverse / http://127.0.0.1:8080/ # Headers for proper proxying RequestHeader set X-Forwarded-Proto "http" RequestHeader set X-Forwarded-Port "80" # Logging ErrorLog ${APACHE_LOG_DIR}/spacepad_error.log CustomLog ${APACHE_LOG_DIR}/spacepad_access.log combined ``` ### HTTPS Configuration with Let's Encrypt For production use with HTTPS: ```apache # HTTP server - redirect to HTTPS ServerName your-domain.com # Let's Encrypt challenge ProxyPass ! Alias /.well-known/acme-challenge/ /var/www/html/.well-known/acme-challenge/ # Redirect all other traffic to HTTPS RewriteEngine On RewriteCond %{HTTPS} off RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] # HTTPS server ServerName your-domain.com # SSL configuration SSLEngine on SSLCertificateFile /etc/letsencrypt/live/your-domain.com/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/your-domain.com/privkey.pem # SSL protocol and cipher configuration SSLProtocol all -SSLv2 -SSLv3 SSLCipherSuite HIGH:!aNULL:!MD5 SSLHonorCipherOrder on # Security headers Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" Header always set X-Frame-Options "SAMEORIGIN" Header always set X-Content-Type-Options "nosniff" Header always set X-XSS-Protection "1; mode=block" # Increase body size limit for file uploads LimitRequestBody 104857600 ProxyPreserveHost On ProxyRequests Off # Proxy all requests to Spacepad container ProxyPass / http://127.0.0.1:8080/ ProxyPassReverse / http://127.0.0.1:8080/ # Headers for proper proxying RequestHeader set X-Forwarded-Proto "https" RequestHeader set X-Forwarded-Port "443" # WebSocket support (if needed) RewriteEngine on RewriteCond %{HTTP:Upgrade} websocket [NC] RewriteCond %{HTTP:Connection} upgrade [NC] RewriteRule ^/?(.*) "ws://127.0.0.1:8080/$1" [P,L] # Logging ErrorLog ${APACHE_LOG_DIR}/spacepad_ssl_error.log CustomLog ${APACHE_LOG_DIR}/spacepad_ssl_access.log combined ``` ### Enabling the Configuration 1. Enable the site: ```bash sudo a2ensite spacepad.conf ``` 2. Disable the default site (if needed): ```bash sudo a2dissite 000-default.conf ``` 3. Test the configuration: ```bash sudo apache2ctl configtest ``` 4. Reload Apache: ```bash sudo systemctl reload apache2 ``` ### Obtaining SSL Certificates with Certbot If you haven't already obtained SSL certificates: ```bash # Install certbot sudo apt-get update sudo apt-get install certbot python3-certbot-apache # Obtain certificate (Apache will automatically configure SSL) sudo certbot --apache -d your-domain.com # Test automatic renewal sudo certbot renew --dry-run ``` ## Docker Compose Configuration When using a reverse proxy, you typically don't need to expose port 8080 to the host. However, if your reverse proxy is running on the same host (not in Docker), you'll need to keep the port mapping. ### Option 1: Reverse Proxy on Host (Recommended) Keep the port mapping in `docker-compose.yml`: ```yaml services: app: image: ghcr.io/magweter/spacepad:latest restart: unless-stopped platform: linux/amd64 ports: - "127.0.0.1:8080:8080" # Only bind to localhost volumes: - storage_data:/var/www/html/storage - .env:/var/www/html/.env:ro # ... rest of configuration ``` Binding to `127.0.0.1:8080` ensures the port is only accessible from the localhost, which is more secure. ### Option 2: Reverse Proxy in Docker Network If your reverse proxy is also running in Docker, you can use a shared network: ```yaml name: spacepad services: app: image: ghcr.io/magweter/spacepad:latest restart: unless-stopped platform: linux/amd64 networks: - proxy_network volumes: - storage_data:/var/www/html/storage - .env:/var/www/html/.env:ro # ... rest of configuration networks: proxy_network: external: true ``` Then configure your reverse proxy to use the service name `app` instead of `127.0.0.1:8080`. ## Troubleshooting ### Connection Refused - **Check container is running**: `docker compose ps` - **Verify port mapping**: `docker compose port app 8080` - **Check firewall**: Ensure port 80/443 are open, but 8080 can be restricted to localhost ### 502 Bad Gateway - **Check container logs**: `docker compose logs app` - **Verify proxy_pass URL**: Ensure it matches your container's exposed port - **Check network connectivity**: Test with `curl http://127.0.0.1:8080/health` ### SSL Certificate Issues - **Verify certificate paths**: Ensure paths in configuration match actual certificate locations - **Check certificate expiration**: `sudo certbot certificates` - **Test renewal**: `sudo certbot renew --dry-run` ### Headers Not Working - **Verify proxy headers**: Ensure all `X-Forwarded-*` headers are set correctly - **Check Laravel trust proxies**: The application trusts all proxies by default, but verify your `.env` doesn't override this ### Performance Issues - **Enable caching**: Consider adding caching headers for static assets - **Adjust timeouts**: Increase proxy timeouts if requests are timing out - **Check container resources**: Ensure Docker has adequate CPU and memory ## Additional Security Considerations 1. **Restrict container port**: Bind port 8080 only to `127.0.0.1` instead of `0.0.0.0` 2. **Rate limiting**: Consider adding rate limiting in your reverse proxy configuration 3. **IP whitelisting**: If needed, restrict access by IP in your reverse proxy 4. **Fail2ban**: Consider setting up Fail2ban to protect against brute force attacks 5. **Regular updates**: Keep your reverse proxy and SSL certificates up to date ## Testing Your Configuration After configuring your reverse proxy: 1. **Test HTTP redirect** (if using HTTPS): ```bash curl -I http://your-domain.com ``` 2. **Test HTTPS connection**: ```bash curl -I https://your-domain.com ``` 3. **Test health endpoint**: ```bash curl https://your-domain.com/health ``` 4. **Verify headers**: ```bash curl -I https://your-domain.com | grep -i "strict-transport" ``` ## Next Steps Once your reverse proxy is configured: 1. Update your `.env` file with the correct `APP_URL`: ```env APP_URL=https://your-domain.com ``` 2. Restart your Spacepad containers: ```bash docker compose restart ``` 3. Test the application in your browser and verify all functionality works correctly 4. Set up monitoring and logging for your reverse proxy to track usage and troubleshoot issues ================================================ FILE: docs/SETUP.md ================================================ # Setting up your self-hosted Spacepad To self host this application, you can deploy your own instance using Docker and Traefik out of the box. Using other reverse proxies will also work, but might require a bit more configuration. Get started setting up your own self hosted (production) instance: ```bash # Clone the repository git clone https://github.com/magweter/spacepad.git cd spacepad # Create the environment config cp .env.example .env ``` Set the app key for the application: ```bash # Linux sed -i "s/^APP_KEY=.*/APP_KEY=base64:$(openssl rand -base64 32)/" .env # macOS sed -i '' "s/^APP_KEY=.*/APP_KEY=base64:$(openssl rand -base64 32)/" .env # Windows (PowerShell) $appKey = "base64:" + [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 })) (Get-Content .env) -replace '^APP_KEY=.*', "APP_KEY=$appKey" | Set-Content .env ``` Now open the .env file and configure your domain and email. Edit the DOMAIN and ACME_EMAIL variables: ```env DOMAIN="mypublicdomain.com" ACME_EMAIL="your-email@example.com" ``` > [!NOTE] > When using Microsoft as integration, you are not able to use http due to security limitations. So your server is required to use https and be publicly available. You can log into the app using three different methods; Email, Microsoft (OAuth) or Google (OAuth). In order to use the regular email login you should configure an email provider, as it sends a 'magic link' by email. Edit the following variables: ```env MAIL_MAILER=smtp MAIL_HOST= MAIL_PORT=587 MAIL_USERNAME= MAIL_PASSWORD= MAIL_FROM_ADDRESS="hello@example.com" ``` Configuring the following providers is optional, but you do require at least one. Leaving the client id of the provider empty will ensure it is not enabled. Configuring the Outlook provider: 1. Go to [Azure Portal - App Registrations](https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade/quickStartType~/null/sourceType/Microsoft_AAD_IAM) > [!NOTE] > Please ensure you have selected 'multi-tenant' for your app. Using the single tenant configuration is not yet supported. 1. Click on 'New registration', add a name for the applicaton e.g. "Spacepad" and click 'register' 1. You will be taken to the Overview Page, record the "Application (client) ID" as this is the "AZURE_AD_CLIENT_ID=" 1. Click on the 'Authentication' tab and create two new 'web' platforms: - https://your-domain.com/outlook-accounts/callback - https://your-domain.com/auth/microsoft/callback 1. Save, and click on 'API-permissions' 1. Click 'Microsoft Graph', click 'Delegated permissions' and search for and select the following permissions `Calendars.Read.Shared`, `Place.Read.All` and `User.Read`. > [!NOTE] > If you want users to be able to write events back to their calendar (e.g., when booking rooms directly from the tablet display), you also need to add the `Calendars.ReadWrite.Shared` permission. This allows the application to create and modify calendar events on behalf of users. 1. Save, and click on 'certificates and secrets' 1. Create a new secret (not certificate) and copy the value 1. Click on 'overview' and copy the 'client id'. Beware: this is the client ID value you need, not the ID of the secret you just created. 1. Paste the values in the .env 'AZURE_AD...' variables Configuring the Google provider: 1. Go to [Google Cloud Console](https://console.cloud.google.com/) 1. Create a new project or select an existing one 1. Navigate to "APIs & Services" > "Credentials" 1. Click "Create Credentials" > "OAuth client ID" 1. Select "Web application" as the application type 1. Add authorized redirect URIs: - https://your-domain.com/google-accounts/callback - https://your-domain.com/auth/google/callback 1. Click "Create" 1. Enable the required Google APIs: - Go to "APIs & Services" > "Library" - Search for and enable: - Google Calendar API - Google Admin SDK API 1. Copy the Client ID and Client Secret 1. Paste the values in your .env file: - GOOGLE_CLIENT_ID=your_client_id - GOOGLE_CLIENT_SECRET=your_client_secret Now you can choose to run the application with or without built-in proxy using Docker Compose. To run the application with Traefik as a proxy: ```bash docker compose -f docker-compose.prod.yml up -d ``` To run the application standalone (e.g. to use your own proxy): ```bash docker compose up -d ``` > [!TIP] > If you're using Nginx or Apache as your reverse proxy, see the [Reverse Proxy Guide](REVERSE_PROXY.md) for complete configuration instructions. Great! You should now be able to access the application at http://localhost or without proxy at http://localhost:8080. Download the mobile app from the App Store or Play Store and follow the instructions 🚀 > **Email login security** > > If you want to disable email login (for example, to prevent spam or abuse of the email login form), you can set the following environment variable in your `.env` file: > > ```env > DISABLE_EMAIL_LOGIN=true > ``` > > When this is set to `true`, users will not be able to log in or register using email. Only OAuth (Microsoft/Google) will be available. > **Restricting login to specific domains or emails** > > To restrict who can log in or register, set the `ALLOWED_LOGINS` environment variable in your `.env` file. This can be a comma-separated list of allowed email addresses and/or domains. For example: > > ```env > ALLOWED_LOGINS=yourcompany.com,anothercompany.com,admin@special.com > ``` > > - To allow all users from a domain, add the domain (e.g. `yourcompany.com`). > - To allow a specific email, add the full email address (e.g. `admin@special.com`). > - Leave empty to allow all users. ================================================ FILE: docs/UPGRADE_GUIDE.md ================================================ # Upgrade Guide ## Database Tables Missing Due to incorrect mounting of a volume in pre v1.3.0 docker-compose files, changes need to be made in order to upgrade to a new self hosted Spacepad version. Edit docker compose volumes section (for both app and scheduler), and change the database volume to a file path mount: ```yml volumes: - storage_data:/var/www/html/storage - ./database.sqlite:/var/www/html/storage/database.sqlite - .env:/var/www/html/.env:ro ``` Then, execute the following commands to make your database writeable for the application: ```bash sudo chmod -R 775 database.sqlite sudo chown -R 33:33 database.sqlite ``` After having made these changes, execute the following commands: ```bash docker compose down docker compose up -d ``` The migrations should now be able to be updated by the image, thus errors regarding the missing of database tables should be fixed. ================================================ FILE: k6/README.md ================================================ # k6 Load Testing for Spacepad k6 script for generating realistic traffic to test the Spacepad application and demonstrate the observability stack. ## Script ### `load-test.js` A unified script that can run in two modes: **Load Test Mode (default)**: Variable load with different stages (ramp up, steady state, ramp down). Good for testing under different load conditions. **Continuous Mode**: Steady, continuous traffic (2 requests/second) indefinitely. Perfect for demonstrating observability in action. ## Usage ### Load Test Mode (Default) ```bash # Run with default settings k6 run k6/load-test.js # Or with custom backend URL BACKEND_URL=http://localhost:8000 k6 run k6/load-test.js # Via docker docker run --rm -i --network spacepad-dev_app \ -v $(pwd)/k6:/scripts \ -e BACKEND_URL=http://app:8080 \ -e CONNECT_CODE=100001 \ grafana/k6 run /scripts/load-test.js ``` ### Continuous Mode ```bash # Set CONTINUOUS=true to enable continuous mode CONTINUOUS=true BACKEND_URL=http://localhost:8000 k6 run k6/load-test.js # Via docker docker run --rm -i --network spacepad-dev_app \ -v $(pwd)/k6:/scripts \ -e BACKEND_URL=http://app:8080 \ -e CONNECT_CODE=100001 \ -e CONTINUOUS=true \ grafana/k6 run /scripts/load-test.js ``` ## Configuration The script can be configured via environment variables: - `BACKEND_URL`: Backend API URL (default: `http://localhost:8000`) - `CONNECT_CODE`: Connect code for authentication (default: `100001`) - `CONTINUOUS`: Set to `true` or `1` to enable continuous mode (default: `false`) ## Authentication The script automatically authenticates using the connect code system: 1. Each Virtual User (VU) authenticates once during setup using `/api/auth/login` with connect code `100001` 2. The authentication token is stored and reused for all API requests 3. The scripts fetch available displays and use the first display for testing 4. Includes retry logic (up to 3 attempts) for robust authentication 5. Automatic recovery if authentication is lost during execution ## Traffic Patterns The script simulates realistic user behavior: - **Display Data API** (`/api/displays/{display}/data`): 70% of requests - Most frequently used endpoint - Requires authentication token - Returns display calendar data and events - **Dashboard Page** (`/`): 30% of requests - Web dashboard page - Simulates user browsing Each request includes: - Proper authentication headers (for API endpoints) - Random think time (simulates user reading/thinking) - Proper headers (User-Agent, Accept) - OpenTelemetry trace correlation ## Load Test Mode Stages When running in load test mode (default), the script follows these stages: - Ramp up to 5 users (30s) - Ramp up to 10 users (2m) - Stay at 10 users (5m) - Ramp up to 20 users (2m) - Stay at 20 users (5m) - Ramp down to 10 users (2m) - Stay at 10 users (5m) ## Continuous Mode When running in continuous mode (`CONTINUOUS=true`): - Generates 2 requests per second - Runs for 24 hours (effectively continuous) - Pre-allocates 5 VUs, scales up to 20 VUs if needed - Lower think time (0.5-2s) for higher throughput ## Observing Traffic With k6 running, you can observe: 1. **Grafana** (http://localhost:3000): - Traces in Tempo showing request flows - Metrics in Prometheus showing request rates, latencies - Service maps showing service dependencies - Logs in Loki showing application logs 2. **Prometheus** (http://localhost:9090): - Query: `rate(http_requests_total[1m])` - Query: `histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))` 3. **Tempo** (via Grafana): - Search for traces from `spacepad-app` - View trace details and spans - Filter by route: `/api/displays/{display}/data` or `/` 4. **Loki** (via Grafana): - Search for logs from the application - View log entries with trace context ## Troubleshooting ### Authentication Failures - Ensure connect code `100001` exists and is valid - Check that the user associated with the connect code has displays configured - Verify the `BACKEND_URL` is correct - The script includes retry logic, but check logs for persistent failures ### No Displays Available - The script will skip API calls if no displays are found - Ensure the authenticated user has at least one display configured - Check display status (should be READY or ACTIVE) ### High Error Rates - Check application logs for errors - Verify database connectivity - Ensure all required services are running - Check network connectivity between k6 and the backend ### VUs Failing Authentication - The script retries authentication up to 3 times - Check backend logs for authentication errors - Verify the connect code is valid and not expired - Ensure the backend is accessible from k6 ## Customization Edit the script to: - Change request rates (modify `rate` in continuous mode or `stages` in load test mode) - Modify the endpoint distribution (currently 70% API, 30% dashboard) - Add more endpoints - Modify user behavior patterns - Add custom metrics - Change load patterns ================================================ FILE: k6/load-test.js ================================================ /** * k6 Load Test Script for Spacepad * * Tests the dashboard page and the /api/displays/{display}/data endpoint * Uses connect code 100001 for authentication * * Can run in two modes: * - Load test mode (default): Variable load with stages * - Continuous mode: Steady continuous load (set CONTINUOUS=true) */ import http from 'k6/http'; import { check, sleep } from 'k6'; import { Rate, Trend, Counter } from 'k6/metrics'; // Custom metrics const errorRate = new Rate('errors'); const requestDuration = new Trend('request_duration'); const requestsCounter = new Counter('requests_total'); // Configuration const CONTINUOUS_MODE = __ENV.CONTINUOUS === 'true' || __ENV.CONTINUOUS === '1'; const BASE_URL = __ENV.BACKEND_URL || 'http://localhost:8000'; const CONNECT_CODE = __ENV.CONNECT_CODE || '100001'; // Different execution patterns based on mode export const options = CONTINUOUS_MODE ? { scenarios: { continuous_load: { executor: 'constant-arrival-rate', rate: 2, // 2 requests per second timeUnit: '1s', duration: '24h', // Run for 24 hours (effectively continuous) preAllocatedVUs: 5, // Pre-allocate 5 VUs maxVUs: 20, // Max 20 VUs if needed }, }, thresholds: { http_req_duration: ['p(95)<1000'], http_req_failed: ['rate<0.1'], }, } : { stages: [ { duration: '30s', target: 5 }, // Ramp up to 5 users { duration: '2m', target: 10 }, // Ramp up to 10 users { duration: '5m', target: 10 }, // Stay at 10 users { duration: '2m', target: 20 }, // Ramp up to 20 users { duration: '5m', target: 20 }, // Stay at 20 users { duration: '2m', target: 10 }, // Ramp down to 10 users { duration: '5m', target: 10 }, // Stay at 10 users ], thresholds: { http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95% of requests under 500ms, 99% under 1s http_req_failed: ['rate<0.05'], // Less than 5% errors errors: ['rate<0.05'], }, }; // Shared state for VU - stores auth token and display ID let authToken = null; let displayId = null; // Setup function - runs once per VU to authenticate export function setup() { if (!CONTINUOUS_MODE) { console.log(`Starting k6 load test against ${BASE_URL}`); } // Authenticate once per VU with retry logic const deviceUid = `k6-device-${__VU}-${Date.now()}`; const deviceName = CONTINUOUS_MODE ? `k6-continuous-${__VU}` : `k6-load-test-${__VU}`; let token = null; let displayIdToUse = null; // Retry authentication up to 3 times for (let attempt = 1; attempt <= 3; attempt++) { const loginResponse = http.post( `${BASE_URL}/api/auth/login`, JSON.stringify({ code: CONNECT_CODE, uid: deviceUid, name: deviceName, }), { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, tags: { endpoint: '/api/auth/login', test_type: 'setup', }, timeout: '10s', } ); const loginSuccess = check(loginResponse, { 'login status is 200': (r) => r.status === 200, 'login has token': (r) => { try { const body = JSON.parse(r.body); return body.data && body.data.token !== undefined; } catch { return false; } }, }); if (loginSuccess) { try { const loginBody = JSON.parse(loginResponse.body); token = loginBody.data.token; if (!CONTINUOUS_MODE) { console.log(`VU ${__VU} authenticated successfully on attempt ${attempt}`); } break; } catch (e) { console.error(`VU ${__VU} failed to parse login response on attempt ${attempt}: ${e}`); } } else { console.error(`VU ${__VU} authentication failed on attempt ${attempt}: ${loginResponse.status} - ${loginResponse.body}`); if (attempt < 3) { sleep(1); // Wait before retry } } } if (!token) { console.error(`VU ${__VU} failed to authenticate after 3 attempts`); return { baseUrl: BASE_URL, token: null, displayId: null }; } // Get displays list to find a display ID with retry for (let attempt = 1; attempt <= 3; attempt++) { const displaysResponse = http.get( `${BASE_URL}/api/displays`, { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, tags: { endpoint: '/api/displays', test_type: 'setup', }, timeout: '10s', } ); const displaysSuccess = check(displaysResponse, { 'displays status is 200': (r) => r.status === 200, 'displays has data': (r) => { try { const body = JSON.parse(r.body); return body.data && Array.isArray(body.data) && body.data.length > 0; } catch { return false; } }, }); if (displaysSuccess) { try { const displaysBody = JSON.parse(displaysResponse.body); if (displaysBody.data && displaysBody.data.length > 0) { displayIdToUse = displaysBody.data[0].id; if (!CONTINUOUS_MODE) { console.log(`VU ${__VU} found display ID: ${displayIdToUse}`); } break; } else { console.warn(`VU ${__VU} authenticated but no displays available`); } } catch (e) { console.error(`VU ${__VU} failed to parse displays response on attempt ${attempt}: ${e}`); } } else { console.error(`VU ${__VU} failed to get displays on attempt ${attempt}: ${displaysResponse.status} - ${displaysResponse.body}`); if (attempt < 3) { sleep(1); // Wait before retry } } } return { baseUrl: BASE_URL, token: token, displayId: displayIdToUse, }; } // Main test function export default function (data) { // Use token and displayId from setup authToken = data.token; displayId = data.displayId; if (!authToken) { // If no token, try to re-authenticate (might be a transient issue) const deviceUid = `k6-device-${__VU}-${Date.now()}`; const deviceName = CONTINUOUS_MODE ? `k6-continuous-${__VU}` : `k6-load-test-${__VU}`; const loginResponse = http.post( `${BASE_URL}/api/auth/login`, JSON.stringify({ code: CONNECT_CODE, uid: deviceUid, name: deviceName, }), { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, tags: { endpoint: '/api/auth/login', test_type: 'recovery', }, timeout: '10s', } ); if (loginResponse.status === 200) { try { const loginBody = JSON.parse(loginResponse.body); authToken = loginBody.data.token; data.token = authToken; // Update data for future iterations if (!CONTINUOUS_MODE) { console.log(`VU ${__VU} recovered authentication`); } } catch (e) { console.error(`VU ${__VU} failed to parse recovery login response: ${e}`); } } if (!authToken) { console.error(`VU ${__VU} has no auth token, skipping iteration`); sleep(1); return; } } // Weighted endpoint selection // 70% of requests go to the display data endpoint (most used) // 30% go to dashboard page const useDisplayData = Math.random() < 0.7; let url, params, endpoint; if (useDisplayData && displayId) { // Call the display data endpoint endpoint = `/api/displays/${displayId}/data`; url = `${BASE_URL}${endpoint}`; params = { headers: { 'Authorization': `Bearer ${authToken}`, 'Accept': 'application/json', 'User-Agent': CONTINUOUS_MODE ? `k6-continuous/${__VU}` : `k6-load-test/${__VU}`, }, tags: { endpoint: endpoint, test_type: 'api', load_type: CONTINUOUS_MODE ? 'continuous' : 'load_test', }, }; } else { // Call the dashboard page endpoint = '/'; url = `${BASE_URL}${endpoint}`; params = { headers: { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'User-Agent': CONTINUOUS_MODE ? `k6-continuous/${__VU}` : `k6-load-test/${__VU}`, }, tags: { endpoint: endpoint, test_type: 'web', load_type: CONTINUOUS_MODE ? 'continuous' : 'load_test', }, }; } const startTime = Date.now(); const response = http.get(url, params); const duration = Date.now() - startTime; requestsCounter.add(1, { endpoint: endpoint }); requestDuration.add(duration, { endpoint: endpoint }); const success = check(response, { 'status is 200 or 302': (r) => r.status === 200 || r.status === 302, 'response time < 2000ms': (r) => r.timings.duration < 2000, }); // For API endpoints, also check for valid JSON if (useDisplayData && displayId) { const jsonCheck = check(response, { 'has valid JSON': (r) => { try { JSON.parse(r.body); return true; } catch { return false; } }, 'has display data': (r) => { try { const body = JSON.parse(r.body); return body.data !== undefined; } catch { return false; } }, }); errorRate.add(!success || !jsonCheck); } else { errorRate.add(!success); } // Simulate user think time // Continuous mode: 0.5-2 seconds, Load test mode: 1-3 seconds const thinkTime = CONTINUOUS_MODE ? Math.random() * 1.5 + 0.5 : Math.random() * 2 + 1; sleep(thinkTime); } // Teardown function - runs once after all VUs finish export function teardown(data) { const mode = CONTINUOUS_MODE ? 'continuous load test' : 'load test'; console.log(`${mode} completed for ${data.baseUrl}`); if (data.displayId) { console.log(`Tested display ID: ${data.displayId}`); } } ================================================ FILE: k6/tags.js ================================================ /** * k6 StatsD Tags Script * Adds custom tags to metrics for better observability */ export function tags(data) { return { test_type: 'continuous_load', service: 'spacepad-app', }; }