From 965fb992e9663018259de67faeaaed70a6694882 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Tue, 30 Jun 2026 23:35:29 +0000 Subject: [PATCH] Initial Ladill Meet release. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 0–18: core meetings, webinar, breakouts, team chat, live streaming, town hall, billing, and Ladill Mail calendar wiring. Co-authored-by: Cursor --- .editorconfig | 18 + .env.example | 72 + .gitattributes | 11 + .gitea/workflows/deploy.yml | 110 + .gitignore | 31 + DEPLOY.md | 86 + README.md | 55 + app/Console/Commands/SendMeetingReminders.php | 34 + .../Printers/PrinterDriverInterface.php | 13 + app/Events/ServiceEventOccurred.php | 22 + .../Api/ServiceEventController.php | 35 + .../Controllers/Api/V1/RoomController.php | 83 + .../Api/V1/ServiceRoomController.php | 96 + .../Controllers/Auth/SsoLoginController.php | 293 + app/Http/Controllers/Controller.php | 10 + app/Http/Controllers/Meet/AdminController.php | 35 + .../Controllers/Meet/CalendarController.php | 115 + .../Controllers/Meet/ChannelController.php | 130 + .../Meet/Concerns/ScopesToAccount.php | 59 + .../Meet/ContactGroupController.php | 59 + .../Controllers/Meet/DashboardController.php | 74 + .../Meet/DirectMessageController.php | 94 + .../Controllers/Meet/InvitationController.php | 142 + app/Http/Controllers/Meet/JoinController.php | 123 + .../Meet/MeetingFeaturesController.php | 314 + .../Meet/MeetingRoomController.php | 286 + .../Controllers/Meet/MemberController.php | 98 + .../Controllers/Meet/OnboardingController.php | 65 + .../Controllers/Meet/RecordingController.php | 66 + .../Controllers/Meet/ReportController.php | 114 + app/Http/Controllers/Meet/RoomController.php | 256 + .../Controllers/Meet/SettingsController.php | 58 + .../Controllers/Meet/SummaryController.php | 23 + .../Controllers/Meet/TemplateController.php | 68 + .../Meet/WebhookSettingsController.php | 64 + .../Meet/WebinarRegistrationController.php | 88 + .../Controllers/NotificationController.php | 64 + app/Http/Middleware/AuthenticateService.php | 38 + app/Http/Middleware/EnsureMeetAbility.php | 34 + .../Middleware/EnsureOrganizationSetup.php | 33 + app/Http/Middleware/EnsurePlatformSession.php | 73 + app/Http/Middleware/SetActingAccount.php | 29 + app/Jobs/GenerateAiSummary.php | 27 + app/Jobs/ProcessRecording.php | 27 + .../PlatformServiceEventListener.php | 85 + app/Mail/ContactMessageMail.php | 40 + app/Models/ActionItem.php | 39 + app/Models/AiSummary.php | 55 + app/Models/AuditLog.php | 56 + app/Models/Branch.php | 29 + app/Models/BreakoutAssignment.php | 23 + app/Models/BreakoutRoom.php | 49 + app/Models/CalendarConnection.php | 49 + app/Models/Channel.php | 54 + app/Models/ChannelMember.php | 18 + app/Models/ChannelMessage.php | 49 + app/Models/Concerns/BelongsToOwner.php | 13 + app/Models/ContactGroup.php | 44 + app/Models/ContactGroupMember.php | 20 + app/Models/DirectConversation.php | 47 + app/Models/DirectMessage.php | 44 + app/Models/DirectParticipant.php | 18 + app/Models/FileDownload.php | 27 + app/Models/Invitation.php | 56 + app/Models/LiveStream.php | 61 + app/Models/MeetPoll.php | 49 + app/Models/Member.php | 31 + app/Models/Message.php | 39 + app/Models/Organization.php | 49 + app/Models/Participant.php | 81 + app/Models/PollVote.php | 18 + app/Models/QaQuestion.php | 39 + app/Models/Reaction.php | 23 + app/Models/Recording.php | 54 + app/Models/Room.php | 118 + app/Models/Session.php | 123 + app/Models/SessionFile.php | 60 + app/Models/Template.php | 50 + app/Models/Transcript.php | 50 + app/Models/UsageRecord.php | 31 + app/Models/User.php | 41 + app/Models/WebhookEndpoint.php | 51 + app/Models/WebinarRegistration.php | 52 + app/Models/Whiteboard.php | 48 + .../AiSummaryReadyNotification.php | 32 + .../MeetingInvitationNotification.php | 34 + .../MeetingReminderNotification.php | 32 + .../RecordingReadyNotification.php | 32 + app/Providers/AppServiceProvider.php | 28 + app/Services/Billing/BillingClient.php | 64 + .../Billing/OneTimePurchaseService.php | 76 + app/Services/Comms/EmailService.php | 32 + app/Services/Comms/SmsService.php | 67 + app/Services/CrossApp/CrossAppLinkService.php | 112 + app/Services/Events/ServiceEventSignature.php | 23 + .../Integrations/WebhookDispatcher.php | 114 + app/Services/Meet/AttendanceService.php | 46 + app/Services/Meet/AuditLogger.php | 28 + app/Services/Meet/AvailabilityService.php | 49 + app/Services/Meet/BreakoutService.php | 106 + .../Meet/Calendar/GoogleCalendarProvider.php | 108 + .../Meet/Calendar/MailCalendarProvider.php | 88 + .../Calendar/MicrosoftCalendarProvider.php | 106 + app/Services/Meet/CalendarService.php | 138 + app/Services/Meet/ChannelService.php | 100 + app/Services/Meet/ChatService.php | 55 + app/Services/Meet/ContactService.php | 105 + app/Services/Meet/DirectMessageService.php | 61 + app/Services/Meet/FileSharingService.php | 104 + app/Services/Meet/IcalService.php | 44 + app/Services/Meet/InvitationService.php | 120 + app/Services/Meet/LicenseService.php | 67 + app/Services/Meet/LiveStreamService.php | 91 + app/Services/Meet/Media/LiveKitProvider.php | 77 + .../Meet/Media/MediaProviderInterface.php | 27 + app/Services/Meet/MeetAiService.php | 146 + app/Services/Meet/MeetNotificationService.php | 142 + app/Services/Meet/MeetPermissions.php | 44 + app/Services/Meet/OrganizationResolver.php | 112 + app/Services/Meet/PollService.php | 60 + app/Services/Meet/QaService.php | 68 + app/Services/Meet/RecordingService.php | 124 + app/Services/Meet/RecurringMeetingService.php | 93 + app/Services/Meet/ReportService.php | 247 + app/Services/Meet/RoomService.php | 114 + app/Services/Meet/SecurityPolicyService.php | 78 + app/Services/Meet/SessionService.php | 213 + app/Services/Meet/TemplateService.php | 42 + app/Services/Meet/TownHallService.php | 110 + app/Services/Meet/TranscriptService.php | 71 + app/Services/Meet/UsageMeteringService.php | 119 + app/Services/Meet/WebinarService.php | 72 + app/Services/Meet/WhiteboardService.php | 66 + app/Services/Meet/WorkspaceSearchService.php | 56 + app/Support/LadillAppUrl.php | 15 + app/Support/MobileTopbar.php | 19 + app/Support/OrganizationBranding.php | 50 + app/Support/UserProfileMenu.php | 163 + app/Support/helpers.php | 55 + artisan | 18 + bootstrap/app.php | 72 + bootstrap/cache/.gitignore | 2 + bootstrap/providers.php | 7 + composer.json | 94 + composer.lock | 8858 +++++++++++++++++ config/app.php | 132 + config/arkesel.php | 13 + config/auth.php | 117 + config/billing.php | 9 + config/cache.php | 117 + config/database.php | 204 + config/filesystems.php | 87 + config/identity.php | 7 + config/ladill.php | 6 + config/ladill_launcher.php | 42 + config/logging.php | 132 + config/mail.php | 132 + config/meet.php | 205 + config/mobile-topbar.php | 5 + config/queue.php | 129 + config/service_events.php | 9 + config/services.php | 44 + config/session.php | 217 + config/signed_out.php | 7 + database/.gitignore | 1 + .../0001_01_01_000000_create_users_table.php | 52 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 57 + ...6_23_120000_create_notifications_table.php | 25 + ...6_06_30_100000_create_meet_core_tables.php | 75 + ...6_30_110000_create_meet_meeting_tables.php | 90 + ...20000_create_meet_collaboration_tables.php | 43 + ...0_create_meet_recordings_and_ai_tables.php | 122 + ...140000_create_meet_integrations_tables.php | 85 + ...ebinar_breakout_file_whiteboard_tables.php | 157 + ...et_workspaces_streaming_billing_tables.php | 138 + deploy/bin/ladill-hosting-admin | 126 + deploy/bin/ladill-hosting-user | 21 + deploy/deploy.sh | 301 + deploy/sudoers.ladill-hosting.example | 7 + deployment/setup-service-subdomain-nginx.sh | 153 + deployment/sudoers.ladill-care-deploy.example | 3 + deployment/supervisor/ladill-care-worker.conf | 14 + docs/openapi/meet.yaml | 79 + package-lock.json | 2564 +++++ package.json | 26 + phpunit.xml | 38 + public/.htaccess | 25 + public/favicon.ico | Bin 0 -> 270398 bytes public/images/ladill-icons/bird.svg | 20 + public/images/ladill-icons/domains.svg | 20 + public/images/ladill-icons/events.svg | 2 + public/images/ladill-icons/pro.svg | 3 + public/images/ladill-icons/qrplus.svg | 25 + public/images/ladill-icons/server.svg | 1 + public/images/ladill-icons/wordpress.svg | 23 + public/images/launcher-icons/accounting.svg | 25 + public/images/launcher-icons/bird.svg | 20 + public/images/launcher-icons/care.svg | 25 + public/images/launcher-icons/crm.svg | 35 + public/images/launcher-icons/domains.svg | 20 + public/images/launcher-icons/email.svg | 25 + public/images/launcher-icons/events.svg | 21 + public/images/launcher-icons/frontdesk.svg | 25 + public/images/launcher-icons/give.svg | 20 + public/images/launcher-icons/hosting.svg | 40 + public/images/launcher-icons/invoice.svg | 25 + public/images/launcher-icons/link.svg | 25 + public/images/launcher-icons/mail.svg | 25 + public/images/launcher-icons/meet.svg | 25 + public/images/launcher-icons/merchant.svg | 25 + public/images/launcher-icons/mini.svg | 25 + public/images/launcher-icons/pos.svg | 30 + public/images/launcher-icons/qrplus.svg | 25 + public/images/launcher-icons/servers.svg | 35 + public/images/launcher-icons/sms.svg | 25 + public/images/launcher-icons/transfer.svg | 25 + public/images/logo/ladillcare-logo.svg | 43 + public/images/logo/ladillcrm-logo.svg | 52 + .../images/logo/ladilldomains-logo-white.svg | 34 + public/images/logo/ladilldomains-logo.svg | 45 + public/images/logo/ladillemail-logo.svg | 48 + public/images/logo/ladillevents-logo.svg | 41 + public/images/logo/ladillfrontdesk-logo.svg | 50 + public/images/logo/ladillhosting-logo.svg | 61 + public/images/logo/ladillinvoice-logo.svg | 46 + public/images/logo/ladillmeet-logo.svg | 43 + public/images/logo/ladillmini-logo-email.png | Bin 0 -> 3302 bytes public/images/logo/ladillmini-logo.svg | 43 + .../images/logo/ladillqrplus-logo-white.svg | 27 + public/images/logo/ladillqrplus-logo.svg | 42 + public/images/qr-icons/app.svg | 11 + public/images/qr-icons/business-profile.svg | 14 + public/images/qr-icons/facebook.svg | 22 + public/images/qr-icons/instagram.svg | 24 + public/images/qr-icons/linkedin.svg | 24 + public/images/qr-icons/list.svg | 8 + public/images/qr-icons/terms.svg | 4 + public/images/qr-icons/tik-tok.svg | 20 + public/images/qr-icons/twitter.svg | 5 + public/images/qr-icons/website.svg | 12 + public/images/qr-icons/whatsapp.svg | 24 + public/images/qr-icons/wifi.svg | 6 + public/images/qr-icons/youtube.svg | 22 + public/index.php | 20 + public/robots.txt | 2 + resources/css/app.css | 223 + resources/js/app.js | 172 + resources/js/meet-room.js | 354 + resources/views/auth/signed-out.blade.php | 52 + resources/views/auth/sso-error.blade.php | 30 + .../views/auth/sso-logout-bridge.blade.php | 26 + .../views/components/app-layout.blade.php | 60 + resources/views/components/badge.blade.php | 15 + .../views/components/btn/create.blade.php | 33 + resources/views/components/field.blade.php | 37 + resources/views/components/modal.blade.php | 88 + .../views/email/contact-message.blade.php | 18 + .../views/meet/admin/members/create.blade.php | 38 + .../views/meet/admin/members/index.blade.php | 31 + resources/views/meet/admin/usage.blade.php | 24 + .../views/meet/channels/create.blade.php | 24 + resources/views/meet/channels/index.blade.php | 27 + resources/views/meet/channels/show.blade.php | 38 + .../views/meet/contacts/groups.blade.php | 30 + resources/views/meet/dashboard.blade.php | 110 + .../views/meet/invitations/index.blade.php | 59 + .../meet/invitations/rsvp-done.blade.php | 19 + .../views/meet/invitations/rsvp.blade.php | 24 + resources/views/meet/join/cancelled.blade.php | 16 + resources/views/meet/join/denied.blade.php | 17 + resources/views/meet/join/passcode.blade.php | 27 + resources/views/meet/join/show.blade.php | 40 + resources/views/meet/messages/index.blade.php | 34 + resources/views/meet/messages/show.blade.php | 21 + .../views/meet/onboarding/show.blade.php | 62 + .../views/meet/partials/room-card.blade.php | 38 + .../views/meet/recordings/index.blade.php | 29 + .../views/meet/recordings/show.blade.php | 27 + resources/views/meet/reports/index.blade.php | 12 + resources/views/meet/reports/show.blade.php | 90 + resources/views/meet/room/show.blade.php | 181 + resources/views/meet/rooms/create.blade.php | 101 + resources/views/meet/rooms/index.blade.php | 16 + resources/views/meet/rooms/show.blade.php | 123 + .../views/meet/settings/calendar.blade.php | 48 + .../views/meet/settings/security.blade.php | 23 + .../views/meet/settings/webhooks.blade.php | 39 + resources/views/meet/summaries/show.blade.php | 41 + .../views/meet/templates/create.blade.php | 21 + .../views/meet/templates/index.blade.php | 17 + .../meet/webinar/register-done.blade.php | 15 + .../views/meet/webinar/register.blade.php | 22 + .../meet/webinar/registrations.blade.php | 30 + resources/views/notifications/index.blade.php | 24 + .../views/partials/afia-button.blade.php | 15 + .../views/partials/boot-splash.blade.php | 43 + resources/views/partials/favicon.blade.php | 3 + resources/views/partials/flash.blade.php | 34 + resources/views/partials/launcher.blade.php | 84 + .../partials/mobile-bottom-nav.blade.php | 130 + .../partials/mobile-header-btn.blade.php | 53 + .../views/partials/mobile-icon-link.blade.php | 27 + .../partials/mobile-topbar-title.blade.php | 6 + .../partials/notification-dropdown.blade.php | 97 + resources/views/partials/sidebar.blade.php | 72 + .../topbar-account-switcher.blade.php | 24 + .../partials/topbar-desktop-widgets.blade.php | 45 + resources/views/partials/topbar.blade.php | 34 + .../partials/user-profile-menu.blade.php | 73 + .../partials/wallet-topup-modal.blade.php | 24 + .../views/partials/wallet-widget.blade.php | 30 + routes/api.php | 23 + routes/console.php | 5 + routes/web.php | 175 + tests/Feature/MeetWebTest.php | 343 + tests/TestCase.php | 14 + tests/Unit/ExampleTest.php | 16 + vite.config.js | 18 + 319 files changed, 30322 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 DEPLOY.md create mode 100644 README.md create mode 100644 app/Console/Commands/SendMeetingReminders.php create mode 100644 app/Contracts/Printers/PrinterDriverInterface.php create mode 100644 app/Events/ServiceEventOccurred.php create mode 100644 app/Http/Controllers/Api/ServiceEventController.php create mode 100644 app/Http/Controllers/Api/V1/RoomController.php create mode 100644 app/Http/Controllers/Api/V1/ServiceRoomController.php create mode 100644 app/Http/Controllers/Auth/SsoLoginController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/Meet/AdminController.php create mode 100644 app/Http/Controllers/Meet/CalendarController.php create mode 100644 app/Http/Controllers/Meet/ChannelController.php create mode 100644 app/Http/Controllers/Meet/Concerns/ScopesToAccount.php create mode 100644 app/Http/Controllers/Meet/ContactGroupController.php create mode 100644 app/Http/Controllers/Meet/DashboardController.php create mode 100644 app/Http/Controllers/Meet/DirectMessageController.php create mode 100644 app/Http/Controllers/Meet/InvitationController.php create mode 100644 app/Http/Controllers/Meet/JoinController.php create mode 100644 app/Http/Controllers/Meet/MeetingFeaturesController.php create mode 100644 app/Http/Controllers/Meet/MeetingRoomController.php create mode 100644 app/Http/Controllers/Meet/MemberController.php create mode 100644 app/Http/Controllers/Meet/OnboardingController.php create mode 100644 app/Http/Controllers/Meet/RecordingController.php create mode 100644 app/Http/Controllers/Meet/ReportController.php create mode 100644 app/Http/Controllers/Meet/RoomController.php create mode 100644 app/Http/Controllers/Meet/SettingsController.php create mode 100644 app/Http/Controllers/Meet/SummaryController.php create mode 100644 app/Http/Controllers/Meet/TemplateController.php create mode 100644 app/Http/Controllers/Meet/WebhookSettingsController.php create mode 100644 app/Http/Controllers/Meet/WebinarRegistrationController.php create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 app/Http/Middleware/AuthenticateService.php create mode 100644 app/Http/Middleware/EnsureMeetAbility.php create mode 100644 app/Http/Middleware/EnsureOrganizationSetup.php create mode 100644 app/Http/Middleware/EnsurePlatformSession.php create mode 100644 app/Http/Middleware/SetActingAccount.php create mode 100644 app/Jobs/GenerateAiSummary.php create mode 100644 app/Jobs/ProcessRecording.php create mode 100644 app/Listeners/PlatformServiceEventListener.php create mode 100644 app/Mail/ContactMessageMail.php create mode 100644 app/Models/ActionItem.php create mode 100644 app/Models/AiSummary.php create mode 100644 app/Models/AuditLog.php create mode 100644 app/Models/Branch.php create mode 100644 app/Models/BreakoutAssignment.php create mode 100644 app/Models/BreakoutRoom.php create mode 100644 app/Models/CalendarConnection.php create mode 100644 app/Models/Channel.php create mode 100644 app/Models/ChannelMember.php create mode 100644 app/Models/ChannelMessage.php create mode 100644 app/Models/Concerns/BelongsToOwner.php create mode 100644 app/Models/ContactGroup.php create mode 100644 app/Models/ContactGroupMember.php create mode 100644 app/Models/DirectConversation.php create mode 100644 app/Models/DirectMessage.php create mode 100644 app/Models/DirectParticipant.php create mode 100644 app/Models/FileDownload.php create mode 100644 app/Models/Invitation.php create mode 100644 app/Models/LiveStream.php create mode 100644 app/Models/MeetPoll.php create mode 100644 app/Models/Member.php create mode 100644 app/Models/Message.php create mode 100644 app/Models/Organization.php create mode 100644 app/Models/Participant.php create mode 100644 app/Models/PollVote.php create mode 100644 app/Models/QaQuestion.php create mode 100644 app/Models/Reaction.php create mode 100644 app/Models/Recording.php create mode 100644 app/Models/Room.php create mode 100644 app/Models/Session.php create mode 100644 app/Models/SessionFile.php create mode 100644 app/Models/Template.php create mode 100644 app/Models/Transcript.php create mode 100644 app/Models/UsageRecord.php create mode 100644 app/Models/User.php create mode 100644 app/Models/WebhookEndpoint.php create mode 100644 app/Models/WebinarRegistration.php create mode 100644 app/Models/Whiteboard.php create mode 100644 app/Notifications/AiSummaryReadyNotification.php create mode 100644 app/Notifications/MeetingInvitationNotification.php create mode 100644 app/Notifications/MeetingReminderNotification.php create mode 100644 app/Notifications/RecordingReadyNotification.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Services/Billing/BillingClient.php create mode 100644 app/Services/Billing/OneTimePurchaseService.php create mode 100644 app/Services/Comms/EmailService.php create mode 100644 app/Services/Comms/SmsService.php create mode 100644 app/Services/CrossApp/CrossAppLinkService.php create mode 100644 app/Services/Events/ServiceEventSignature.php create mode 100644 app/Services/Integrations/WebhookDispatcher.php create mode 100644 app/Services/Meet/AttendanceService.php create mode 100644 app/Services/Meet/AuditLogger.php create mode 100644 app/Services/Meet/AvailabilityService.php create mode 100644 app/Services/Meet/BreakoutService.php create mode 100644 app/Services/Meet/Calendar/GoogleCalendarProvider.php create mode 100644 app/Services/Meet/Calendar/MailCalendarProvider.php create mode 100644 app/Services/Meet/Calendar/MicrosoftCalendarProvider.php create mode 100644 app/Services/Meet/CalendarService.php create mode 100644 app/Services/Meet/ChannelService.php create mode 100644 app/Services/Meet/ChatService.php create mode 100644 app/Services/Meet/ContactService.php create mode 100644 app/Services/Meet/DirectMessageService.php create mode 100644 app/Services/Meet/FileSharingService.php create mode 100644 app/Services/Meet/IcalService.php create mode 100644 app/Services/Meet/InvitationService.php create mode 100644 app/Services/Meet/LicenseService.php create mode 100644 app/Services/Meet/LiveStreamService.php create mode 100644 app/Services/Meet/Media/LiveKitProvider.php create mode 100644 app/Services/Meet/Media/MediaProviderInterface.php create mode 100644 app/Services/Meet/MeetAiService.php create mode 100644 app/Services/Meet/MeetNotificationService.php create mode 100644 app/Services/Meet/MeetPermissions.php create mode 100644 app/Services/Meet/OrganizationResolver.php create mode 100644 app/Services/Meet/PollService.php create mode 100644 app/Services/Meet/QaService.php create mode 100644 app/Services/Meet/RecordingService.php create mode 100644 app/Services/Meet/RecurringMeetingService.php create mode 100644 app/Services/Meet/ReportService.php create mode 100644 app/Services/Meet/RoomService.php create mode 100644 app/Services/Meet/SecurityPolicyService.php create mode 100644 app/Services/Meet/SessionService.php create mode 100644 app/Services/Meet/TemplateService.php create mode 100644 app/Services/Meet/TownHallService.php create mode 100644 app/Services/Meet/TranscriptService.php create mode 100644 app/Services/Meet/UsageMeteringService.php create mode 100644 app/Services/Meet/WebinarService.php create mode 100644 app/Services/Meet/WhiteboardService.php create mode 100644 app/Services/Meet/WorkspaceSearchService.php create mode 100644 app/Support/LadillAppUrl.php create mode 100644 app/Support/MobileTopbar.php create mode 100644 app/Support/OrganizationBranding.php create mode 100644 app/Support/UserProfileMenu.php create mode 100644 app/Support/helpers.php create mode 100755 artisan create mode 100644 bootstrap/app.php create mode 100644 bootstrap/cache/.gitignore create mode 100644 bootstrap/providers.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/app.php create mode 100644 config/arkesel.php create mode 100644 config/auth.php create mode 100644 config/billing.php create mode 100644 config/cache.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/identity.php create mode 100644 config/ladill.php create mode 100644 config/ladill_launcher.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/meet.php create mode 100644 config/mobile-topbar.php create mode 100644 config/queue.php create mode 100644 config/service_events.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 config/signed_out.php create mode 100644 database/.gitignore create mode 100644 database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/migrations/2026_06_23_120000_create_notifications_table.php create mode 100644 database/migrations/2026_06_30_100000_create_meet_core_tables.php create mode 100644 database/migrations/2026_06_30_110000_create_meet_meeting_tables.php create mode 100644 database/migrations/2026_06_30_120000_create_meet_collaboration_tables.php create mode 100644 database/migrations/2026_06_30_130000_create_meet_recordings_and_ai_tables.php create mode 100644 database/migrations/2026_06_30_140000_create_meet_integrations_tables.php create mode 100644 database/migrations/2026_06_30_150000_create_meet_webinar_breakout_file_whiteboard_tables.php create mode 100644 database/migrations/2026_06_30_160000_create_meet_workspaces_streaming_billing_tables.php create mode 100755 deploy/bin/ladill-hosting-admin create mode 100755 deploy/bin/ladill-hosting-user create mode 100755 deploy/deploy.sh create mode 100644 deploy/sudoers.ladill-hosting.example create mode 100755 deployment/setup-service-subdomain-nginx.sh create mode 100644 deployment/sudoers.ladill-care-deploy.example create mode 100644 deployment/supervisor/ladill-care-worker.conf create mode 100644 docs/openapi/meet.yaml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 phpunit.xml create mode 100644 public/.htaccess create mode 100644 public/favicon.ico create mode 100644 public/images/ladill-icons/bird.svg create mode 100644 public/images/ladill-icons/domains.svg create mode 100644 public/images/ladill-icons/events.svg create mode 100644 public/images/ladill-icons/pro.svg create mode 100644 public/images/ladill-icons/qrplus.svg create mode 100644 public/images/ladill-icons/server.svg create mode 100644 public/images/ladill-icons/wordpress.svg create mode 100644 public/images/launcher-icons/accounting.svg create mode 100644 public/images/launcher-icons/bird.svg create mode 100644 public/images/launcher-icons/care.svg create mode 100644 public/images/launcher-icons/crm.svg create mode 100644 public/images/launcher-icons/domains.svg create mode 100644 public/images/launcher-icons/email.svg create mode 100644 public/images/launcher-icons/events.svg create mode 100644 public/images/launcher-icons/frontdesk.svg create mode 100644 public/images/launcher-icons/give.svg create mode 100644 public/images/launcher-icons/hosting.svg create mode 100644 public/images/launcher-icons/invoice.svg create mode 100644 public/images/launcher-icons/link.svg create mode 100644 public/images/launcher-icons/mail.svg create mode 100644 public/images/launcher-icons/meet.svg create mode 100644 public/images/launcher-icons/merchant.svg create mode 100644 public/images/launcher-icons/mini.svg create mode 100644 public/images/launcher-icons/pos.svg create mode 100644 public/images/launcher-icons/qrplus.svg create mode 100644 public/images/launcher-icons/servers.svg create mode 100644 public/images/launcher-icons/sms.svg create mode 100644 public/images/launcher-icons/transfer.svg create mode 100644 public/images/logo/ladillcare-logo.svg create mode 100644 public/images/logo/ladillcrm-logo.svg create mode 100644 public/images/logo/ladilldomains-logo-white.svg create mode 100644 public/images/logo/ladilldomains-logo.svg create mode 100644 public/images/logo/ladillemail-logo.svg create mode 100644 public/images/logo/ladillevents-logo.svg create mode 100644 public/images/logo/ladillfrontdesk-logo.svg create mode 100644 public/images/logo/ladillhosting-logo.svg create mode 100644 public/images/logo/ladillinvoice-logo.svg create mode 100644 public/images/logo/ladillmeet-logo.svg create mode 100644 public/images/logo/ladillmini-logo-email.png create mode 100644 public/images/logo/ladillmini-logo.svg create mode 100644 public/images/logo/ladillqrplus-logo-white.svg create mode 100644 public/images/logo/ladillqrplus-logo.svg create mode 100644 public/images/qr-icons/app.svg create mode 100644 public/images/qr-icons/business-profile.svg create mode 100644 public/images/qr-icons/facebook.svg create mode 100644 public/images/qr-icons/instagram.svg create mode 100644 public/images/qr-icons/linkedin.svg create mode 100644 public/images/qr-icons/list.svg create mode 100644 public/images/qr-icons/terms.svg create mode 100644 public/images/qr-icons/tik-tok.svg create mode 100644 public/images/qr-icons/twitter.svg create mode 100644 public/images/qr-icons/website.svg create mode 100644 public/images/qr-icons/whatsapp.svg create mode 100644 public/images/qr-icons/wifi.svg create mode 100644 public/images/qr-icons/youtube.svg create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 resources/css/app.css create mode 100644 resources/js/app.js create mode 100644 resources/js/meet-room.js create mode 100644 resources/views/auth/signed-out.blade.php create mode 100644 resources/views/auth/sso-error.blade.php create mode 100644 resources/views/auth/sso-logout-bridge.blade.php create mode 100644 resources/views/components/app-layout.blade.php create mode 100644 resources/views/components/badge.blade.php create mode 100644 resources/views/components/btn/create.blade.php create mode 100644 resources/views/components/field.blade.php create mode 100644 resources/views/components/modal.blade.php create mode 100644 resources/views/email/contact-message.blade.php create mode 100644 resources/views/meet/admin/members/create.blade.php create mode 100644 resources/views/meet/admin/members/index.blade.php create mode 100644 resources/views/meet/admin/usage.blade.php create mode 100644 resources/views/meet/channels/create.blade.php create mode 100644 resources/views/meet/channels/index.blade.php create mode 100644 resources/views/meet/channels/show.blade.php create mode 100644 resources/views/meet/contacts/groups.blade.php create mode 100644 resources/views/meet/dashboard.blade.php create mode 100644 resources/views/meet/invitations/index.blade.php create mode 100644 resources/views/meet/invitations/rsvp-done.blade.php create mode 100644 resources/views/meet/invitations/rsvp.blade.php create mode 100644 resources/views/meet/join/cancelled.blade.php create mode 100644 resources/views/meet/join/denied.blade.php create mode 100644 resources/views/meet/join/passcode.blade.php create mode 100644 resources/views/meet/join/show.blade.php create mode 100644 resources/views/meet/messages/index.blade.php create mode 100644 resources/views/meet/messages/show.blade.php create mode 100644 resources/views/meet/onboarding/show.blade.php create mode 100644 resources/views/meet/partials/room-card.blade.php create mode 100644 resources/views/meet/recordings/index.blade.php create mode 100644 resources/views/meet/recordings/show.blade.php create mode 100644 resources/views/meet/reports/index.blade.php create mode 100644 resources/views/meet/reports/show.blade.php create mode 100644 resources/views/meet/room/show.blade.php create mode 100644 resources/views/meet/rooms/create.blade.php create mode 100644 resources/views/meet/rooms/index.blade.php create mode 100644 resources/views/meet/rooms/show.blade.php create mode 100644 resources/views/meet/settings/calendar.blade.php create mode 100644 resources/views/meet/settings/security.blade.php create mode 100644 resources/views/meet/settings/webhooks.blade.php create mode 100644 resources/views/meet/summaries/show.blade.php create mode 100644 resources/views/meet/templates/create.blade.php create mode 100644 resources/views/meet/templates/index.blade.php create mode 100644 resources/views/meet/webinar/register-done.blade.php create mode 100644 resources/views/meet/webinar/register.blade.php create mode 100644 resources/views/meet/webinar/registrations.blade.php create mode 100644 resources/views/notifications/index.blade.php create mode 100644 resources/views/partials/afia-button.blade.php create mode 100644 resources/views/partials/boot-splash.blade.php create mode 100644 resources/views/partials/favicon.blade.php create mode 100644 resources/views/partials/flash.blade.php create mode 100644 resources/views/partials/launcher.blade.php create mode 100644 resources/views/partials/mobile-bottom-nav.blade.php create mode 100644 resources/views/partials/mobile-header-btn.blade.php create mode 100644 resources/views/partials/mobile-icon-link.blade.php create mode 100644 resources/views/partials/mobile-topbar-title.blade.php create mode 100644 resources/views/partials/notification-dropdown.blade.php create mode 100644 resources/views/partials/sidebar.blade.php create mode 100644 resources/views/partials/topbar-account-switcher.blade.php create mode 100644 resources/views/partials/topbar-desktop-widgets.blade.php create mode 100644 resources/views/partials/topbar.blade.php create mode 100644 resources/views/partials/user-profile-menu.blade.php create mode 100644 resources/views/partials/wallet-topup-modal.blade.php create mode 100644 resources/views/partials/wallet-widget.blade.php create mode 100644 routes/api.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100644 tests/Feature/MeetWebTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ExampleTest.php create mode 100644 vite.config.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +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 + +[compose.yaml] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f515fb3 --- /dev/null +++ b/.env.example @@ -0,0 +1,72 @@ +APP_NAME="Ladill Meet" +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=https://meet.ladill.com + +PLATFORM_URL=https://ladill.com +PLATFORM_DOMAIN=ladill.com + +LOG_CHANNEL=stack +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +DB_DATABASE=database/database.sqlite +# Production: DB_CONNECTION=mysql, DB_HOST=127.0.0.1, DB_DATABASE=ladill_meet, DB_USERNAME=ladill_meet + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +SESSION_DRIVER=redis +SESSION_LIFETIME=1440 +CACHE_STORE=redis +QUEUE_CONNECTION=database + +# --- Ladill SSO (Sign in with Ladill — auth.ladill.com OIDC client) --- +LADILL_SSO_CLIENT_ID= +LADILL_SSO_CLIENT_SECRET= + +# --- Platform APIs this app consumes (per-consumer service keys) --- +BILLING_API_URL=https://ladill.com/api/billing +BILLING_API_KEY_MEET= +IDENTITY_API_URL=https://ladill.com/api +IDENTITY_API_KEY_MEET= + +# Platform events webhook (user/org lifecycle from the monolith) +SERVICE_EVENTS_INBOUND_SECRET= + +# --- LiveKit (WebRTC SFU) --- +LIVEKIT_URL=wss://your-livekit-server.example.com +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= + +# --- Recordings & AI --- +MEET_RECORDINGS_DISK=local +MEET_AI_DRIVER=openai +MEET_AI_API_KEY= +MEET_AI_MODEL=gpt-4o-mini +MEET_AI_PLATFORM_URL= +MEET_AI_PLATFORM_KEY= + +# --- Inbound service API keys (sibling Ladill apps calling Meet) --- +MEET_API_KEY_CARE= +MEET_API_KEY_CRM= +MEET_API_KEY_BOOKINGS= +MEET_API_KEY_EVENTS= +MEET_API_KEY_FRONTDESK= +MEET_API_KEY_HR= +MEET_API_KEY_SUPPORT= + +# --- Calendar OAuth (optional) --- +GOOGLE_CALENDAR_CLIENT_ID= +GOOGLE_CALENDAR_CLIENT_SECRET= +GOOGLE_CALENDAR_REDIRECT_URI=https://meet.ladill.com/settings/calendar/callback +MICROSOFT_CALENDAR_CLIENT_ID= +MICROSOFT_CALENDAR_CLIENT_SECRET= +MICROSOFT_CALENDAR_REDIRECT_URI=https://meet.ladill.com/settings/calendar/callback +MEET_FILES_DISK=local +MEET_FILES_EXPIRY_DAYS=30 +LADILL_DRIVE_API_URL= +LADILL_DRIVE_API_KEY_MEET= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* 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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..9c3c745 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,110 @@ +name: Deploy Ladill Meet + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-meet + cancel-in-progress: true + +jobs: + deploy: + runs-on: deploy + env: + NODE_ROOT: /tmp/ladill-node-22-r1 + NODE_VERSION: "22.14.0" + RELEASE_ARCHIVE: /tmp/ladill-meet-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz + WORKSPACE: /tmp/${{ gitea.repository_owner }}-meet-${{ gitea.run_id }}-${{ gitea.run_attempt }} + LADILL_APP_ROOT: /var/www/ladill-meet + steps: + - name: Checkout + shell: bash {0} + run: | + set -Eeuo pipefail + DEPLOY_GITEA_TOKEN="$(cat /home/deploy/.ladill-deploy-gitea-token)" + REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git" + rm -rf "$WORKSPACE" + mkdir -p "$WORKSPACE" + export GIT_TERMINAL_PROMPT=0 + git \ + -c credential.helper= \ + -c http.extraHeader="Authorization: token ${DEPLOY_GITEA_TOKEN}" \ + clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \ + "$REPO_URL" "$WORKSPACE" + + - name: Setup Node.js + shell: bash {0} + run: | + set -Eeuo pipefail + if [ ! -x "$NODE_ROOT/bin/node" ]; then + rm -rf "$NODE_ROOT" + mkdir -p "$NODE_ROOT" + case "$(uname -m)" in + x86_64|amd64) NODE_ARCH="x64" ;; + aarch64|arm64) NODE_ARCH="arm64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \ + | tar -xJ --strip-components=1 -C "$NODE_ROOT" + fi + "$NODE_ROOT/bin/node" -v + + - name: Build frontend assets + shell: bash {0} + run: | + set -Eeuo pipefail + cd "$WORKSPACE" + export PATH="$NODE_ROOT/bin:$PATH" + npm ci --no-audit --no-fund + npm run build + + - name: Build release archive + shell: bash {0} + run: | + set -Eeuo pipefail + cd "$WORKSPACE" + printf '%s\n' "${{ gitea.sha }}" > REVISION + rm -f "$RELEASE_ARCHIVE" + tar -czf "$RELEASE_ARCHIVE" \ + --exclude=.git --exclude=.gitea --exclude=.github --exclude=.env \ + --exclude=node_modules --exclude=vendor --exclude=storage --exclude=tests . + + - name: Deploy release + shell: bash {0} + env: + LADILL_RELEASE_ARCHIVE: /tmp/ladill-meet-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz + run: | + set -Eeuo pipefail + : "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}" + bash "$WORKSPACE/deploy/deploy.sh" + + - name: Ensure nginx vhost + shell: bash {0} + run: | + set -Eeuo pipefail + NGINX_SCRIPT="$WORKSPACE/deployment/setup-service-subdomain-nginx.sh" + if [ ! -f "$NGINX_SCRIPT" ]; then + NGINX_SCRIPT="/var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh" + fi + if [ ! -f "$NGINX_SCRIPT" ]; then + echo "WARN: setup-service-subdomain-nginx.sh not found — configure meet.ladill.com vhost manually" + exit 0 + fi + if sudo -n bash "$NGINX_SCRIPT" meet --app /var/www/ladill-meet/current; then + echo "nginx vhost updated for meet.ladill.com" + else + echo "WARN: nginx vhost step skipped (deploy user cannot sudo) — vhost must already be provisioned" + fi + + - name: Cleanup + if: always() + shell: bash {0} + run: | + rm -rf "$WORKSPACE" + rm -f "$RELEASE_ARCHIVE" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd47ce8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/storage/framework/views/* +!/storage/framework/views/.gitignore +/vendor +Homestead.json +Homestead.yaml +Thumbs.db + +# Native mobile apps — kept locally, not tracked in this repo +/apps + +/database/database.sqlite diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..faf522e --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,86 @@ +# Ladill Care — deploy runbook + +Healthcare management at **care.ladill.com** (patients, appointments, consultations, lab, pharmacy, billing). + +## 1. Gitea repo + CI + +- Repo: **ladill-care** (`isaacclad/ladill-care`). +- Push to `main` triggers `.gitea/workflows/deploy.yml` (on-host `deploy` runner). +- App root: `/var/www/ladill-care`. + +To publish from the monorepo copy: + +```bash +bash scripts/extract-ladill-care.sh +``` + +## 2. Server app-slot + database + +```bash +sudo install -d -o deploy -g www-data /var/www/ladill-care +sudo install -d -o deploy -g www-data /var/www/ladill-care/{releases,shared} +sudo chown -R deploy:www-data /var/www/ladill-care +sudo mysql -e "CREATE DATABASE ladill_care CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +sudo mysql -e "CREATE USER 'ladill_care'@'127.0.0.1' IDENTIFIED BY '';" +sudo mysql -e "GRANT ALL ON ladill_care.* TO 'ladill_care'@'127.0.0.1'; FLUSH PRIVILEGES;" +``` + +## 3. Platform wiring (monolith) + +On the Ladill platform host: + +```bash +cd /var/www/ladill.com/current +php artisan ladill:onboard-app care --run-dns +php artisan passport:client --name="Ladill Care" --redirect_uri=https://care.ladill.com/sso/callback +php artisan ladill:launcher:sync --propagate +``` + +Apply the generated `IDENTITY_API_KEY_CARE`, `BILLING_API_KEY_CARE`, +`SERVICE_EVENTS_CARE_*`, and `SERVICE_EVENTS_INBOUND_SECRET` values to the monolith `.env`, then: + +```bash +php artisan config:cache +``` + +## 4. Shared `.env` (`/var/www/ladill-care/shared/.env`) + +Copy `.env.example`, set production values: + +| Variable | Notes | +|----------|-------| +| `APP_KEY` | `php artisan key:generate --show` | +| `APP_URL` | `https://care.ladill.com` | +| `DB_*` | MySQL `ladill_care` credentials | +| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | Passport client (plain text from `passport:client`) | +| `BILLING_API_KEY_CARE` | Same as monolith consumer key | +| `IDENTITY_API_KEY_CARE` | Same as monolith consumer key | +| `SERVICE_EVENTS_INBOUND_SECRET` | Same as monolith `SERVICE_EVENTS_CARE_SECRET` | + +**SSO secret:** use the plain-text value printed once by `passport:client` — never copy +`oauth_clients.secret` from the database (Passport stores a bcrypt hash there). + +## 5. nginx + TLS + +```bash +sudo bash deployment/setup-service-subdomain-nginx.sh care --app /var/www/ladill-care/current +``` + +DNS: `care.ladill.com` → app server (via `dns:sync-subdomains` on the monolith). + +## 6. First deploy + +Push to `main` or run the Gitea deploy workflow manually. Then verify: + +```bash +curl -sI https://care.ladill.com | head -1 +curl -s -o /dev/null -w '%{http_code}\n' https://care.ladill.com/login +curl -s https://care.ladill.com/api/health +``` + +## 7. Optional queue worker + +```bash +sudo cp deployment/supervisor/ladill-care-worker.conf /etc/supervisor/conf.d/ +sudo supervisorctl reread && sudo supervisorctl update +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..99a2f82 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Ladill Care + +Healthcare management for clinics and hospitals at **care.ladill.com** — patients, +appointments, queue, consultations, vitals, lab, prescriptions, pharmacy inventory, +encounter billing, and operational reports. + +Authenticates via **Sign in with Ladill** (OIDC against `auth.ladill.com`). Every +clinical record is scoped to a platform account (`owner_ref`) and organization. + +## Features + +- **Patients** — registration, medical history, documents, visit timeline +- **Appointments & queue** — booking, walk-in, check-in, department queue +- **Consultations** — vitals, diagnoses, investigations, prescriptions +- **Laboratory** — test catalog, sample collection, results, approval +- **Pharmacy** — dispensing queue, drug inventory, batch stock tracking +- **Billing** — visit invoices, line items, payments, print view +- **Reports** — patients, appointments, lab, finance, clinical (CSV export) +- **Admin** — branches, departments, practitioners, members, audit log + +## Platform integration + +| Integration | Purpose | +|-------------|---------| +| OIDC SSO | User authentication (`LADILL_SSO_*`) | +| Billing API | Wallet top-up / purchases (`BILLING_API_KEY_CARE`) | +| Identity API | Platform user lookups (`IDENTITY_API_KEY_CARE`) | +| Service events | Inbound webhooks for `user.deleted`, `user.suspended`, `organization.updated` | + +Registry entry lives in the monolith `config/ladill_apps.php` (`care` slug). + +## Local development + +```bash +composer install +cp .env.example .env && php artisan key:generate +touch database/database.sqlite # sqlite is fine for local +php artisan migrate +npm install && npm run build # or `npm run dev` +php artisan serve +php artisan test +``` + +For SSO locally, point `LADILL_SSO_*` at a Passport client on your Ladill platform +dev instance, or bypass `EnsurePlatformSession` in tests (see `CareWebTest`). + +## Deployment + +See [DEPLOY.md](DEPLOY.md) for production cutover (`/var/www/ladill-care`, Gitea CI, +nginx vhost, platform onboarding). + +## API + +REST API under `/api/v1` (Sanctum + organization setup middleware). Health check: +`GET /api/health`. diff --git a/app/Console/Commands/SendMeetingReminders.php b/app/Console/Commands/SendMeetingReminders.php new file mode 100644 index 0000000..8af6196 --- /dev/null +++ b/app/Console/Commands/SendMeetingReminders.php @@ -0,0 +1,34 @@ +whereBetween('scheduled_at', [now(), now()->addMinutes(15)]) + ->whereNull('deleted_at') + ->get(); + + foreach ($rooms as $room) { + $host = User::where('public_id', $room->host_user_ref)->first(); + if ($host) { + $notifications->sendReminder($room, $host); + } + } + + $this->info('Sent '.$rooms->count().' reminder(s).'); + + return self::SUCCESS; + } +} diff --git a/app/Contracts/Printers/PrinterDriverInterface.php b/app/Contracts/Printers/PrinterDriverInterface.php new file mode 100644 index 0000000..4463082 --- /dev/null +++ b/app/Contracts/Printers/PrinterDriverInterface.php @@ -0,0 +1,13 @@ + $data + */ + public function __construct( + public readonly string $name, + public readonly array $data = [], + ) {} +} diff --git a/app/Http/Controllers/Api/ServiceEventController.php b/app/Http/Controllers/Api/ServiceEventController.php new file mode 100644 index 0000000..3324f9d --- /dev/null +++ b/app/Http/Controllers/Api/ServiceEventController.php @@ -0,0 +1,35 @@ +header('X-Ladill-Signature', ''); + + if (! ServiceEventSignature::verify($request->getContent(), $signature, $secret)) { + return response()->json(['error' => 'invalid signature'], 401); + } + + $eventId = (string) $request->header('X-Ladill-Event-Id', (string) $request->input('id', '')); + if ($eventId !== '') { + if (Cache::has("svcevt:{$eventId}")) { + return response()->json(['status' => 'duplicate']); + } + Cache::put("svcevt:{$eventId}", true, now()->addDay()); + } + + event(new ServiceEventOccurred((string) $request->input('event'), (array) $request->input('data', []))); + + return response()->json(['status' => 'accepted']); + } +} diff --git a/app/Http/Controllers/Api/V1/RoomController.php b/app/Http/Controllers/Api/V1/RoomController.php new file mode 100644 index 0000000..b7a6c1a --- /dev/null +++ b/app/Http/Controllers/Api/V1/RoomController.php @@ -0,0 +1,83 @@ +authorizeAbility($request, 'meetings.view'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $query = Room::owned($owner)->where('organization_id', $organization->id); + $this->scopeToBranch($request, $query); + + $rooms = $query->orderByDesc('scheduled_at')->paginate(20); + + return response()->json($rooms); + } + + public function store(Request $request): JsonResponse + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'scheduled_at' => ['nullable', 'date'], + 'passcode' => ['nullable', 'string', 'max:20'], + 'settings' => ['nullable', 'array'], + 'source' => ['nullable', 'array'], + ]); + + $room = empty($validated['scheduled_at']) + ? $this->rooms->createInstant($request->user(), $organization, $validated) + : $this->rooms->createScheduled($request->user(), $organization, $validated); + + return response()->json([ + 'room' => $room, + 'join_url' => $room->joinUrl(), + ], 201); + } + + public function show(Request $request, Room $room): JsonResponse + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $room); + + return response()->json([ + 'room' => $room->load('sessions'), + 'join_url' => $room->joinUrl(), + ]); + } + + public function start(Request $request, Room $room): JsonResponse + { + $this->authorizeAbility($request, 'meetings.host'); + $this->authorizeOwner($request, $room); + + $session = $this->sessions->start($room, $request->user()); + + return response()->json([ + 'session' => $session, + 'room_url' => route('meet.room', $session), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/ServiceRoomController.php b/app/Http/Controllers/Api/V1/ServiceRoomController.php new file mode 100644 index 0000000..141e63a --- /dev/null +++ b/app/Http/Controllers/Api/V1/ServiceRoomController.php @@ -0,0 +1,96 @@ +validate([ + 'owner_ref' => ['required', 'string'], + 'organization_id' => ['required', 'integer', 'exists:meet_organizations,id'], + 'host_user_ref' => ['required', 'string'], + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'scheduled_at' => ['nullable', 'date'], + 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], + 'passcode' => ['nullable', 'string', 'max:20'], + 'settings' => ['nullable', 'array'], + 'source' => ['required', 'array'], + 'source.app' => ['required', 'string'], + 'source.entity_type' => ['nullable', 'string'], + 'source.entity_id' => ['nullable', 'string'], + 'invite_emails' => ['nullable', 'array'], + 'invite_emails.*' => ['email'], + ]); + + $organization = Organization::findOrFail($validated['organization_id']); + abort_unless($organization->owner_ref === $validated['owner_ref'], 403); + + $host = User::where('public_id', $validated['host_user_ref'])->firstOrFail(); + + $room = empty($validated['scheduled_at']) + ? $this->rooms->createInstant($host, $organization, $validated) + : $this->rooms->createScheduled($host, $organization, $validated); + + if (! empty($validated['invite_emails'])) { + $invites = collect($validated['invite_emails'])->map(fn ($email) => ['email' => $email])->all(); + $this->invitations->inviteToRoom($room, $invites, $host); + } + + if ($room->scheduled_at) { + $this->calendar->syncRoomCreated($room, $host); + } + + $this->webhooks->roomCreated($room); + + return response()->json([ + 'room' => $room->fresh(), + 'join_url' => $room->joinUrl(), + 'embed_url' => route('meet.join', $room), + ], 201); + } + + public function show(Room $room): JsonResponse + { + return response()->json([ + 'room' => $room->load('sessions'), + 'join_url' => $room->joinUrl(), + 'embed_url' => route('meet.join', $room), + ]); + } + + public function start(Request $request, Room $room): JsonResponse + { + $host = User::where('public_id', $request->input('host_user_ref', $room->host_user_ref))->firstOrFail(); + abort_unless($room->host_user_ref === $host->ownerRef(), 403); + + $session = $this->sessions->start($room, $host); + + return response()->json([ + 'session' => $session, + 'room_url' => route('meet.room', $session), + 'join_url' => $room->joinUrl(), + ]); + } +} diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php new file mode 100644 index 0000000..d1f0ea7 --- /dev/null +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -0,0 +1,293 @@ +query('redirect', route('meet.dashboard')); + + if (Auth::check()) { + return $this->safeRedirect($intended, route('meet.dashboard')); + } + + if (! $request->boolean('fallback')) { + $request->session()->forget('sso.attempts'); + } + + if ($this->attemptSilentRefresh($request, $intended)) { + return $this->safeRedirect($intended, route('meet.dashboard')); + } + + $verifier = Str::random(64); + $state = Str::random(40); + $request->session()->put('sso.verifier', $verifier); + $request->session()->put('sso.state', $state); + $request->session()->put('sso.intended', $intended); + + $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + + $query = [ + 'response_type' => 'code', + 'client_id' => (string) config('services.ladill_sso.client_id'), + 'redirect_uri' => (string) config('services.ladill_sso.redirect'), + 'scope' => 'openid profile email', + 'state' => $state, + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + ]; + + $loginHint = (string) $request->session()->get('sso.login_hint', ''); + if ($loginHint !== '') { + $query['login_hint'] = $loginHint; + } + + if (! $request->boolean('interactive')) { + $query['prompt'] = 'none'; + } + + $authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query); + + return redirect()->away($authorizeUrl); + } + + public function callback(Request $request): RedirectResponse + { + $intended = (string) $request->session()->get('sso.intended', route('meet.dashboard')); + + if ($request->filled('error')) { + if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true) + && ! $request->boolean('interactive')) { + return redirect()->away((string) config('ladill.marketing_url')); + } + + return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error'))); + } + + if (! $request->filled('code') + || $request->query('state') !== $request->session()->pull('sso.state')) { + return $this->finishCallback($request, $intended, 'invalid_state'); + } + + $issuer = rtrim((string) config('services.ladill_sso.issuer'), '/'); + + $tokenRes = Http::asForm()->post($issuer.'/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => (string) config('services.ladill_sso.client_id'), + 'client_secret' => (string) config('services.ladill_sso.client_secret'), + 'redirect_uri' => (string) config('services.ladill_sso.redirect'), + 'code' => (string) $request->query('code'), + 'code_verifier' => (string) $request->session()->pull('sso.verifier'), + ]); + if ($tokenRes->failed()) { + return $this->finishCallback($request, $intended, 'token_exchange_failed'); + } + + $user = $this->loginFromTokenResponse($request, $tokenRes); + if (! $user) { + return $this->finishCallback($request, $intended, 'userinfo_failed'); + } + + Auth::login($user, remember: true); + $request->session()->regenerate(); + $request->session()->forget('sso.attempts'); + + return $this->finishCallback($request, $intended, null); + } + + public function failed(Request $request): View + { + return view('auth.sso-error', [ + 'reason' => (string) $request->session()->get('sso.error', ''), + ]); + } + + public function logout(Request $request): RedirectResponse + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + // Per-app sign-out: end only this app's session and keep the platform + // (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently. + return redirect()->route('meet.signed-out'); + } + + /** Platform session ended — clear this app and offer silent sign-in again. */ + public function platformSignedOut(Request $request): RedirectResponse + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('sso.connect', [ + 'redirect' => (string) $request->query('redirect', ''), + ]); + } + + public function logoutBridge(Request $request): View + { + $return = $this->safeReturnUrl((string) $request->query('return', '')); + $hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([ + 'embedded' => 1, + 'return' => $return, + ]); + + return view('auth.sso-logout-bridge', [ + 'hubUrl' => $hubUrl, + 'return' => $return, + 'authOrigin' => 'https://'.config('app.auth_domain'), + ]); + } + + public function frontchannelLogout(Request $request): Response|RedirectResponse + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + $return = (string) $request->query('return', ''); + $root = (string) config('app.platform_domain', 'ladill.com'); + $host = parse_url($return, PHP_URL_HOST); + if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) { + return redirect()->away($return); + } + + return response('', 204); + } + + private function attemptSilentRefresh(Request $request, string $intended): bool + { + $refreshToken = (string) $request->session()->get('sso.refresh_token', ''); + if ($refreshToken === '') { + return false; + } + + $issuer = rtrim((string) config('services.ladill_sso.issuer'), '/'); + $tokenRes = Http::asForm()->post($issuer.'/oauth/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'client_id' => (string) config('services.ladill_sso.client_id'), + 'client_secret' => (string) config('services.ladill_sso.client_secret'), + 'scope' => 'openid profile email', + ]); + + if ($tokenRes->failed()) { + $request->session()->forget('sso.refresh_token'); + + return false; + } + + $user = $this->loginFromTokenResponse($request, $tokenRes); + + if (! $user) { + return false; + } + + Auth::login($user, remember: true); + $request->session()->put('sso.intended', $intended); + + return true; + } + + private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User + { + $refreshToken = (string) $tokenRes->json('refresh_token', ''); + if ($refreshToken !== '') { + $request->session()->put('sso.refresh_token', $refreshToken); + } + + $issuer = rtrim((string) config('services.ladill_sso.issuer'), '/'); + $claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo'); + if ($claims->failed() || ! $claims->json('sub')) { + return null; + } + + $email = (string) ($claims->json('email') ?: ''); + if ($email !== '') { + $request->session()->put('sso.login_hint', $email); + } + + return User::updateOrCreate( + ['public_id' => (string) $claims->json('sub')], + [ + 'name' => $claims->json('name'), + 'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com', + 'avatar_url' => $claims->json('picture'), + ], + ); + } + + private function finishCallback(Request $request, string $intended, ?string $error = null): RedirectResponse + { + if ($error) { + $attempts = (int) $request->session()->get('sso.attempts', 0) + 1; + + if ($attempts >= self::MAX_SSO_ATTEMPTS) { + $request->session()->forget(['sso.attempts', 'sso.state', 'sso.verifier', 'sso.intended']); + $request->session()->flash('sso.error', $error); + + return redirect()->route('sso.failed'); + } + + $request->session()->put('sso.attempts', $attempts); + + return redirect()->route('sso.connect', [ + 'redirect' => $intended, + 'interactive' => 1, + 'fallback' => 1, + ]); + } + + return $this->safeRedirect($intended, route('meet.dashboard')); + } + + private function safeReturnUrl(string $url): string + { + $host = parse_url($url, PHP_URL_HOST); + $root = (string) config('app.platform_domain', 'ladill.com'); + + if (is_string($host) && str_starts_with($url, 'https://') + && ($host === $root || str_ends_with($host, '.'.$root))) { + return $url; + } + + return route('meet.signed-out'); + } + + private function safeRedirect(string $url, string $fallback): RedirectResponse + { + $host = parse_url($url, PHP_URL_HOST); + $root = (string) config('app.platform_domain', 'ladill.com'); + + if (is_string($host) && str_starts_with($url, 'https://') + && ($host === $root || str_ends_with($host, '.'.$root))) { + return redirect()->away($url); + } + + return redirect()->away($fallback); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..e7f7c94 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,10 @@ +authorizeAbility($request, 'meetings.manage'); + $organization = $this->organization($request); + $branchId = app(OrganizationResolver::class)->branchScope($this->member($request)); + + $summary = $this->metering->usageSummary($organization, $branchId); + $quotas = $this->licenses->quotas($organization); + $tier = $organization->license_tier ?? 'standard'; + + return view('meet.admin.usage', compact('organization', 'summary', 'quotas', 'tier')); + } +} diff --git a/app/Http/Controllers/Meet/CalendarController.php b/app/Http/Controllers/Meet/CalendarController.php new file mode 100644 index 0000000..c478e77 --- /dev/null +++ b/app/Http/Controllers/Meet/CalendarController.php @@ -0,0 +1,115 @@ +authorizeAbility($request, 'meetings.manage'); + $organization = $this->organization($request); + $user = $request->user(); + + return view('meet.settings.calendar', [ + 'organization' => $organization, + 'googleConnected' => (bool) $this->calendar->connectionFor($user, 'google'), + 'microsoftConnected' => (bool) $this->calendar->connectionFor($user, 'microsoft'), + 'mailConnected' => (bool) $this->calendar->connectionFor($user, 'mail'), + 'mailConfigured' => filled(config('meet.calendar.mail.api_key')), + 'googleAuthUrl' => $this->calendar->oauthRedirectUrl('google'), + 'microsoftAuthUrl' => $this->calendar->oauthRedirectUrl('microsoft'), + ]); + } + + public function connectMail(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + abort_unless(config('meet.calendar.mail.api_key'), 503, 'Ladill Mail calendar is not configured.'); + + $validated = $request->validate([ + 'mailbox_email' => ['nullable', 'email', 'max:255'], + ]); + + $this->calendar->connectMail( + $request->user(), + $this->organization($request)->id, + $validated['mailbox_email'] ?? null, + ); + + return redirect()->route('meet.settings.calendar') + ->with('success', 'Ladill Mail calendar connected.'); + } + + public function connect(Request $request, string $provider): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + abort_unless(in_array($provider, ['google', 'microsoft'], true), 404); + + $url = $this->calendar->oauthRedirectUrl($provider); + abort_unless($url, 503, 'Calendar integration is not configured.'); + + $request->session()->put('meet.calendar.provider', $provider); + + return redirect()->away($url); + } + + public function callback(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + $provider = $request->session()->pull('meet.calendar.provider', 'google'); + $code = (string) $request->query('code'); + + abort_unless($code !== '', 422, 'Authorization failed.'); + + $connection = $this->calendar->handleOAuthCallback( + $provider, + $code, + $request->user(), + $this->organization($request)->id, + ); + + return redirect()->route('meet.settings.calendar') + ->with($connection ? 'success' : 'error', $connection + ? ucfirst($provider).' Calendar connected.' + : 'Could not connect calendar.'); + } + + public function availability(Request $request): JsonResponse + { + $this->authorizeAbility($request, 'meetings.create'); + + $validated = $request->validate([ + 'scheduled_at' => ['required', 'date'], + 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], + ]); + + $start = Carbon::parse($validated['scheduled_at']); + $duration = (int) ($validated['duration_minutes'] ?? 60); + + $result = $this->availability->checkHostAvailability( + $request->user()->ownerRef(), + $this->organization($request)->id, + $start, + $duration, + ); + + return response()->json($result); + } +} diff --git a/app/Http/Controllers/Meet/ChannelController.php b/app/Http/Controllers/Meet/ChannelController.php new file mode 100644 index 0000000..07c9e4c --- /dev/null +++ b/app/Http/Controllers/Meet/ChannelController.php @@ -0,0 +1,130 @@ +authorizeAbility($request, 'meetings.view'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $query = Channel::owned($owner)->where('organization_id', $organization->id); + $this->scopeToBranch($request, $query); + + $channels = $query->orderBy('name')->get(); + + return view('meet.channels.index', compact('channels', 'organization')); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'meetings.manage'); + + return view('meet.channels.create', [ + 'organization' => $this->organization($request), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'visibility' => ['required', 'in:public,private'], + 'description' => ['nullable', 'string', 'max:2000'], + ]); + + $channel = $this->channels->create( + $request->user(), + $this->organization($request), + $validated, + ); + + return redirect()->route('meet.channels.show', $channel)->with('success', 'Channel created.'); + } + + public function show(Request $request, Channel $channel): View + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $channel); + abort_unless($this->channels->canAccess($channel, $this->ownerRef($request)), 403); + + $messages = $channel->messages() + ->whereNull('parent_id') + ->with('replies') + ->orderBy('created_at') + ->paginate(50); + + return view('meet.channels.show', compact('channel', 'messages')); + } + + public function postMessage(Request $request, Channel $channel): RedirectResponse|JsonResponse + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $channel); + + $validated = $request->validate([ + 'body' => ['required', 'string', 'max:5000'], + 'parent_id' => ['nullable', 'integer', 'exists:meet_channel_messages,id'], + ]); + + $message = $this->channels->postMessage( + $channel, + $request->user(), + $validated['body'], + $validated['parent_id'] ?? null, + ); + + if ($request->expectsJson()) { + return response()->json(['message' => $message]); + } + + return back()->with('success', 'Message posted.'); + } + + public function react(Request $request, Channel $channel, ChannelMessage $message): JsonResponse + { + $this->authorizeOwner($request, $channel); + abort_unless($message->channel_id === $channel->id, 404); + + $validated = $request->validate(['emoji' => ['required', 'string', 'max:16']]); + + return response()->json([ + 'message' => $this->channels->react($message, $validated['emoji'], $this->ownerRef($request)), + ]); + } + + public function search(Request $request): JsonResponse + { + $this->authorizeAbility($request, 'meetings.view'); + $validated = $request->validate(['q' => ['required', 'string', 'min:2', 'max:100']]); + + return response()->json( + $this->search->search( + $this->organization($request), + $this->ownerRef($request), + $validated['q'], + ), + ); + } +} diff --git a/app/Http/Controllers/Meet/Concerns/ScopesToAccount.php b/app/Http/Controllers/Meet/Concerns/ScopesToAccount.php new file mode 100644 index 0000000..b3530bd --- /dev/null +++ b/app/Http/Controllers/Meet/Concerns/ScopesToAccount.php @@ -0,0 +1,59 @@ +user()->public_id; + } + + protected function organization(Request $request): Organization + { + $organization = $request->attributes->get('meet.organization') + ?? app(OrganizationResolver::class)->resolveForUser($request->user()); + + abort_unless($organization, 404); + + return $organization; + } + + protected function member(Request $request): ?Member + { + return $request->attributes->get('meet.member') + ?? app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request)); + } + + protected function authorizeAbility(Request $request, string $ability): void + { + abort_unless( + app(MeetPermissions::class)->can($this->member($request), $ability), + 403, + ); + } + + protected function authorizeOwner(Request $request, Model $model): void + { + abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404); + } + + protected function scopeToBranch(Request $request, Builder $query, string $column = 'branch_id'): Builder + { + $branchId = app(OrganizationResolver::class)->branchScope($this->member($request)); + + if ($branchId !== null) { + $query->where($column, $branchId); + } + + return $query; + } +} diff --git a/app/Http/Controllers/Meet/ContactGroupController.php b/app/Http/Controllers/Meet/ContactGroupController.php new file mode 100644 index 0000000..6314ad4 --- /dev/null +++ b/app/Http/Controllers/Meet/ContactGroupController.php @@ -0,0 +1,59 @@ +authorizeAbility($request, 'meetings.manage'); + $organization = $this->organization($request); + + $groups = ContactGroup::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->withCount('members') + ->orderBy('name') + ->get(); + + return view('meet.contacts.groups', compact('groups', 'organization')); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'emails' => ['nullable', 'string'], + ]); + + $group = ContactGroup::create([ + 'owner_ref' => $this->ownerRef($request), + 'organization_id' => $organization->id, + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + ]); + + if ($emails = trim((string) ($validated['emails'] ?? ''))) { + foreach (preg_split('/[\s,;]+/', $emails) as $email) { + if ($email = trim($email)) { + $group->members()->create(['email' => $email]); + } + } + } + + return redirect()->route('meet.contacts.groups')->with('success', 'Contact group created.'); + } +} diff --git a/app/Http/Controllers/Meet/DashboardController.php b/app/Http/Controllers/Meet/DashboardController.php new file mode 100644 index 0000000..c3156f6 --- /dev/null +++ b/app/Http/Controllers/Meet/DashboardController.php @@ -0,0 +1,74 @@ +authorizeAbility($request, 'dashboard.view'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + $reports = app(ReportService::class); + + $roomQuery = Room::owned($owner)->where('organization_id', $organization->id); + $this->scopeToBranch($request, $roomQuery); + + $upcoming = (clone $roomQuery) + ->where('status', 'scheduled') + ->where('scheduled_at', '>=', now()) + ->orderBy('scheduled_at') + ->limit(10) + ->get(); + + $active = (clone $roomQuery) + ->where('status', 'live') + ->orderByDesc('updated_at') + ->get(); + + $past = (clone $roomQuery) + ->whereIn('status', ['ended', 'cancelled']) + ->orderByDesc('scheduled_at') + ->limit(10) + ->get(); + + $stats = [ + 'branches' => Branch::owned($owner)->where('organization_id', $organization->id)->where('is_active', true)->count(), + 'team_members' => Member::owned($owner)->where('organization_id', $organization->id)->count(), + 'upcoming_count' => (clone $roomQuery)->where('status', 'scheduled')->where('scheduled_at', '>=', now())->count(), + 'live_count' => (clone $roomQuery)->where('status', 'live')->count(), + 'recordings_count' => Recording::owned($owner) + ->whereHas('session.room', fn ($q) => $q->where('organization_id', $organization->id)) + ->count(), + ]; + + $recentRecordings = Recording::owned($owner) + ->whereHas('session.room', fn ($q) => $q->where('organization_id', $organization->id)) + ->with(['session.room']) + ->orderByDesc('created_at') + ->limit(5) + ->get(); + + $analytics = $reports->orgSummary( + $owner, + $organization->id, + Carbon::now()->subDays(30)->startOfDay(), + Carbon::now()->endOfDay(), + ); + + return view('meet.dashboard', compact('organization', 'stats', 'upcoming', 'active', 'past', 'recentRecordings', 'analytics')); + } +} diff --git a/app/Http/Controllers/Meet/DirectMessageController.php b/app/Http/Controllers/Meet/DirectMessageController.php new file mode 100644 index 0000000..ef30946 --- /dev/null +++ b/app/Http/Controllers/Meet/DirectMessageController.php @@ -0,0 +1,94 @@ +authorizeAbility($request, 'meetings.view'); + $owner = $this->ownerRef($request); + $organization = $this->organization($request); + + $conversations = DirectConversation::owned($owner) + ->where('organization_id', $organization->id) + ->with(['participants', 'messages' => fn ($q) => $q->latest()->limit(1)]) + ->orderByDesc('updated_at') + ->get(); + + $members = Member::owned($owner) + ->where('organization_id', $organization->id) + ->where('user_ref', '!=', $owner) + ->get(); + + return view('meet.messages.index', compact('conversations', 'members', 'organization')); + } + + public function start(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.view'); + $validated = $request->validate([ + 'user_ref' => ['required', 'string', 'max:64'], + ]); + + $conversation = $this->dms->findOrCreateDm( + $request->user(), + $this->organization($request), + $validated['user_ref'], + ); + + return redirect()->route('meet.messages.show', $conversation); + } + + public function show(Request $request, DirectConversation $conversation): View + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $conversation); + abort_unless( + $conversation->participants()->where('user_ref', $this->ownerRef($request))->exists(), + 403, + ); + + $messages = $conversation->messages() + ->whereNull('parent_id') + ->with('replies') + ->orderBy('created_at') + ->paginate(50); + + return view('meet.messages.show', compact('conversation', 'messages')); + } + + public function postMessage(Request $request, DirectConversation $conversation): RedirectResponse + { + $this->authorizeOwner($request, $conversation); + + $validated = $request->validate([ + 'body' => ['required', 'string', 'max:5000'], + 'parent_id' => ['nullable', 'integer', 'exists:meet_direct_messages,id'], + ]); + + $this->dms->postMessage( + $conversation, + $request->user(), + $validated['body'], + $validated['parent_id'] ?? null, + ); + + return back()->with('success', 'Message sent.'); + } +} diff --git a/app/Http/Controllers/Meet/InvitationController.php b/app/Http/Controllers/Meet/InvitationController.php new file mode 100644 index 0000000..c080522 --- /dev/null +++ b/app/Http/Controllers/Meet/InvitationController.php @@ -0,0 +1,142 @@ +authorizeAbility($request, 'meetings.manage'); + $this->authorizeOwner($request, $room); + + $room->load(['invitations' => fn ($q) => $q->orderByDesc('created_at')]); + $groups = ContactGroup::owned($this->ownerRef($request)) + ->where('organization_id', $room->organization_id) + ->withCount('members') + ->orderBy('name') + ->get(); + + return view('meet.invitations.index', compact('room', 'groups')); + } + + public function store(Request $request, Room $room): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + $this->authorizeOwner($request, $room); + + $validated = $request->validate([ + 'emails' => ['nullable', 'string'], + 'member_refs' => ['nullable', 'array'], + 'member_refs.*' => ['string'], + 'contact_group_id' => ['nullable', 'integer', 'exists:meet_contact_groups,id'], + ]); + + $invites = []; + + if ($emails = trim((string) ($validated['emails'] ?? ''))) { + foreach (preg_split('/[\s,;]+/', $emails) as $email) { + if ($email = trim($email)) { + $invites[] = ['email' => $email]; + } + } + } + + foreach ($validated['member_refs'] ?? [] as $userRef) { + $user = \App\Models\User::where('public_id', $userRef)->first(); + if ($user?->email) { + $invites[] = ['email' => $user->email, 'name' => $user->name, 'user_ref' => $userRef]; + } + } + + if (! empty($validated['contact_group_id'])) { + $group = ContactGroup::owned($this->ownerRef($request))->findOrFail($validated['contact_group_id']); + $invites = array_merge($invites, $this->contacts->fromGroup($group)->all()); + } + + $this->invitations->inviteToRoom($room, $invites, $request->user()); + + return back()->with('success', count($invites).' invitation(s) sent.'); + } + + public function bulkCsv(Request $request, Room $room): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + $this->authorizeOwner($request, $room); + + $request->validate(['csv' => ['required', 'file', 'mimes:csv,txt', 'max:2048']]); + $count = $this->invitations->bulkFromCsv($room, $request->file('csv')->getContent(), $request->user()); + + return back()->with('success', "{$count} invitation(s) imported."); + } + + public function resend(Request $request, Invitation $invitation): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + $this->authorizeOwner($request, $invitation); + + $this->invitations->resend($invitation, $request->user()); + + return back()->with('success', 'Invitation resent.'); + } + + public function destroy(Request $request, Invitation $invitation): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + $this->authorizeOwner($request, $invitation); + $room = $invitation->room; + $invitation->delete(); + + return redirect()->route('meet.invitations.index', $room)->with('success', 'Invitation removed.'); + } + + public function rsvp(Request $request, Invitation $invitation): View|RedirectResponse + { + abort_unless(hash_equals((string) $invitation->rsvp_token, (string) $request->query('token')), 403); + + if ($request->isMethod('post')) { + $validated = $request->validate(['status' => ['required', 'in:accepted,declined']]); + $this->invitations->respond($invitation, $validated['status'], $invitation->rsvp_token); + + return view('meet.invitations.rsvp-done', [ + 'invitation' => $invitation->fresh(), + 'room' => $invitation->room, + ]); + } + + return view('meet.invitations.rsvp', [ + 'invitation' => $invitation, + 'room' => $invitation->room, + ]); + } + + public function searchContacts(Request $request): JsonResponse + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + $query = (string) $request->query('q', ''); + + $results = $this->contacts->search($organization->id, $this->ownerRef($request), $query); + + return response()->json(['contacts' => $results]); + } +} diff --git a/app/Http/Controllers/Meet/JoinController.php b/app/Http/Controllers/Meet/JoinController.php new file mode 100644 index 0000000..b2ea63a --- /dev/null +++ b/app/Http/Controllers/Meet/JoinController.php @@ -0,0 +1,123 @@ +status === 'cancelled') { + return view('meet.join.cancelled', compact('room')); + } + + if ($room->isWebinar() && $request->query('token')) { + $request->session()->put("meet.webinar.{$room->uuid}", $request->query('token')); + } + + [$allowed, $reason] = $this->security->canJoin($room, $request->user(), $request->user()?->email); + if (! $allowed && $reason !== 'passcode_required') { + return view('meet.join.denied', compact('room', 'reason')); + } + + if ($room->passcode && ! $request->session()->get("meet.passcode.{$room->uuid}")) { + return view('meet.join.passcode', compact('room')); + } + + return view('meet.join.show', [ + 'room' => $room, + 'user' => $request->user(), + 'isWebinar' => $room->isWebinar(), + ]); + } + + public function verifyPasscode(Request $request, Room $room): RedirectResponse + { + $request->validate(['passcode' => ['required', 'string']]); + + abort_unless($room->passcode === $request->input('passcode'), 422); + + $request->session()->put("meet.passcode.{$room->uuid}", true); + + return redirect()->route('meet.join', $room); + } + + public function enter(Request $request, Room $room): RedirectResponse + { + if ($room->passcode && ! $request->session()->get("meet.passcode.{$room->uuid}")) { + return redirect()->route('meet.join', $room); + } + + $validated = $request->validate([ + 'display_name' => ['required_without:auth', 'string', 'max:100'], + 'email' => ['nullable', 'email'], + ]); + + $user = $request->user(); + $displayName = $user?->name ?? $validated['display_name']; + $email = $user?->email ?? ($validated['email'] ?? null); + + [$allowed, $reason] = $this->security->canJoin($room, $user, $email ?? $displayName.'@guest.local'); + if (! $allowed && $reason !== 'passcode_required') { + return back()->withErrors(['join' => $reason ?? 'Unable to join meeting.']); + } + + if ($room->isLive() && $room->activeSession()) { + $session = $room->activeSession(); + } elseif ($room->isTownHall() && $user && $user->ownerRef() === $room->host_user_ref && $room->setting('green_room')) { + $session = app(\App\Services\Meet\TownHallService::class)->startGreenRoom($room, $user); + } elseif ($user && $user->ownerRef() === $room->host_user_ref) { + $session = $this->sessions->start($room, $user); + } elseif ($room->setting('join_before_host') || ($user && $user->ownerRef() === $room->host_user_ref)) { + if (! $room->activeSession() && $user && $user->ownerRef() === $room->host_user_ref) { + $session = $this->sessions->start($room, $user); + } else { + abort_unless($room->activeSession(), 422, 'Meeting has not started yet.'); + $session = $room->activeSession(); + } + } else { + abort_unless($room->activeSession(), 422, 'Meeting has not started yet. Please wait for the host.'); + $session = $room->activeSession(); + } + + $role = ($user && $user->ownerRef() === $room->host_user_ref) ? 'host' : 'guest'; + + if ($room->isWebinar() && $role !== 'host') { + $token = $request->session()->get("meet.webinar.{$room->uuid}"); + $reg = $token + ? $this->webinars->findByAccessToken($token) + : ($email ? $this->webinars->findApprovedRegistration($room, $email) : null); + + if (! $reg) { + return redirect()->route('meet.webinar.register', $room) + ->withErrors(['register' => 'Approved registration is required for this webinar.']); + } + + $role = 'attendee'; + $displayName = $reg->display_name; + } + + $participant = $this->sessions->joinParticipant($session, $user, $role, $displayName, $email); + + app(InvitationService::class)->markAcceptedOnJoin($room, $user, $email); + + $request->session()->put("meet.participant.{$session->uuid}", $participant->uuid); + + return redirect()->route('meet.room', $session); + } +} diff --git a/app/Http/Controllers/Meet/MeetingFeaturesController.php b/app/Http/Controllers/Meet/MeetingFeaturesController.php new file mode 100644 index 0000000..c414c2c --- /dev/null +++ b/app/Http/Controllers/Meet/MeetingFeaturesController.php @@ -0,0 +1,314 @@ +participant($request, $session); + $validated = $request->validate(['question' => ['required', 'string', 'max:1000']]); + $question = $this->qa->submit($session, $participant, $validated['question']); + + return response()->json(['question' => $question]); + } + + public function approveQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse + { + $this->hostOnly($request, $session); + abort_unless($question->session_id === $session->id, 404); + + return response()->json(['question' => $this->qa->approve($question)]); + } + + public function answerQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse + { + $participant = $this->hostOnly($request, $session); + abort_unless($question->session_id === $session->id, 404); + $validated = $request->validate(['answer' => ['required', 'string', 'max:2000']]); + + return response()->json([ + 'question' => $this->qa->answer($question, $validated['answer'], $participant->display_name), + ]); + } + + public function upvoteQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse + { + $this->participant($request, $session); + abort_unless($question->session_id === $session->id, 404); + + return response()->json(['question' => $this->qa->upvote($question)]); + } + + // --- Polls --- + + public function createPoll(Request $request, Session $session): JsonResponse + { + $this->hostOnly($request, $session); + $validated = $request->validate([ + 'question' => ['required', 'string', 'max:500'], + 'options' => ['required', 'array', 'min:2', 'max:6'], + 'options.*' => ['required', 'string', 'max:200'], + ]); + + $poll = $this->polls->create($session, $validated['question'], $validated['options']); + + return response()->json(['poll' => $poll], 201); + } + + public function votePoll(Request $request, Session $session, MeetPoll $poll): JsonResponse + { + $participant = $this->participant($request, $session); + abort_unless($poll->session_id === $session->id, 404); + $validated = $request->validate(['option_index' => ['required', 'integer', 'min:0']]); + $this->polls->vote($poll, $participant, $validated['option_index']); + + return response()->json($this->polls->results($poll)); + } + + public function closePoll(Request $request, Session $session, MeetPoll $poll): JsonResponse + { + $this->hostOnly($request, $session); + abort_unless($poll->session_id === $session->id, 404); + + return response()->json($this->polls->results($this->polls->close($poll))); + } + + // --- Breakouts --- + + public function createBreakouts(Request $request, Session $session): JsonResponse + { + $this->hostOnly($request, $session); + $validated = $request->validate([ + 'count' => ['required', 'integer', 'min:1', 'max:20'], + 'timer_minutes' => ['nullable', 'integer', 'min:5', 'max:120'], + ]); + + $rooms = $this->breakouts->createRooms( + $session, + $validated['count'], + $validated['timer_minutes'] ?? null, + ); + + return response()->json(['breakouts' => $rooms]); + } + + public function moveToBreakout(Request $request, Session $session, BreakoutRoom $breakout): JsonResponse + { + $participant = $this->participant($request, $session); + abort_unless($breakout->session_id === $session->id, 404); + $this->breakouts->moveParticipant($participant, $breakout); + + return response()->json(['breakout_uuid' => $breakout->uuid, 'media_room' => $breakout->media_room_name]); + } + + public function returnFromBreakout(Request $request, Session $session): JsonResponse + { + $participant = $this->participant($request, $session); + $this->breakouts->returnToMain($participant); + + return response()->json(['returned' => true]); + } + + public function broadcastBreakout(Request $request, Session $session): JsonResponse + { + $participant = $this->hostOnly($request, $session); + $validated = $request->validate(['message' => ['required', 'string', 'max:1000']]); + $message = $this->breakouts->broadcast($session, $validated['message'], $participant->display_name); + + return response()->json(['message' => $message]); + } + + public function closeBreakouts(Request $request, Session $session): JsonResponse + { + $this->hostOnly($request, $session); + $this->breakouts->closeAll($session); + + return response()->json(['closed' => true]); + } + + // --- Files --- + + public function uploadFile(Request $request, Session $session): JsonResponse + { + $participant = $this->participant($request, $session); + $validated = $request->validate(['file' => ['required', 'file', 'max:51200']]); + + $file = $this->files->upload( + $session->room, + $session, + $validated['file'], + $participant->user_ref ?? $participant->uuid, + $participant->display_name, + ); + + return response()->json(['file' => $file], 201); + } + + public function downloadFile(Request $request, Session $session, SessionFile $file): StreamedResponse|JsonResponse + { + $participant = $this->participant($request, $session); + abort_unless($file->room_id === $session->room_id, 404); + + if ($file->drive_file_id) { + return response()->json(['url' => $this->files->download($file, $participant)]); + } + + $path = $this->files->download($file, $participant); + + return Storage::disk(config('meet.files.disk', 'local'))->download($file->storage_path, $file->original_name); + } + + // --- Whiteboard --- + + public function showWhiteboard(Request $request, Session $session): JsonResponse + { + $this->participant($request, $session); + $board = $this->whiteboards->ensureForSession($session); + + return response()->json(['whiteboard' => $board]); + } + + public function saveWhiteboard(Request $request, Session $session): JsonResponse + { + $this->participant($request, $session); + $validated = $request->validate(['state' => ['required', 'array']]); + $board = $this->whiteboards->ensureForSession($session); + + return response()->json(['whiteboard' => $this->whiteboards->saveState($board, $validated['state'])]); + } + + public function exportWhiteboard(Request $request, Session $session): JsonResponse + { + $this->participant($request, $session); + $validated = $request->validate(['image_data' => ['required', 'string']]); + $data = preg_replace('#^data:image/\w+;base64,#i', '', $validated['image_data']); + $binary = base64_decode($data, true); + abort_unless($binary !== false, 422); + + $path = 'whiteboards/'.$session->uuid.'/'.now()->timestamp.'.png'; + Storage::disk(config('meet.files.disk', 'local'))->put($path, $binary); + + return response()->json(['export_path' => $path]); + } + + // --- Live streaming --- + + public function configureLiveStream(Request $request, Session $session): JsonResponse + { + $this->hostOnly($request, $session); + $validated = $request->validate([ + 'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))], + 'rtmp_url' => ['nullable', 'url', 'max:2048'], + 'stream_key' => ['required', 'string', 'max:512'], + 'layout' => ['nullable', 'string', 'in:'.implode(',', config('meet.live_stream.layouts'))], + ]); + + $stream = $this->liveStreams->configure($session, $request->user(), $validated); + + return response()->json(['stream' => $stream->makeHidden('stream_key')], 201); + } + + public function startLiveStream(Request $request, Session $session): JsonResponse + { + $this->hostOnly($request, $session); + $validated = $request->validate([ + 'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))], + ]); + + $stream = $session->liveStreams()->where('platform', $validated['platform'])->firstOrFail(); + $started = $this->liveStreams->start($stream); + + return response()->json(['stream' => $started->makeHidden('stream_key')]); + } + + public function stopLiveStream(Request $request, Session $session): JsonResponse + { + $this->hostOnly($request, $session); + $validated = $request->validate([ + 'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))], + ]); + + $stream = $session->liveStreams()->where('platform', $validated['platform'])->firstOrFail(); + + return response()->json(['stream' => $this->liveStreams->stop($stream)->makeHidden('stream_key')]); + } + + // --- Town hall --- + + public function startGreenRoom(Request $request, Session $session): JsonResponse + { + $this->hostOnly($request, $session); + abort_unless($session->room->isTownHall(), 422); + + $greenRoom = $this->townHall->startGreenRoom($session->room, $request->user()); + + return response()->json(['session_uuid' => $greenRoom->uuid, 'mode' => 'green_room']); + } + + public function goLiveTownHall(Request $request, Session $session): JsonResponse + { + $this->hostOnly($request, $session); + $live = $this->townHall->goLive($session, $request->user()); + + return response()->json(['session_uuid' => $live->uuid, 'mode' => 'live']); + } + + public function handoffCoHost(Request $request, Session $session): JsonResponse + { + $participant = $this->hostOnly($request, $session); + $validated = $request->validate(['user_ref' => ['required', 'string', 'max:64']]); + + return response()->json([ + 'participant' => $this->townHall->handoffCoHost($session, $participant, $validated['user_ref']), + ]); + } + + protected function participant(Request $request, Session $session): Participant + { + $uuid = $request->session()->get("meet.participant.{$session->uuid}"); + abort_unless($uuid, 403); + + return Participant::where('uuid', $uuid)->where('session_id', $session->id)->firstOrFail(); + } + + protected function hostOnly(Request $request, Session $session): Participant + { + $participant = $this->participant($request, $session); + abort_unless($participant->isHost(), 403); + + return $participant; + } +} diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php new file mode 100644 index 0000000..bec7ee8 --- /dev/null +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -0,0 +1,286 @@ +isLive(), 404, 'Meeting has ended.'); + + $participantUuid = $request->session()->get("meet.participant.{$session->uuid}"); + abort_unless($participantUuid, 403, 'Please join the meeting first.'); + + $participant = Participant::where('uuid', $participantUuid) + ->where('session_id', $session->id) + ->firstOrFail(); + + $token = $this->media->isConfigured() + ? $this->sessions->generateMediaToken($participant) + : ''; + + $session->load(['room', 'participants']); + + return view('meet.room.show', [ + 'session' => $session, + 'room' => $session->room, + 'participant' => $participant, + 'mediaToken' => $token, + 'mediaUrl' => $this->media->serverUrl(), + 'mediaConfigured' => $this->media->isConfigured(), + 'messages' => $this->chat->recentMessages($session), + 'activeRecording' => $session->recordings()->where('status', 'recording')->first(), + 'watermark' => $session->room->organization?->securitySetting('watermark_enabled', false), + 'isWebinar' => $session->room->isWebinar(), + 'canPublish' => app(\App\Services\Meet\WebinarService::class)->canPublish($session->room, $participant), + 'isAttendee' => $participant->isAttendee(), + ]); + } + + public function end(Request $request, Session $session): RedirectResponse + { + $participantUuid = $request->session()->get("meet.participant.{$session->uuid}"); + $participant = Participant::where('uuid', $participantUuid)->first(); + + abort_unless($participant?->isHost(), 403); + + $this->sessions->end($session, $participant->user_ref ?? $participant->uuid); + + return redirect()->route('meet.rooms.show', $session->room) + ->with('success', 'Meeting ended.'); + } + + public function leave(Request $request, Session $session): RedirectResponse + { + $participantUuid = $request->session()->get("meet.participant.{$session->uuid}"); + $participant = Participant::where('uuid', $participantUuid)->first(); + + if ($participant) { + $this->sessions->leaveParticipant($participant); + } + + $request->session()->forget("meet.participant.{$session->uuid}"); + + return redirect()->route('meet.dashboard'); + } + + public function raiseHand(Request $request, Session $session): JsonResponse + { + $participant = $this->currentParticipant($request, $session); + $participant->update(['hand_raised' => ! $participant->hand_raised]); + + return response()->json(['hand_raised' => $participant->hand_raised]); + } + + public function sendMessage(Request $request, Session $session): JsonResponse + { + $participant = $this->currentParticipant($request, $session); + + $validated = $request->validate(['body' => ['required', 'string', 'max:2000']]); + + $message = $this->chat->sendPublicMessage( + $session, + $participant->display_name, + $validated['body'], + $participant->user_ref, + ); + + return response()->json([ + 'message' => [ + 'uuid' => $message->uuid, + 'sender_name' => $message->sender_name, + 'body' => $message->body, + 'created_at' => $message->created_at->toIso8601String(), + ], + ]); + } + + public function sendReaction(Request $request, Session $session): JsonResponse + { + $participant = $this->currentParticipant($request, $session); + + $validated = $request->validate(['emoji' => ['required', 'string', 'max:32']]); + + $reaction = $this->chat->sendReaction( + $session, + $participant->display_name, + $validated['emoji'], + $participant->uuid, + ); + + return response()->json([ + 'reaction' => [ + 'sender_name' => $reaction->sender_name, + 'emoji' => $reaction->emoji, + 'created_at' => $reaction->created_at->toIso8601String(), + ], + ]); + } + + public function poll(Request $request, Session $session): JsonResponse + { + $this->currentParticipant($request, $session); + + $session->load(['participants' => fn ($q) => $q->where('status', 'joined')]); + + $messages = $this->chat->recentMessages($session, 50); + $reactions = $session->reactions()->where('created_at', '>=', now()->subMinutes(5))->latest()->limit(20)->get(); + $qa = app(\App\Services\Meet\QaService::class)->forSession($session, true); + $polls = $session->polls()->where('status', 'open')->with('votes')->get(); + $breakouts = $session->breakoutRooms()->where('status', 'open')->get(); + $broadcasts = $session->messages()->where('type', 'breakout_broadcast')->where('created_at', '>=', now()->subMinutes(30))->latest()->limit(5)->get(); + $files = $session->sessionFiles() + ->where(function ($q) { + $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->latest() + ->limit(20) + ->get(); + + return response()->json([ + 'participants' => $session->participants->map(fn ($p) => [ + 'uuid' => $p->uuid, + 'display_name' => $p->display_name, + 'role' => $p->role, + 'hand_raised' => $p->hand_raised, + 'is_muted' => $p->is_muted, + 'is_video_off' => $p->is_video_off, + 'breakout_room_id' => $p->breakout_room_id, + ]), + 'messages' => $messages->map(fn ($m) => [ + 'uuid' => $m->uuid, + 'sender_name' => $m->sender_name, + 'body' => $m->body, + 'type' => $m->type, + 'created_at' => $m->created_at->toIso8601String(), + ]), + 'reactions' => $reactions->map(fn ($r) => [ + 'sender_name' => $r->sender_name, + 'emoji' => $r->emoji, + 'created_at' => $r->created_at->toIso8601String(), + ]), + 'qa' => $qa->map(fn ($q) => [ + 'uuid' => $q->uuid, + 'asker_name' => $q->asker_name, + 'question' => $q->question, + 'status' => $q->status, + 'answer' => $q->answer, + 'upvotes' => $q->upvotes, + ]), + 'polls' => $polls->map(fn ($p) => [ + 'uuid' => $p->uuid, + 'question' => $p->question, + 'options' => $p->options, + 'votes' => $p->votes->count(), + ]), + 'breakouts' => $breakouts->map(fn ($b) => [ + 'uuid' => $b->uuid, + 'name' => $b->name, + 'closes_at' => $b->closes_at?->toIso8601String(), + ]), + 'broadcasts' => $broadcasts->map(fn ($m) => [ + 'body' => $m->body, + 'sender_name' => $m->sender_name, + 'created_at' => $m->created_at->toIso8601String(), + ]), + 'files' => $files->map(fn ($f) => [ + 'uuid' => $f->uuid, + 'original_name' => $f->original_name, + 'file_size' => $f->file_size, + ]), + ]); + } + + public function startRecording(Request $request, Session $session): JsonResponse + { + $participant = $this->currentParticipant($request, $session); + abort_unless($participant->isHost(), 403); + abort_if($session->room->organization?->securitySetting('recording_host_only') && ! $participant->isHost(), 403); + + $host = $request->user() ?? \App\Models\User::where('public_id', $session->room->host_user_ref)->first(); + abort_unless($host, 403); + + $recording = $this->recordings->start($session, $host); + + return response()->json(['recording_uuid' => $recording->uuid, 'status' => $recording->status]); + } + + public function stopRecording(Request $request, Session $session): JsonResponse + { + $participant = $this->currentParticipant($request, $session); + abort_unless($participant->isHost(), 403); + + $recording = $session->recordings()->where('status', 'recording')->firstOrFail(); + $this->recordings->stop($recording, $participant->user_ref ?? $participant->uuid); + + return response()->json(['status' => 'processing']); + } + + public function lock(Request $request, Session $session): JsonResponse + { + $participant = $this->currentParticipant($request, $session); + abort_unless($participant->isHost(), 403); + $this->sessions->lock($session, $participant->user_ref ?? $participant->uuid); + + return response()->json(['locked' => true]); + } + + public function unlock(Request $request, Session $session): JsonResponse + { + $participant = $this->currentParticipant($request, $session); + abort_unless($participant->isHost(), 403); + $this->sessions->unlock($session, $participant->user_ref ?? $participant->uuid); + + return response()->json(['locked' => false]); + } + + public function caption(Request $request, Session $session): JsonResponse + { + $participant = $this->currentParticipant($request, $session); + $validated = $request->validate(['text' => ['required', 'string', 'max:500']]); + + if (! $session->room->setting('live_captions', false)) { + return response()->json(['ok' => false], 422); + } + + $transcript = $session->transcripts()->latest()->first() + ?? $this->transcripts->ensureForSession($session); + + $this->transcripts->appendSegment($transcript, $participant->display_name, $validated['text']); + + return response()->json(['ok' => true]); + } + + protected function currentParticipant(Request $request, Session $session): Participant + { + $participantUuid = $request->session()->get("meet.participant.{$session->uuid}"); + abort_unless($participantUuid, 403); + + return Participant::where('uuid', $participantUuid) + ->where('session_id', $session->id) + ->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Meet/MemberController.php b/app/Http/Controllers/Meet/MemberController.php new file mode 100644 index 0000000..b04925c --- /dev/null +++ b/app/Http/Controllers/Meet/MemberController.php @@ -0,0 +1,98 @@ +authorizeAbility($request, 'admin.members.view'); + $organization = $this->organization($request); + + $members = Member::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->with('branch') + ->orderBy('created_at') + ->get(); + + return view('meet.admin.members.index', [ + 'members' => $members, + 'organization' => $organization, + 'roles' => config('meet.roles'), + ]); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'admin.members.manage'); + $organization = $this->organization($request); + + $branches = Branch::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->orderBy('name') + ->get(); + + return view('meet.admin.members.create', [ + 'organization' => $organization, + 'branches' => $branches, + 'roles' => config('meet.roles'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'admin.members.manage'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $validated = $request->validate([ + 'user_ref' => ['required', 'string', 'max:255'], + 'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('meet.roles')))], + 'branch_id' => ['nullable', 'integer', 'exists:meet_branches,id'], + ]); + + $member = Member::updateOrCreate( + [ + 'organization_id' => $organization->id, + 'user_ref' => $validated['user_ref'], + ], + [ + 'owner_ref' => $owner, + 'role' => $validated['role'], + 'branch_id' => $validated['branch_id'] ?? null, + ], + ); + + AuditLogger::record($owner, 'member.created', $organization->id, $owner, Member::class, $member->id); + + return redirect()->route('meet.members.index')->with('success', 'Member saved.'); + } + + public function destroy(Request $request, Member $member): RedirectResponse + { + $this->authorizeAbility($request, 'admin.members.manage'); + $this->authorizeOwner($request, $member); + + abort_if($member->user_ref === $this->ownerRef($request), 422, 'You cannot remove yourself.'); + + $memberId = $member->id; + $organizationId = $member->organization_id; + $member->delete(); + + AuditLogger::record($this->ownerRef($request), 'member.deleted', $organizationId, $this->ownerRef($request), Member::class, $memberId); + + return redirect()->route('meet.members.index')->with('success', 'Member removed.'); + } +} diff --git a/app/Http/Controllers/Meet/OnboardingController.php b/app/Http/Controllers/Meet/OnboardingController.php new file mode 100644 index 0000000..902a87a --- /dev/null +++ b/app/Http/Controllers/Meet/OnboardingController.php @@ -0,0 +1,65 @@ +organizations->isOnboarded($request->user())) { + return redirect()->route('meet.dashboard'); + } + + return view('meet.onboarding.show', [ + 'user' => $request->user(), + 'timezones' => timezone_identifiers_list(), + 'orgTypes' => [ + 'business' => 'Business', + 'healthcare' => 'Healthcare', + 'education' => 'Education', + 'nonprofit' => 'Nonprofit', + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + if ($this->organizations->isOnboarded($request->user())) { + return redirect()->route('meet.dashboard'); + } + + $validated = $request->validate([ + 'organization_name' => ['required', 'string', 'max:255'], + 'org_type' => ['required', 'string', 'in:business,healthcare,education,nonprofit'], + 'branch_name' => ['required', 'string', 'max:255'], + 'branch_address' => ['nullable', 'string', 'max:500'], + 'branch_phone' => ['nullable', 'string', 'max:50'], + 'timezone' => ['required', 'timezone'], + 'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'], + ]); + + $organization = $this->organizations->completeOnboarding($request->user(), $validated); + + if ($request->hasFile('logo')) { + $organization->update([ + 'logo_path' => OrganizationBranding::storeLogo($organization, $request->file('logo')), + ]); + } + + return redirect()->route('meet.dashboard')->with('success', 'Welcome to Ladill Meet!'); + } +} diff --git a/app/Http/Controllers/Meet/RecordingController.php b/app/Http/Controllers/Meet/RecordingController.php new file mode 100644 index 0000000..2c4e031 --- /dev/null +++ b/app/Http/Controllers/Meet/RecordingController.php @@ -0,0 +1,66 @@ +authorizeAbility($request, 'meetings.view'); + $owner = $this->ownerRef($request); + $organization = $this->organization($request); + + $recordings = Recording::owned($owner) + ->whereHas('session.room', fn ($q) => $q->where('organization_id', $organization->id)) + ->with(['session.room']) + ->orderByDesc('created_at') + ->paginate(20); + + return view('meet.recordings.index', compact('recordings')); + } + + public function show(Request $request, Recording $recording): View + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $recording); + $recording->load(['session.room', 'session.participants']); + + return view('meet.recordings.show', compact('recording')); + } + + public function download(Request $request, Recording $recording): StreamedResponse + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $recording); + abort_unless($recording->isReady() && $recording->storage_path, 404); + + return Storage::disk(config('meet.recordings.disk', 'local')) + ->download($recording->storage_path, $recording->session->room->title.'.mp4'); + } + + public function destroy(Request $request, Recording $recording): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + $this->authorizeOwner($request, $recording); + + $this->recordings->delete($recording, $this->ownerRef($request)); + + return redirect()->route('meet.recordings.index')->with('success', 'Recording deleted.'); + } +} diff --git a/app/Http/Controllers/Meet/ReportController.php b/app/Http/Controllers/Meet/ReportController.php new file mode 100644 index 0000000..c01e9bb --- /dev/null +++ b/app/Http/Controllers/Meet/ReportController.php @@ -0,0 +1,114 @@ +authorizeAbility($request, 'reports.view'); + $organization = $this->organization($request); + + $branches = Branch::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->orderBy('name') + ->get(); + + return view('meet.reports.index', [ + 'organization' => $organization, + 'branches' => $branches, + 'reports' => config('meet.report_types'), + ]); + } + + public function show(Request $request, string $type): View + { + $this->authorizeReport($request, $type); + $organization = $this->organization($request); + $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); + $branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope; + [$from, $to] = $this->dateRange($request); + + $data = $this->reportData($type, $this->ownerRef($request), $organization->id, $from, $to, $branchId); + + $branches = Branch::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->orderBy('name') + ->get(); + + return view('meet.reports.show', [ + 'type' => $type, + 'label' => config('meet.report_types')[$type] ?? $type, + 'data' => $data, + 'from' => $from->toDateString(), + 'to' => $to->toDateString(), + 'branchId' => $branchId, + 'branches' => $branches, + 'canExport' => app(MeetPermissions::class)->can($this->member($request), 'reports.export'), + ]); + } + + public function export(Request $request, string $type): StreamedResponse + { + $this->authorizeReport($request, $type); + abort_unless(app(MeetPermissions::class)->can($this->member($request), 'reports.export'), 403); + + $organization = $this->organization($request); + $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); + $branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope; + [$from, $to] = $this->dateRange($request); + $data = $this->reportData($type, $this->ownerRef($request), $organization->id, $from, $to, $branchId); + + $filename = 'meet-report-'.$type.'-'.now()->format('Y-m-d').'.csv'; + + return response()->streamDownload(function () use ($type, $data) { + echo $this->reports->toCsv($type, $data); + }, $filename, ['Content-Type' => 'text/csv']); + } + + /** @return array */ + protected function reportData(string $type, string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId): array + { + return match ($type) { + 'summary' => $this->reports->orgSummary($ownerRef, $organizationId, $from, $to, $branchId), + 'meetings' => $this->reports->meetingsReport($ownerRef, $organizationId, $from, $to, $branchId), + 'users' => $this->reports->usersReport($ownerRef, $organizationId, $from, $to, $branchId), + 'invitations' => $this->reports->invitationsReport($ownerRef, $organizationId, $from, $to, $branchId), + 'branches' => $this->reports->branchRollup($ownerRef, $organizationId, $from, $to), + default => abort(404), + }; + } + + protected function authorizeReport(Request $request, string $type): void + { + $this->authorizeAbility($request, 'reports.view'); + abort_unless(array_key_exists($type, config('meet.report_types', [])), 404); + } + + /** @return array{0: Carbon, 1: Carbon} */ + protected function dateRange(Request $request): array + { + $from = Carbon::parse($request->input('from', now()->subDays(30)->toDateString()))->startOfDay(); + $to = Carbon::parse($request->input('to', now()->toDateString()))->endOfDay(); + + return [$from, $to]; + } +} diff --git a/app/Http/Controllers/Meet/RoomController.php b/app/Http/Controllers/Meet/RoomController.php new file mode 100644 index 0000000..34ceeb3 --- /dev/null +++ b/app/Http/Controllers/Meet/RoomController.php @@ -0,0 +1,256 @@ +authorizeAbility($request, 'meetings.view'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $query = Room::owned($owner)->where('organization_id', $organization->id); + $this->scopeToBranch($request, $query); + + $rooms = $query->orderByDesc('scheduled_at')->paginate(20); + + return view('meet.rooms.index', compact('rooms', 'organization')); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + + $templates = Template::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->orderBy('name') + ->get(); + + return view('meet.rooms.create', [ + 'organization' => $organization, + 'templates' => $templates, + 'timezones' => timezone_identifiers_list(), + 'recurrenceRules' => config('meet.recurrence_rules'), + 'defaultSettings' => config('meet.default_settings'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'type' => ['nullable', 'string', 'in:scheduled,webinar,town_hall'], + 'scheduled_at' => ['nullable', 'date'], + 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], + 'timezone' => ['nullable', 'timezone'], + 'passcode' => ['nullable', 'string', 'max:20'], + 'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'], + 'recurrence_rule' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('meet.recurrence_rules')))], + 'recurrence_interval' => ['nullable', 'integer', 'min:1', 'max:12'], + 'recurrence_until' => ['nullable', 'date'], + 'invite_emails' => ['nullable', 'string'], + 'waiting_room' => ['sometimes', 'boolean'], + 'join_before_host' => ['sometimes', 'boolean'], + 'mute_on_join' => ['sometimes', 'boolean'], + 'auto_record' => ['sometimes', 'boolean'], + 'live_captions' => ['sometimes', 'boolean'], + 'webinar_auto_approve' => ['sometimes', 'boolean'], + ]); + + $settings = [ + 'waiting_room' => $request->boolean('waiting_room', true), + 'join_before_host' => $request->boolean('join_before_host'), + 'mute_on_join' => $request->boolean('mute_on_join', true), + 'auto_record' => $request->boolean('auto_record'), + 'live_captions' => $request->boolean('live_captions'), + 'webinar_auto_approve' => $request->boolean('webinar_auto_approve'), + 'stage_mode' => ($validated['type'] ?? '') === 'webinar', + ]; + + $data = array_merge($validated, [ + 'settings' => $settings, + 'timezone' => $validated['timezone'] ?? $organization->timezone, + ]); + + if (! empty($validated['template_id'])) { + $template = Template::findOrFail($validated['template_id']); + $data = $this->templates->applyToRoomData($template, $data); + } + + $isInstant = empty($validated['scheduled_at']); + + if (! empty($validated['recurrence_rule']) && ! $isInstant) { + $room = $this->recurring->createSeries($request->user(), $organization, $data); + } elseif (($validated['type'] ?? '') === 'webinar') { + abort_if($isInstant, 422, 'Webinars must be scheduled.'); + $room = $this->rooms->create($request->user(), $organization, array_merge($data, [ + 'type' => 'webinar', + 'status' => 'scheduled', + ])); + } elseif (($validated['type'] ?? '') === 'town_hall') { + abort_if($isInstant, 422, 'Town halls must be scheduled.'); + $room = $this->rooms->create($request->user(), $organization, array_merge($data, [ + 'type' => 'town_hall', + 'status' => 'scheduled', + 'settings' => array_merge($settings, [ + 'green_room' => true, + 'practice_mode' => $request->boolean('practice_mode'), + ]), + ])); + } elseif ($isInstant) { + $room = $this->rooms->createInstant($request->user(), $organization, $data); + } else { + $room = $this->rooms->createScheduled($request->user(), $organization, $data); + } + + if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) { + $invites = collect(preg_split('/[\s,;]+/', $emails)) + ->filter() + ->map(fn ($email) => ['email' => $email]) + ->all(); + $this->invitations->inviteToRoom($room, $invites, $request->user()); + } + + if (! $isInstant && $room->scheduled_at) { + $this->calendar->syncRoomCreated($room, $request->user()); + } + + return redirect()->route('meet.rooms.show', $room)->with('success', 'Meeting created.'); + } + + public function show(Request $request, Room $room): View + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $room); + + $room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']); + + return view('meet.rooms.show', compact('room')); + } + + public function start(Request $request, Room $room): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.host'); + $this->authorizeOwner($request, $room); + abort_if($room->status === 'cancelled', 422, 'Meeting was cancelled.'); + + if ($room->isTownHall() && $room->setting('green_room')) { + $session = app(\App\Services\Meet\TownHallService::class)->startGreenRoom($room, $request->user()); + } else { + $session = $this->sessions->start($room, $request->user()); + } + + return redirect()->route('meet.room', $session); + } + + public function cancel(Request $request, Room $room): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + $this->authorizeOwner($request, $room); + + $this->rooms->cancel($room, $this->ownerRef($request), $request->user()); + + return redirect()->route('meet.rooms.index')->with('success', 'Meeting cancelled.'); + } + + public function instant(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.create'); + $organization = $this->organization($request); + + $room = $this->rooms->createInstant($request->user(), $organization, [ + 'title' => $request->user()->name."'s meeting", + ]); + + $session = $this->sessions->start($room, $request->user()); + + return redirect()->route('meet.room', $session); + } + + public function personal(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.host'); + $organization = $this->organization($request); + $room = $this->rooms->ensurePersonalRoom($request->user(), $organization); + + return redirect()->route('meet.rooms.show', $room); + } + + public function ical(Request $request, Room $room): Response + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $room); + + $content = $this->ical->forRoom($room, $request->user()); + + return response($content, 200, [ + 'Content-Type' => 'text/calendar; charset=utf-8', + 'Content-Disposition' => 'attachment; filename="meeting-'.$room->uuid.'.ics"', + ]); + } + + public function qr(Request $request, Room $room): Response + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $room); + + $options = new QROptions(['outputType' => QRCode::OUTPUT_IMAGE_PNG, 'scale' => 8]); + $binary = (new QRCode($options))->render($room->joinUrl()); + + return response($binary, 200, ['Content-Type' => 'image/png']); + } + + public function attendance(Request $request, Room $room): Response + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $room); + + $session = $room->sessions()->latest()->first(); + abort_unless($session, 404); + + return response($this->attendance->toCsv($session), 200, [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="attendance-'.$room->uuid.'.csv"', + ]); + } +} diff --git a/app/Http/Controllers/Meet/SettingsController.php b/app/Http/Controllers/Meet/SettingsController.php new file mode 100644 index 0000000..477c16b --- /dev/null +++ b/app/Http/Controllers/Meet/SettingsController.php @@ -0,0 +1,58 @@ +authorizeAbility($request, 'meetings.manage'); + $organization = $this->organization($request); + + return view('meet.settings.security', [ + 'organization' => $organization, + 'policy' => array_merge(config('meet.security_defaults'), $organization->security_policy ?? []), + ]); + } + + public function updateSecurity(Request $request, SecurityPolicyService $security): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'org_only' => ['sometimes', 'boolean'], + 'authenticated_only' => ['sometimes', 'boolean'], + 'allowed_domains' => ['nullable', 'string'], + 'recording_host_only' => ['sometimes', 'boolean'], + 'watermark_enabled' => ['sometimes', 'boolean'], + 'session_idle_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], + ]); + + $domains = collect(explode(',', (string) ($validated['allowed_domains'] ?? ''))) + ->map(fn ($d) => trim($d)) + ->filter() + ->values() + ->all(); + + $security->updateOrganizationPolicy($organization, [ + 'org_only' => $request->boolean('org_only'), + 'authenticated_only' => $request->boolean('authenticated_only'), + 'allowed_domains' => $domains, + 'recording_host_only' => $request->boolean('recording_host_only', true), + 'watermark_enabled' => $request->boolean('watermark_enabled'), + 'session_idle_minutes' => $validated['session_idle_minutes'] ?? 120, + ]); + + return redirect()->route('meet.settings.security')->with('success', 'Security policy updated.'); + } +} diff --git a/app/Http/Controllers/Meet/SummaryController.php b/app/Http/Controllers/Meet/SummaryController.php new file mode 100644 index 0000000..6017acf --- /dev/null +++ b/app/Http/Controllers/Meet/SummaryController.php @@ -0,0 +1,23 @@ +authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $summary); + $summary->load(['session.room', 'actionItems', 'transcript']); + + return view('meet.summaries.show', compact('summary')); + } +} diff --git a/app/Http/Controllers/Meet/TemplateController.php b/app/Http/Controllers/Meet/TemplateController.php new file mode 100644 index 0000000..b7c547d --- /dev/null +++ b/app/Http/Controllers/Meet/TemplateController.php @@ -0,0 +1,68 @@ +authorizeAbility($request, 'meetings.manage'); + $organization = $this->organization($request); + + $templates = Template::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->orderBy('name') + ->get(); + + return view('meet.templates.index', compact('templates', 'organization')); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'meetings.manage'); + + return view('meet.templates.create', [ + 'defaultSettings' => config('meet.default_settings'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.manage'); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], + 'is_default' => ['sometimes', 'boolean'], + 'waiting_room' => ['sometimes', 'boolean'], + 'auto_record' => ['sometimes', 'boolean'], + 'live_captions' => ['sometimes', 'boolean'], + ]); + + $this->templates->create($request->user(), $organization, array_merge($validated, [ + 'settings' => [ + 'waiting_room' => $request->boolean('waiting_room', true), + 'auto_record' => $request->boolean('auto_record'), + 'live_captions' => $request->boolean('live_captions'), + ], + ])); + + return redirect()->route('meet.templates.index')->with('success', 'Template saved.'); + } +} diff --git a/app/Http/Controllers/Meet/WebhookSettingsController.php b/app/Http/Controllers/Meet/WebhookSettingsController.php new file mode 100644 index 0000000..d419823 --- /dev/null +++ b/app/Http/Controllers/Meet/WebhookSettingsController.php @@ -0,0 +1,64 @@ +authorizeAbility($request, 'webhooks.manage'); + $organization = $this->organization($request); + + $endpoints = WebhookEndpoint::where('organization_id', $organization->id) + ->orderByDesc('created_at') + ->get(); + + return view('meet.settings.webhooks', [ + 'organization' => $organization, + 'endpoints' => $endpoints, + 'events' => config('meet.webhook_events'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'webhooks.manage'); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'url' => ['required', 'url', 'max:500'], + 'secret' => ['nullable', 'string', 'max:255'], + 'events' => ['required', 'array'], + 'events.*' => ['string'], + ]); + + WebhookEndpoint::create([ + 'owner_ref' => $this->ownerRef($request), + 'organization_id' => $organization->id, + 'url' => $validated['url'], + 'secret' => $validated['secret'] ?? null, + 'events' => $validated['events'], + 'is_active' => true, + ]); + + return redirect()->route('meet.settings.webhooks')->with('success', 'Webhook endpoint added.'); + } + + public function destroy(Request $request, WebhookEndpoint $webhook): RedirectResponse + { + $this->authorizeAbility($request, 'webhooks.manage'); + abort_unless($webhook->organization_id === $this->organization($request)->id, 403); + $webhook->delete(); + + return redirect()->route('meet.settings.webhooks')->with('success', 'Webhook removed.'); + } +} diff --git a/app/Http/Controllers/Meet/WebinarRegistrationController.php b/app/Http/Controllers/Meet/WebinarRegistrationController.php new file mode 100644 index 0000000..4ea31eb --- /dev/null +++ b/app/Http/Controllers/Meet/WebinarRegistrationController.php @@ -0,0 +1,88 @@ +isWebinar(), 404); + + return view('meet.webinar.register', compact('room')); + } + + public function store(Request $request, Room $room): View + { + abort_unless($room->isWebinar(), 404); + + $validated = $request->validate([ + 'email' => ['required', 'email'], + 'display_name' => ['required', 'string', 'max:100'], + ]); + + $registration = $this->webinars->register( + $room, + $validated['email'], + $validated['display_name'], + ); + + return view('meet.webinar.register-done', compact('room', 'registration')); + } + + public function manage(Request $request, Room $room): View + { + $this->authorizeWebinarHost($request, $room); + + $registrations = $room->webinarRegistrations()->orderByDesc('created_at')->get(); + + return view('meet.webinar.registrations', compact('room', 'registrations')); + } + + public function approve(Request $request, Room $room, \App\Models\WebinarRegistration $registration): RedirectResponse + { + $this->authorizeWebinarHost($request, $room); + abort_unless($registration->room_id === $room->id, 404); + + $this->webinars->approve($registration, $request->user()); + + return back()->with('success', 'Registration approved.'); + } + + public function reject(Request $request, Room $room, \App\Models\WebinarRegistration $registration): RedirectResponse + { + $this->authorizeWebinarHost($request, $room); + abort_unless($registration->room_id === $room->id, 404); + + $this->webinars->reject($registration); + + return back()->with('success', 'Registration rejected.'); + } + + protected function authorizeWebinarHost(Request $request, Room $room): void + { + abort_unless($request->user()?->ownerRef() === $room->host_user_ref, 403); + } +} diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..d438a3a --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,64 @@ +user() + ->notifications() + ->latest() + ->paginate(20); + + return view('notifications.index', compact('notifications')); + } + + public function unread(Request $request): JsonResponse + { + $notifications = $request->user() + ->unreadNotifications() + ->latest() + ->take(10) + ->get() + ->map(fn ($n) => [ + 'id' => $n->id, + 'type' => class_basename($n->type), + 'title' => $n->data['title'] ?? 'Notification', + 'message' => $n->data['message'] ?? '', + 'icon' => $n->data['icon'] ?? 'bell', + 'url' => $n->data['url'] ?? null, + 'created_at' => $n->created_at->diffForHumans(), + ]); + + return response()->json([ + 'notifications' => $notifications, + 'unread_count' => $request->user()->unreadNotifications()->count(), + ]); + } + + public function markAsRead(Request $request, string $id): JsonResponse + { + $notification = $request->user() + ->notifications() + ->where('id', $id) + ->first(); + + if ($notification) { + $notification->markAsRead(); + } + + return response()->json(['success' => true]); + } + + public function markAllAsRead(Request $request): JsonResponse + { + $request->user()->unreadNotifications->markAsRead(); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Middleware/AuthenticateService.php b/app/Http/Middleware/AuthenticateService.php new file mode 100644 index 0000000..b9f5781 --- /dev/null +++ b/app/Http/Middleware/AuthenticateService.php @@ -0,0 +1,38 @@ +bearerToken(); + $caller = null; + + if ($token !== '') { + foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) { + if (is_string($key) && $key !== '' && hash_equals($key, $token)) { + $caller = $name; + break; + } + } + } + + if ($caller === null) { + return response()->json(['error' => 'Unauthorized.'], 401); + } + + $request->attributes->set('service_caller', $caller); + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureMeetAbility.php b/app/Http/Middleware/EnsureMeetAbility.php new file mode 100644 index 0000000..73bf1a1 --- /dev/null +++ b/app/Http/Middleware/EnsureMeetAbility.php @@ -0,0 +1,34 @@ +user(); + abort_unless($user, 403); + + $organization = $this->organizations->resolveForUser($user); + abort_unless($organization, 403); + + $member = $this->organizations->memberFor($user, $organization); + abort_unless($this->permissions->can($member, $ability), 403); + + $request->attributes->set('meet.organization', $organization); + $request->attributes->set('meet.member', $member); + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureOrganizationSetup.php b/app/Http/Middleware/EnsureOrganizationSetup.php new file mode 100644 index 0000000..8edda09 --- /dev/null +++ b/app/Http/Middleware/EnsureOrganizationSetup.php @@ -0,0 +1,33 @@ +user(); + if (! $user) { + return $next($request); + } + + if ($request->routeIs('meet.onboarding*')) { + return $next($request); + } + + if (! $this->organizations->isOnboarded($user)) { + return redirect()->route('meet.onboarding.show'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsurePlatformSession.php b/app/Http/Middleware/EnsurePlatformSession.php new file mode 100644 index 0000000..a3af287 --- /dev/null +++ b/app/Http/Middleware/EnsurePlatformSession.php @@ -0,0 +1,73 @@ +session()->get('platform_session_verified_at', 0); + if ($verifiedAt > 0 && (time() - $verifiedAt) < self::VERIFY_TTL_SECONDS) { + return $next($request); + } + + $cookieHeader = (string) $request->headers->get('Cookie', ''); + if ($cookieHeader === '') { + return $this->clearAppSession($request); + } + + try { + $response = Http::withHeaders(['Cookie' => $cookieHeader]) + ->timeout(3) + ->connectTimeout(2) + ->get('https://'.$authDomain.'/sso/ping'); + } catch (\Throwable) { + return $next($request); + } + + if ($response->status() === 401) { + return $this->clearAppSession($request); + } + + if ($response->successful()) { + $request->session()->put('platform_session_verified_at', time()); + } + + return $next($request); + } + + private function clearAppSession(Request $request): Response + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('sso.connect', [ + 'redirect' => $request->fullUrl(), + ]); + } +} diff --git a/app/Http/Middleware/SetActingAccount.php b/app/Http/Middleware/SetActingAccount.php new file mode 100644 index 0000000..7e1d5c0 --- /dev/null +++ b/app/Http/Middleware/SetActingAccount.php @@ -0,0 +1,29 @@ +user()) { + $request->attributes->set('actingAccount', $user); + + if (! $request->is('api/*')) { + View::share('actingAccount', $user); + } + } + + return $next($request); + } +} diff --git a/app/Jobs/GenerateAiSummary.php b/app/Jobs/GenerateAiSummary.php new file mode 100644 index 0000000..a09ae88 --- /dev/null +++ b/app/Jobs/GenerateAiSummary.php @@ -0,0 +1,27 @@ +transcriptId); + if (! $transcript) { + return; + } + + $ai->summarize($transcript); + } +} diff --git a/app/Jobs/ProcessRecording.php b/app/Jobs/ProcessRecording.php new file mode 100644 index 0000000..32f90a5 --- /dev/null +++ b/app/Jobs/ProcessRecording.php @@ -0,0 +1,27 @@ +recordingId); + if (! $recording || $recording->status !== 'processing') { + return; + } + + $recordings->process($recording); + } +} diff --git a/app/Listeners/PlatformServiceEventListener.php b/app/Listeners/PlatformServiceEventListener.php new file mode 100644 index 0000000..b4d55a3 --- /dev/null +++ b/app/Listeners/PlatformServiceEventListener.php @@ -0,0 +1,85 @@ +name) { + 'user.deleted' => $this->handleUserDeleted($event), + 'user.suspended' => $this->handleUserSuspended($event), + 'organization.updated' => $this->handleOrganizationUpdated($event), + default => null, + }; + } + + protected function handleUserDeleted(ServiceEventOccurred $event): void + { + $publicId = (string) ($event->data['user'] ?? ''); + if ($publicId === '') { + return; + } + + DB::transaction(function () use ($publicId) { + Member::where('user_ref', $publicId)->delete(); + User::where('public_id', $publicId)->delete(); + }); + + Log::info('PlatformServiceEventListener: user deleted', ['public_id' => $publicId]); + } + + protected function handleUserSuspended(ServiceEventOccurred $event): void + { + $publicId = (string) ($event->data['user'] ?? ''); + if ($publicId === '') { + return; + } + + $user = User::where('public_id', $publicId)->first(); + if ($user) { + $user->tokens()->delete(); + } + + Log::info('PlatformServiceEventListener: user suspended', ['public_id' => $publicId]); + } + + protected function handleOrganizationUpdated(ServiceEventOccurred $event): void + { + $ownerRef = (string) ($event->data['owner'] ?? $event->data['user'] ?? ''); + if ($ownerRef === '') { + return; + } + + $organization = Organization::owned($ownerRef)->first(); + if (! $organization) { + return; + } + + $updates = array_filter([ + 'name' => $event->data['name'] ?? null, + 'timezone' => $event->data['timezone'] ?? null, + ], fn ($value) => $value !== null && $value !== ''); + + if ($updates === []) { + return; + } + + $organization->update($updates); + + Log::info('PlatformServiceEventListener: organization updated', [ + 'organization_id' => $organization->id, + 'owner_ref' => $ownerRef, + ]); + } +} diff --git a/app/Mail/ContactMessageMail.php b/app/Mail/ContactMessageMail.php new file mode 100644 index 0000000..8081ad8 --- /dev/null +++ b/app/Mail/ContactMessageMail.php @@ -0,0 +1,40 @@ +subjectLine, + replyTo: $this->replyToAddress ? [$this->replyToAddress] : [], + ); + } + + public function content(): Content + { + return new Content( + view: 'email.contact-message', + with: [ + 'bodyText' => $this->bodyText, + 'fromName' => $this->fromName, + ], + ); + } +} diff --git a/app/Models/ActionItem.php b/app/Models/ActionItem.php new file mode 100644 index 0000000..9b361d2 --- /dev/null +++ b/app/Models/ActionItem.php @@ -0,0 +1,39 @@ + 'date']; + } + + protected static function booted(): void + { + static::creating(function (ActionItem $item) { + if (empty($item->uuid)) { + $item->uuid = (string) Str::uuid(); + } + }); + } + + public function aiSummary(): BelongsTo + { + return $this->belongsTo(AiSummary::class, 'ai_summary_id'); + } +} diff --git a/app/Models/AiSummary.php b/app/Models/AiSummary.php new file mode 100644 index 0000000..2ce1ba0 --- /dev/null +++ b/app/Models/AiSummary.php @@ -0,0 +1,55 @@ + 'array']; + } + + protected static function booted(): void + { + static::creating(function (AiSummary $summary) { + if (empty($summary->uuid)) { + $summary->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function session(): BelongsTo + { + return $this->belongsTo(Session::class, 'session_id'); + } + + public function transcript(): BelongsTo + { + return $this->belongsTo(Transcript::class, 'transcript_id'); + } + + public function actionItems(): HasMany + { + return $this->hasMany(ActionItem::class, 'ai_summary_id'); + } +} diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php new file mode 100644 index 0000000..0e512cc --- /dev/null +++ b/app/Models/AuditLog.php @@ -0,0 +1,56 @@ + 'array', + 'created_at' => 'datetime', + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public static function record( + string $ownerRef, + string $action, + ?int $organizationId = null, + ?string $actorRef = null, + ?string $subjectType = null, + ?int $subjectId = null, + ?array $metadata = null, + ): self { + return static::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organizationId, + 'actor_ref' => $actorRef, + 'action' => $action, + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'metadata' => $metadata, + 'ip_address' => request()?->ip(), + 'created_at' => now(), + ]); + } +} diff --git a/app/Models/Branch.php b/app/Models/Branch.php new file mode 100644 index 0000000..0a82c17 --- /dev/null +++ b/app/Models/Branch.php @@ -0,0 +1,29 @@ + 'boolean']; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } +} diff --git a/app/Models/BreakoutAssignment.php b/app/Models/BreakoutAssignment.php new file mode 100644 index 0000000..89a2e02 --- /dev/null +++ b/app/Models/BreakoutAssignment.php @@ -0,0 +1,23 @@ +belongsTo(BreakoutRoom::class, 'breakout_room_id'); + } + + public function participant(): BelongsTo + { + return $this->belongsTo(Participant::class, 'participant_id'); + } +} diff --git a/app/Models/BreakoutRoom.php b/app/Models/BreakoutRoom.php new file mode 100644 index 0000000..d1ac3c0 --- /dev/null +++ b/app/Models/BreakoutRoom.php @@ -0,0 +1,49 @@ + 'datetime']; + } + + protected static function booted(): void + { + static::creating(function (BreakoutRoom $room) { + if (empty($room->uuid)) { + $room->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function session(): BelongsTo + { + return $this->belongsTo(Session::class, 'session_id'); + } + + public function assignments(): HasMany + { + return $this->hasMany(BreakoutAssignment::class, 'breakout_room_id'); + } +} diff --git a/app/Models/CalendarConnection.php b/app/Models/CalendarConnection.php new file mode 100644 index 0000000..d147caf --- /dev/null +++ b/app/Models/CalendarConnection.php @@ -0,0 +1,49 @@ + 'datetime', + 'metadata' => 'array', + 'access_token' => 'encrypted', + 'refresh_token' => 'encrypted', + ]; + } + + protected static function booted(): void + { + static::creating(function (CalendarConnection $connection) { + if (empty($connection->uuid)) { + $connection->uuid = (string) Str::uuid(); + } + }); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } +} diff --git a/app/Models/Channel.php b/app/Models/Channel.php new file mode 100644 index 0000000..2c2def3 --- /dev/null +++ b/app/Models/Channel.php @@ -0,0 +1,54 @@ +uuid)) { + $channel->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function members(): HasMany + { + return $this->hasMany(ChannelMember::class, 'channel_id'); + } + + public function messages(): HasMany + { + return $this->hasMany(ChannelMessage::class, 'channel_id'); + } + + public function isPrivate(): bool + { + return $this->visibility === 'private'; + } +} diff --git a/app/Models/ChannelMember.php b/app/Models/ChannelMember.php new file mode 100644 index 0000000..9a5bc77 --- /dev/null +++ b/app/Models/ChannelMember.php @@ -0,0 +1,18 @@ +belongsTo(Channel::class, 'channel_id'); + } +} diff --git a/app/Models/ChannelMessage.php b/app/Models/ChannelMessage.php new file mode 100644 index 0000000..72b5386 --- /dev/null +++ b/app/Models/ChannelMessage.php @@ -0,0 +1,49 @@ + 'array']; + } + + protected static function booted(): void + { + static::creating(function (ChannelMessage $message) { + if (empty($message->uuid)) { + $message->uuid = (string) Str::uuid(); + } + }); + } + + public function channel(): BelongsTo + { + return $this->belongsTo(Channel::class, 'channel_id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(ChannelMessage::class, 'parent_id'); + } + + public function replies(): HasMany + { + return $this->hasMany(ChannelMessage::class, 'parent_id'); + } +} diff --git a/app/Models/Concerns/BelongsToOwner.php b/app/Models/Concerns/BelongsToOwner.php new file mode 100644 index 0000000..4d95386 --- /dev/null +++ b/app/Models/Concerns/BelongsToOwner.php @@ -0,0 +1,13 @@ +where($this->getTable().'.owner_ref', $ownerRef); + } +} diff --git a/app/Models/ContactGroup.php b/app/Models/ContactGroup.php new file mode 100644 index 0000000..2d9f41a --- /dev/null +++ b/app/Models/ContactGroup.php @@ -0,0 +1,44 @@ +uuid)) { + $group->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function members(): HasMany + { + return $this->hasMany(ContactGroupMember::class, 'contact_group_id'); + } +} diff --git a/app/Models/ContactGroupMember.php b/app/Models/ContactGroupMember.php new file mode 100644 index 0000000..16ba9d9 --- /dev/null +++ b/app/Models/ContactGroupMember.php @@ -0,0 +1,20 @@ +belongsTo(ContactGroup::class, 'contact_group_id'); + } +} diff --git a/app/Models/DirectConversation.php b/app/Models/DirectConversation.php new file mode 100644 index 0000000..935cd76 --- /dev/null +++ b/app/Models/DirectConversation.php @@ -0,0 +1,47 @@ +uuid)) { + $conversation->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function participants(): HasMany + { + return $this->hasMany(DirectParticipant::class, 'conversation_id'); + } + + public function messages(): HasMany + { + return $this->hasMany(DirectMessage::class, 'conversation_id'); + } +} diff --git a/app/Models/DirectMessage.php b/app/Models/DirectMessage.php new file mode 100644 index 0000000..34d0143 --- /dev/null +++ b/app/Models/DirectMessage.php @@ -0,0 +1,44 @@ +uuid)) { + $message->uuid = (string) Str::uuid(); + } + }); + } + + public function conversation(): BelongsTo + { + return $this->belongsTo(DirectConversation::class, 'conversation_id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(DirectMessage::class, 'parent_id'); + } + + public function replies(): HasMany + { + return $this->hasMany(DirectMessage::class, 'parent_id'); + } +} diff --git a/app/Models/DirectParticipant.php b/app/Models/DirectParticipant.php new file mode 100644 index 0000000..dd73372 --- /dev/null +++ b/app/Models/DirectParticipant.php @@ -0,0 +1,18 @@ +belongsTo(DirectConversation::class, 'conversation_id'); + } +} diff --git a/app/Models/FileDownload.php b/app/Models/FileDownload.php new file mode 100644 index 0000000..e97a7cf --- /dev/null +++ b/app/Models/FileDownload.php @@ -0,0 +1,27 @@ + 'datetime']; + } + + public function file(): BelongsTo + { + return $this->belongsTo(SessionFile::class, 'session_file_id'); + } +} diff --git a/app/Models/Invitation.php b/app/Models/Invitation.php new file mode 100644 index 0000000..8cff444 --- /dev/null +++ b/app/Models/Invitation.php @@ -0,0 +1,56 @@ + 'datetime', + 'responded_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (Invitation $invitation) { + if (empty($invitation->uuid)) { + $invitation->uuid = (string) Str::uuid(); + } + if (empty($invitation->rsvp_token)) { + $invitation->rsvp_token = Str::random(40); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function rsvpUrl(): string + { + return route('meet.invitations.rsvp', ['invitation' => $this->uuid, 'token' => $this->rsvp_token]); + } + + public function room(): BelongsTo + { + return $this->belongsTo(Room::class, 'room_id'); + } +} diff --git a/app/Models/LiveStream.php b/app/Models/LiveStream.php new file mode 100644 index 0000000..cfbb449 --- /dev/null +++ b/app/Models/LiveStream.php @@ -0,0 +1,61 @@ + 'datetime', + 'ended_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (LiveStream $stream) { + if (empty($stream->uuid)) { + $stream->uuid = (string) Str::uuid(); + } + }); + } + + public function session(): BelongsTo + { + return $this->belongsTo(Session::class, 'session_id'); + } + + public function setStreamKeyAttribute(?string $value): void + { + $this->attributes['stream_key'] = $value ? Crypt::encryptString($value) : null; + } + + public function getStreamKeyAttribute(?string $value): ?string + { + if (! $value) { + return null; + } + + try { + return Crypt::decryptString($value); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Models/MeetPoll.php b/app/Models/MeetPoll.php new file mode 100644 index 0000000..3f169ca --- /dev/null +++ b/app/Models/MeetPoll.php @@ -0,0 +1,49 @@ + 'array']; + } + + protected static function booted(): void + { + static::creating(function (MeetPoll $poll) { + if (empty($poll->uuid)) { + $poll->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function session(): BelongsTo + { + return $this->belongsTo(Session::class, 'session_id'); + } + + public function votes(): HasMany + { + return $this->hasMany(PollVote::class, 'poll_id'); + } +} diff --git a/app/Models/Member.php b/app/Models/Member.php new file mode 100644 index 0000000..4898105 --- /dev/null +++ b/app/Models/Member.php @@ -0,0 +1,31 @@ +belongsTo(Organization::class, 'organization_id'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class, 'branch_id'); + } + + public function hasRole(string ...$roles): bool + { + return in_array($this->role, $roles, true); + } +} diff --git a/app/Models/Message.php b/app/Models/Message.php new file mode 100644 index 0000000..a0238b0 --- /dev/null +++ b/app/Models/Message.php @@ -0,0 +1,39 @@ + 'array']; + } + + protected static function booted(): void + { + static::creating(function (Message $message) { + if (empty($message->uuid)) { + $message->uuid = (string) Str::uuid(); + } + }); + } + + public function session(): BelongsTo + { + return $this->belongsTo(Session::class, 'session_id'); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 0000000..512b777 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,49 @@ + 'array', + 'security_policy' => 'array', + 'usage_quotas' => 'array', + ]; + } + + public function branches(): HasMany + { + return $this->hasMany(Branch::class, 'organization_id'); + } + + public function members(): HasMany + { + return $this->hasMany(Member::class, 'organization_id'); + } + + public function templates(): HasMany + { + return $this->hasMany(Template::class, 'organization_id'); + } + + public function securitySetting(string $key, mixed $default = null): mixed + { + return data_get($this->security_policy, $key, data_get(config('meet.security_defaults'), $key, $default)); + } +} diff --git a/app/Models/Participant.php b/app/Models/Participant.php new file mode 100644 index 0000000..616b7a0 --- /dev/null +++ b/app/Models/Participant.php @@ -0,0 +1,81 @@ + 'datetime', + 'left_at' => 'datetime', + 'hand_raised' => 'boolean', + 'is_muted' => 'boolean', + 'is_video_off' => 'boolean', + ]; + } + + protected static function booted(): void + { + static::creating(function (Participant $participant) { + if (empty($participant->uuid)) { + $participant->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function session(): BelongsTo + { + return $this->belongsTo(Session::class, 'session_id'); + } + + public function isHost(): bool + { + return in_array($this->role, ['host', 'co_host'], true); + } + + public function isPanelist(): bool + { + return $this->role === 'panelist'; + } + + public function isAttendee(): bool + { + return $this->role === 'attendee'; + } + + public function canPublishMedia(): bool + { + return $this->role !== 'attendee'; + } + + public function breakoutRoom(): BelongsTo + { + return $this->belongsTo(BreakoutRoom::class, 'breakout_room_id'); + } + + public function isJoined(): bool + { + return $this->status === 'joined'; + } +} diff --git a/app/Models/PollVote.php b/app/Models/PollVote.php new file mode 100644 index 0000000..3a6f192 --- /dev/null +++ b/app/Models/PollVote.php @@ -0,0 +1,18 @@ +belongsTo(MeetPoll::class, 'poll_id'); + } +} diff --git a/app/Models/QaQuestion.php b/app/Models/QaQuestion.php new file mode 100644 index 0000000..e4d674d --- /dev/null +++ b/app/Models/QaQuestion.php @@ -0,0 +1,39 @@ +uuid)) { + $q->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function session(): BelongsTo + { + return $this->belongsTo(Session::class, 'session_id'); + } +} diff --git a/app/Models/Reaction.php b/app/Models/Reaction.php new file mode 100644 index 0000000..f3475a2 --- /dev/null +++ b/app/Models/Reaction.php @@ -0,0 +1,23 @@ +belongsTo(Session::class, 'session_id'); + } +} diff --git a/app/Models/Recording.php b/app/Models/Recording.php new file mode 100644 index 0000000..d926ec4 --- /dev/null +++ b/app/Models/Recording.php @@ -0,0 +1,54 @@ + 'datetime', + 'ended_at' => 'datetime', + 'metadata' => 'array', + ]; + } + + protected static function booted(): void + { + static::creating(function (Recording $recording) { + if (empty($recording->uuid)) { + $recording->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function session(): BelongsTo + { + return $this->belongsTo(Session::class, 'session_id'); + } + + public function isReady(): bool + { + return $this->status === 'ready'; + } +} diff --git a/app/Models/Room.php b/app/Models/Room.php new file mode 100644 index 0000000..8f1ce2f --- /dev/null +++ b/app/Models/Room.php @@ -0,0 +1,118 @@ + 'datetime', + 'recurrence_until' => 'date', + 'settings' => 'array', + 'source' => 'array', + ]; + } + + protected static function booted(): void + { + static::creating(function (Room $room) { + if (empty($room->uuid)) { + $room->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class, 'branch_id'); + } + + public function sessions(): HasMany + { + return $this->hasMany(Session::class, 'room_id'); + } + + public function invitations(): HasMany + { + return $this->hasMany(Invitation::class, 'room_id'); + } + + public function webinarRegistrations(): HasMany + { + return $this->hasMany(WebinarRegistration::class, 'room_id'); + } + + public function sessionFiles(): HasMany + { + return $this->hasMany(SessionFile::class, 'room_id'); + } + + public function isWebinar(): bool + { + return $this->type === 'webinar'; + } + + public function isTownHall(): bool + { + return $this->type === 'town_hall'; + } + + public function template(): BelongsTo + { + return $this->belongsTo(Template::class, 'template_id'); + } + + public function isRecurring(): bool + { + return filled($this->recurrence_rule); + } + + public function activeSession(): ?Session + { + return $this->sessions()->where('status', 'live')->latest()->first(); + } + + public function setting(string $key, mixed $default = null): mixed + { + return data_get($this->settings, $key, data_get(config('meet.default_settings'), $key, $default)); + } + + public function isLive(): bool + { + return $this->status === 'live'; + } + + public function joinUrl(): string + { + return url('/r/'.$this->uuid); + } +} diff --git a/app/Models/Session.php b/app/Models/Session.php new file mode 100644 index 0000000..4d48bbb --- /dev/null +++ b/app/Models/Session.php @@ -0,0 +1,123 @@ + 'datetime', + 'ended_at' => 'datetime', + 'locked_at' => 'datetime', + 'is_locked' => 'boolean', + 'is_breakout' => 'boolean', + 'presenter_refs' => 'array', + ]; + } + + protected static function booted(): void + { + static::creating(function (Session $session) { + if (empty($session->uuid)) { + $session->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function room(): BelongsTo + { + return $this->belongsTo(Room::class, 'room_id'); + } + + public function participants(): HasMany + { + return $this->hasMany(Participant::class, 'session_id'); + } + + public function messages(): HasMany + { + return $this->hasMany(Message::class, 'session_id'); + } + + public function reactions(): HasMany + { + return $this->hasMany(Reaction::class, 'session_id'); + } + + public function recordings(): HasMany + { + return $this->hasMany(Recording::class, 'session_id'); + } + + public function transcripts(): HasMany + { + return $this->hasMany(Transcript::class, 'session_id'); + } + + public function aiSummaries(): HasMany + { + return $this->hasMany(AiSummary::class, 'session_id'); + } + + public function qaQuestions(): HasMany + { + return $this->hasMany(QaQuestion::class, 'session_id'); + } + + public function polls(): HasMany + { + return $this->hasMany(MeetPoll::class, 'session_id'); + } + + public function breakoutRooms(): HasMany + { + return $this->hasMany(BreakoutRoom::class, 'session_id'); + } + + public function whiteboard(): \Illuminate\Database\Eloquent\Relations\HasOne + { + return $this->hasOne(Whiteboard::class, 'session_id'); + } + + public function sessionFiles(): HasMany + { + return $this->hasMany(SessionFile::class, 'session_id'); + } + + public function liveStreams(): HasMany + { + return $this->hasMany(LiveStream::class, 'session_id'); + } + + public function activeParticipants(): HasMany + { + return $this->participants()->where('status', 'joined'); + } + + public function isLive(): bool + { + return $this->status === 'live'; + } +} diff --git a/app/Models/SessionFile.php b/app/Models/SessionFile.php new file mode 100644 index 0000000..c3fc22e --- /dev/null +++ b/app/Models/SessionFile.php @@ -0,0 +1,60 @@ + 'datetime']; + } + + protected static function booted(): void + { + static::creating(function (SessionFile $file) { + if (empty($file->uuid)) { + $file->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function room(): BelongsTo + { + return $this->belongsTo(Room::class, 'room_id'); + } + + public function session(): BelongsTo + { + return $this->belongsTo(Session::class, 'session_id'); + } + + public function downloads(): HasMany + { + return $this->hasMany(FileDownload::class, 'session_file_id'); + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } +} diff --git a/app/Models/Template.php b/app/Models/Template.php new file mode 100644 index 0000000..c38bdb9 --- /dev/null +++ b/app/Models/Template.php @@ -0,0 +1,50 @@ + 'array', 'is_default' => 'boolean']; + } + + protected static function booted(): void + { + static::creating(function (Template $template) { + if (empty($template->uuid)) { + $template->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function mergedSettings(): array + { + return array_merge(config('meet.default_settings'), $this->settings ?? []); + } +} diff --git a/app/Models/Transcript.php b/app/Models/Transcript.php new file mode 100644 index 0000000..13aaac3 --- /dev/null +++ b/app/Models/Transcript.php @@ -0,0 +1,50 @@ + 'array', 'live_captions_enabled' => 'boolean']; + } + + protected static function booted(): void + { + static::creating(function (Transcript $transcript) { + if (empty($transcript->uuid)) { + $transcript->uuid = (string) Str::uuid(); + } + }); + } + + public function session(): BelongsTo + { + return $this->belongsTo(Session::class, 'session_id'); + } + + public function recording(): BelongsTo + { + return $this->belongsTo(Recording::class, 'recording_id'); + } + + public function aiSummary(): HasOne + { + return $this->hasOne(AiSummary::class, 'transcript_id'); + } +} diff --git a/app/Models/UsageRecord.php b/app/Models/UsageRecord.php new file mode 100644 index 0000000..0f72985 --- /dev/null +++ b/app/Models/UsageRecord.php @@ -0,0 +1,31 @@ + 'array', + 'recorded_at' => 'datetime', + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..1f15e02 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,41 @@ + 'datetime', + 'last_app_active_at' => 'datetime', + 'password' => 'hashed', + ]; + } + + public function ownerRef(): string + { + return (string) $this->public_id; + } + + public function avatarUrl(): ?string + { + $url = trim((string) $this->avatar_url); + + return $url !== '' ? $url : null; + } +} diff --git a/app/Models/WebhookEndpoint.php b/app/Models/WebhookEndpoint.php new file mode 100644 index 0000000..67404aa --- /dev/null +++ b/app/Models/WebhookEndpoint.php @@ -0,0 +1,51 @@ + 'array', + 'is_active' => 'boolean', + 'secret' => 'encrypted', + ]; + } + + protected static function booted(): void + { + static::creating(function (WebhookEndpoint $endpoint) { + if (empty($endpoint->uuid)) { + $endpoint->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function subscribesTo(string $event): bool + { + $events = $this->events ?? config('meet.webhook_events', []); + + return in_array('*', $events, true) || in_array($event, $events, true); + } +} diff --git a/app/Models/WebinarRegistration.php b/app/Models/WebinarRegistration.php new file mode 100644 index 0000000..c009166 --- /dev/null +++ b/app/Models/WebinarRegistration.php @@ -0,0 +1,52 @@ + 'datetime', 'metadata' => 'array']; + } + + protected static function booted(): void + { + static::creating(function (WebinarRegistration $reg) { + if (empty($reg->uuid)) { + $reg->uuid = (string) Str::uuid(); + } + if (empty($reg->access_token)) { + $reg->access_token = Str::random(48); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function room(): BelongsTo + { + return $this->belongsTo(Room::class, 'room_id'); + } + + public function isApproved(): bool + { + return $this->status === 'approved'; + } +} diff --git a/app/Models/Whiteboard.php b/app/Models/Whiteboard.php new file mode 100644 index 0000000..e89f8ad --- /dev/null +++ b/app/Models/Whiteboard.php @@ -0,0 +1,48 @@ + 'array']; + } + + protected static function booted(): void + { + static::creating(function (Whiteboard $board) { + if (empty($board->uuid)) { + $board->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function room(): BelongsTo + { + return $this->belongsTo(Room::class, 'room_id'); + } + + public function session(): BelongsTo + { + return $this->belongsTo(Session::class, 'session_id'); + } +} diff --git a/app/Notifications/AiSummaryReadyNotification.php b/app/Notifications/AiSummaryReadyNotification.php new file mode 100644 index 0000000..7ee130a --- /dev/null +++ b/app/Notifications/AiSummaryReadyNotification.php @@ -0,0 +1,32 @@ + 'ai_summary_ready', + 'title' => 'AI summary ready', + 'body' => 'Summary for '.$this->summary->session->room->title.' is ready.', + 'url' => route('meet.summaries.show', $this->summary), + 'summary_uuid' => $this->summary->uuid, + ]; + } +} diff --git a/app/Notifications/MeetingInvitationNotification.php b/app/Notifications/MeetingInvitationNotification.php new file mode 100644 index 0000000..bbf5d3c --- /dev/null +++ b/app/Notifications/MeetingInvitationNotification.php @@ -0,0 +1,34 @@ + 'meeting_invitation', + 'title' => 'Meeting invitation', + 'body' => $this->host->name.' invited you to '.$this->room->title, + 'url' => $this->room->joinUrl(), + 'room_uuid' => $this->room->uuid, + ]; + } +} diff --git a/app/Notifications/MeetingReminderNotification.php b/app/Notifications/MeetingReminderNotification.php new file mode 100644 index 0000000..5f50b5d --- /dev/null +++ b/app/Notifications/MeetingReminderNotification.php @@ -0,0 +1,32 @@ + 'meeting_reminder', + 'title' => 'Meeting reminder', + 'body' => $this->room->title.' starts soon.', + 'url' => route('meet.rooms.show', $this->room), + 'room_uuid' => $this->room->uuid, + ]; + } +} diff --git a/app/Notifications/RecordingReadyNotification.php b/app/Notifications/RecordingReadyNotification.php new file mode 100644 index 0000000..8022188 --- /dev/null +++ b/app/Notifications/RecordingReadyNotification.php @@ -0,0 +1,32 @@ + 'recording_ready', + 'title' => 'Recording ready', + 'body' => 'Recording for '.$this->recording->session->room->title.' is ready.', + 'url' => route('meet.recordings.show', $this->recording), + 'recording_uuid' => $this->recording->uuid, + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..5edb1e0 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,28 @@ +app->bind(MediaProviderInterface::class, LiveKitProvider::class); + } + + public function boot(): void + { + Event::listen(ServiceEventOccurred::class, PlatformServiceEventListener::class); + + View::composer(['partials.topbar'], function ($view) { + $view->with(\App\Support\MobileTopbar::resolve()); + }); + } +} diff --git a/app/Services/Billing/BillingClient.php b/app/Services/Billing/BillingClient.php new file mode 100644 index 0000000..4b97295 --- /dev/null +++ b/app/Services/Billing/BillingClient.php @@ -0,0 +1,64 @@ +token())->acceptJson()->timeout(8) + ->get($this->base().'/balance', ['user' => $publicId]); + $res->throw(); + + return (int) ($res->json('balance_minor') ?? 0); + } + + public function canAfford(string $publicId, int $amountMinor): bool + { + $res = Http::withToken($this->token())->acceptJson()->timeout(8) + ->get($this->base().'/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor]); + $res->throw(); + + return (bool) ($res->json('affordable') ?? false); + } + + /** + * Debit the wallet. Returns true on success, false on insufficient balance + * (HTTP 402). Idempotent by $reference. + */ + public function debit(string $publicId, int $amountMinor, string $source, string $reference, ?string $description = null): bool + { + $res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([ + 'user' => $publicId, + 'amount_minor' => $amountMinor, + 'service' => (string) config('billing.service', 'crm'), + 'source' => $source, + 'reference' => $reference, + 'description' => $description, + ], static fn ($v) => $v !== null)); + + if ($res->status() === 402) { + return false; + } + $res->throw(); + + return true; + } +} diff --git a/app/Services/Billing/OneTimePurchaseService.php b/app/Services/Billing/OneTimePurchaseService.php new file mode 100644 index 0000000..6fd9aa7 --- /dev/null +++ b/app/Services/Billing/OneTimePurchaseService.php @@ -0,0 +1,76 @@ +|null */ + public function product(string $productKey): ?array + { + $product = config('crm_products.'.$productKey); + + return is_array($product) ? $product : null; + } + + public function hasPurchased(string $ownerRef, string $productKey): bool + { + return CrmPurchase::has($ownerRef, $productKey); + } + + public function priceMinor(string $productKey): int + { + return (int) ($this->product($productKey)['price_minor'] ?? 0); + } + + public function purchase(string $ownerRef, string $productKey): bool + { + if ($this->hasPurchased($ownerRef, $productKey)) { + return true; + } + + $product = $this->product($productKey); + if (! $product) { + return false; + } + + $costMinor = (int) ($product['price_minor'] ?? 0); + + if ($costMinor > 0) { + try { + if (! $this->billing->canAfford($ownerRef, $costMinor)) { + return false; + } + + $charged = $this->billing->debit( + $ownerRef, + $costMinor, + 'purchase', + 'crm-purchase-'.$productKey.'-'.$ownerRef, + 'CRM: '.($product['name'] ?? $productKey), + ); + + if (! $charged) { + return false; + } + } catch (\Throwable) { + return false; + } + } + + CrmPurchase::create([ + 'owner_ref' => $ownerRef, + 'product_key' => $productKey, + 'cost_minor' => $costMinor, + 'purchased_at' => now(), + ]); + + return true; + } +} diff --git a/app/Services/Comms/EmailService.php b/app/Services/Comms/EmailService.php new file mode 100644 index 0000000..bf33e0b --- /dev/null +++ b/app/Services/Comms/EmailService.php @@ -0,0 +1,32 @@ +send(new ContactMessageMail($subject, $body, $fromName, $replyTo)); + + return true; + } catch (\Throwable $e) { + Log::warning('CRM email send failed', ['to' => $to, 'error' => $e->getMessage()]); + + return false; + } + } +} diff --git a/app/Services/Comms/SmsService.php b/app/Services/Comms/SmsService.php new file mode 100644 index 0000000..4bea672 --- /dev/null +++ b/app/Services/Comms/SmsService.php @@ -0,0 +1,67 @@ +normalise($to); + if ($apiKey === '' || $msisdn === null) { + return false; + } + + try { + $res = Http::timeout(20) + ->withHeaders(['api-key' => $apiKey]) + ->acceptJson() + ->post(rtrim((string) config('arkesel.base_url', 'https://sms.arkesel.com'), '/').'/api/v2/sms/send', [ + 'sender' => $sender, + 'message' => $message, + 'recipients' => [$msisdn], + ]); + + return $res->successful() && ($res->json('status') === 'success'); + } catch (\Throwable $e) { + Log::warning('CRM SMS send failed', ['to' => $msisdn, 'error' => $e->getMessage()]); + + return false; + } + } + + /** Normalise to E.164-without-plus (e.g. 233XXXXXXXXX). */ + private function normalise(string $to): ?string + { + $digits = preg_replace('/\D+/', '', $to) ?? ''; + $country = (string) config('arkesel.default_country_code', '233'); + + if (strlen($digits) < 9) { + return null; + } + + if (str_starts_with($digits, $country)) { + return $digits; + } + + if (str_starts_with($digits, '0')) { + return $country.substr($digits, 1); + } + + if (strlen($digits) === 9) { + return $country.$digits; + } + + return $digits; + } +} diff --git a/app/Services/CrossApp/CrossAppLinkService.php b/app/Services/CrossApp/CrossAppLinkService.php new file mode 100644 index 0000000..02ff916 --- /dev/null +++ b/app/Services/CrossApp/CrossAppLinkService.php @@ -0,0 +1,112 @@ +loadMissing(['customer', 'lines']); + + $customer = $deal->customer; + $lines = $deal->lines->map(fn ($line) => [ + 'description' => $line->description, + 'quantity' => (float) $line->quantity, + 'unit_price' => number_format($line->unit_price_minor / 100, 2, '.', ''), + ])->values()->all(); + + if ($lines === [] && (int) $deal->value_minor > 0) { + $lines = [[ + 'description' => $deal->title, + 'quantity' => 1, + 'unit_price' => number_format($deal->value_minor / 100, 2, '.', ''), + ]]; + } + + $prefill = CrmPrefillCodec::encode([ + 'kind' => 'invoice', + 'crm_customer_id' => $customer?->id, + 'client_name' => $customer?->name ?: $deal->title, + 'client_email' => $customer?->email, + 'client_address' => $customer ? $this->formatAddress($customer) : null, + 'notes' => 'Invoice for CRM deal: '.$deal->title, + 'payment_enabled' => true, + 'lines' => $lines, + ]); + + return LadillAppUrl::connect('invoice', '/invoices/create?prefill='.urlencode($prefill)); + } + + public function merchantStorefrontFromDeal(Deal $deal): string + { + $deal->loadMissing('customer'); + + $amount = number_format(((int) $deal->value_minor) / 100, 2, '.', ''); + $itemName = $deal->title; + + $prefill = CrmPrefillCodec::encode([ + 'kind' => 'merchant_storefront', + 'type' => 'shop', + 'label' => 'Payment — '.$deal->title, + 'shop_title' => $deal->customer?->company ?: $deal->title, + 'sections' => [[ + 'name' => 'Payment', + 'items' => [[ + 'name' => $itemName, + 'description' => $deal->notes ?: 'Payment for '.$deal->title, + 'price' => $amount, + 'image_path' => '', + ]], + ]], + 'accepts_payment' => true, + ]); + + return LadillAppUrl::connect('merchant', '/storefronts/create?prefill='.urlencode($prefill)); + } + + public function businessQrFromContact(Customer $contact): string + { + $prefill = CrmPrefillCodec::encode([ + 'kind' => 'qr_business', + 'type' => 'business', + 'label' => 'Card — '.$contact->name, + 'name' => $contact->company ?: $contact->name, + 'phone' => $contact->phone, + 'email' => $contact->email, + 'address' => $this->formatAddress($contact, singleLine: true), + ]); + + return LadillAppUrl::connect('qrplus', '/qr-codes/create?prefill='.urlencode($prefill)); + } + + public function eventsHubForContact(Customer $contact): string + { + $query = http_build_query(array_filter([ + 'search' => $contact->company ?: $contact->name, + ])); + + return LadillAppUrl::connect('events', '/attendees'.($query !== '' ? '?'.$query : '')); + } + + private function formatAddress(Customer $contact, bool $singleLine = false): ?string + { + $parts = array_filter([ + $contact->address_line1, + $contact->address_line2, + trim(implode(', ', array_filter([$contact->city, $contact->region, $contact->country]))), + ]); + + if ($parts === []) { + return null; + } + + return $singleLine + ? implode(', ', $parts) + : implode("\n", $parts); + } +} diff --git a/app/Services/Events/ServiceEventSignature.php b/app/Services/Events/ServiceEventSignature.php new file mode 100644 index 0000000..694ec57 --- /dev/null +++ b/app/Services/Events/ServiceEventSignature.php @@ -0,0 +1,23 @@ +loadMissing('room'); + $this->dispatch($session->room->organization_id, 'meeting.started', $this->sessionPayload($session)); + } + + public function meetingEnded(Session $session): void + { + $session->loadMissing('room'); + $this->dispatch($session->room->organization_id, 'meeting.ended', $this->sessionPayload($session)); + } + + public function recordingReady(Recording $recording): void + { + $recording->loadMissing('session.room'); + $payload = array_merge($this->sessionPayload($recording->session), [ + 'recording' => [ + 'uuid' => $recording->uuid, + 'status' => $recording->status, + 'duration_seconds' => $recording->duration_seconds, + 'download_url' => route('meet.recordings.download', $recording), + ], + ]); + + $this->dispatch($recording->session->room->organization_id, 'recording.ready', $payload); + } + + public function roomCreated(Room $room): void + { + $this->dispatch($room->organization_id, 'meeting.scheduled', $this->roomPayload($room)); + } + + public function roomCancelled(Room $room): void + { + $this->dispatch($room->organization_id, 'meeting.cancelled', $this->roomPayload($room)); + } + + /** @param array $payload */ + protected function dispatch(int $organizationId, string $event, array $payload): void + { + $body = array_merge(['event' => $event, 'timestamp' => now()->toIso8601String()], $payload); + + WebhookEndpoint::query() + ->where('organization_id', $organizationId) + ->where('is_active', true) + ->get() + ->filter(fn (WebhookEndpoint $endpoint) => $endpoint->subscribesTo($event)) + ->each(fn (WebhookEndpoint $endpoint) => $this->send($endpoint, $body)); + } + + /** @param array $payload */ + protected function send(WebhookEndpoint $endpoint, array $payload): void + { + $body = json_encode($payload); + $headers = ['Content-Type' => 'application/json', 'X-Ladill-Meet-Event' => $payload['event']]; + + if ($endpoint->secret) { + $headers['X-Ladill-Meet-Signature'] = hash_hmac('sha256', $body, $endpoint->secret); + } + + try { + Http::timeout(10)->withHeaders($headers)->withBody($body, 'application/json')->post($endpoint->url); + } catch (\Throwable $e) { + Log::warning('Meet webhook delivery failed', [ + 'endpoint_id' => $endpoint->id, + 'url' => $endpoint->url, + 'error' => $e->getMessage(), + ]); + } + } + + /** @return array */ + protected function roomPayload(Room $room): array + { + return [ + 'room' => [ + 'uuid' => $room->uuid, + 'title' => $room->title, + 'status' => $room->status, + 'type' => $room->type, + 'scheduled_at' => $room->scheduled_at?->toIso8601String(), + 'join_url' => $room->joinUrl(), + 'source' => $room->source, + ], + ]; + } + + /** @return array */ + protected function sessionPayload(Session $session): array + { + return array_merge($this->roomPayload($session->room), [ + 'session' => [ + 'uuid' => $session->uuid, + 'status' => $session->status, + 'started_at' => $session->started_at?->toIso8601String(), + 'ended_at' => $session->ended_at?->toIso8601String(), + 'peak_participants' => $session->peak_participants, + ], + ]); + } +} diff --git a/app/Services/Meet/AttendanceService.php b/app/Services/Meet/AttendanceService.php new file mode 100644 index 0000000..77974e8 --- /dev/null +++ b/app/Services/Meet/AttendanceService.php @@ -0,0 +1,46 @@ +> + */ + public function forSession(Session $session): Collection + { + return $session->participants() + ->orderBy('joined_at') + ->get() + ->map(fn ($p) => [ + 'display_name' => $p->display_name, + 'email' => $p->email, + 'role' => $p->role, + 'joined_at' => $p->joined_at?->toIso8601String(), + 'left_at' => $p->left_at?->toIso8601String(), + 'duration_minutes' => ($p->joined_at && $p->left_at) + ? (int) $p->joined_at->diffInMinutes($p->left_at) + : ($p->joined_at ? (int) $p->joined_at->diffInMinutes(now()) : 0), + ]); + } + + public function toCsv(Session $session): string + { + $rows = [['Name', 'Email', 'Role', 'Joined', 'Left', 'Duration (min)']]; + foreach ($this->forSession($session) as $row) { + $rows[] = [ + $row['display_name'], + $row['email'] ?? '', + $row['role'], + $row['joined_at'] ?? '', + $row['left_at'] ?? '', + (string) $row['duration_minutes'], + ]; + } + + return collect($rows)->map(fn ($r) => implode(',', array_map(fn ($c) => '"'.str_replace('"', '""', $c).'"', $r)))->implode("\n"); + } +} diff --git a/app/Services/Meet/AuditLogger.php b/app/Services/Meet/AuditLogger.php new file mode 100644 index 0000000..c4a5499 --- /dev/null +++ b/app/Services/Meet/AuditLogger.php @@ -0,0 +1,28 @@ +>} + */ + public function checkHostAvailability( + string $hostUserRef, + int $organizationId, + Carbon $start, + int $durationMinutes, + ?int $excludeRoomId = null, + ): array { + $end = (clone $start)->addMinutes($durationMinutes); + + $conflicts = Room::query() + ->where('organization_id', $organizationId) + ->where('host_user_ref', $hostUserRef) + ->whereIn('status', ['scheduled', 'live']) + ->when($excludeRoomId, fn ($q) => $q->where('id', '!=', $excludeRoomId)) + ->whereNotNull('scheduled_at') + ->get() + ->filter(function (Room $room) use ($start, $end) { + $roomStart = Carbon::parse($room->scheduled_at); + $roomEnd = (clone $roomStart)->addMinutes($room->duration_minutes ?? 60); + + return $start->lt($roomEnd) && $end->gt($roomStart); + }) + ->map(fn (Room $room) => [ + 'uuid' => $room->uuid, + 'title' => $room->title, + 'scheduled_at' => $room->scheduled_at?->toIso8601String(), + 'duration_minutes' => $room->duration_minutes, + ]) + ->values() + ->all(); + + return [ + 'available' => count($conflicts) === 0, + 'conflicts' => $conflicts, + ]; + } +} diff --git a/app/Services/Meet/BreakoutService.php b/app/Services/Meet/BreakoutService.php new file mode 100644 index 0000000..89a22e9 --- /dev/null +++ b/app/Services/Meet/BreakoutService.php @@ -0,0 +1,106 @@ +|null $assignments + * @return \Illuminate\Support\Collection + */ + public function createRooms(Session $session, int $count, ?int $timerMinutes = null, ?array $assignments = null) + { + $timerMinutes ??= config('meet.breakouts.default_timer_minutes', 15); + $closesAt = now()->addMinutes($timerMinutes); + $rooms = collect(); + + for ($i = 1; $i <= $count; $i++) { + $mediaName = $session->media_room_name.'-breakout-'.Str::random(8); + $this->media->createRoom($mediaName); + + $rooms->push(BreakoutRoom::create([ + 'owner_ref' => $session->owner_ref, + 'session_id' => $session->id, + 'name' => 'Breakout '.$i, + 'media_room_name' => $mediaName, + 'status' => 'open', + 'closes_at' => $closesAt, + ])); + } + + $participants = $session->participants()->where('status', 'joined')->get(); + + if ($assignments) { + foreach ($assignments as $assignment) { + $this->assignParticipant( + $rooms->firstWhere('id', $assignment['breakout_room_id']) ?? $rooms->first(), + Participant::findOrFail($assignment['participant_id']), + ); + } + } else { + $participants->values()->each(function (Participant $participant, int $index) use ($rooms) { + $this->assignParticipant($rooms[$index % $rooms->count()], $participant); + }); + } + + return $rooms; + } + + public function assignParticipant(BreakoutRoom $breakout, Participant $participant): void + { + BreakoutAssignment::updateOrCreate( + ['breakout_room_id' => $breakout->id, 'participant_id' => $participant->id], + ); + $participant->update(['breakout_room_id' => $breakout->id]); + } + + public function moveParticipant(Participant $participant, BreakoutRoom $breakout): void + { + $this->assignParticipant($breakout, $participant); + } + + public function returnToMain(Participant $participant): void + { + $participant->update(['breakout_room_id' => null]); + BreakoutAssignment::where('participant_id', $participant->id)->delete(); + } + + public function broadcast(Session $session, string $message, string $senderName): Message + { + return Message::create([ + 'owner_ref' => $session->owner_ref, + 'session_id' => $session->id, + 'sender_name' => $senderName, + 'type' => 'breakout_broadcast', + 'body' => $message, + ]); + } + + public function closeAll(Session $session): void + { + $session->breakoutRooms()->update(['status' => 'closed']); + $session->participants()->whereNotNull('breakout_room_id')->update(['breakout_room_id' => null]); + BreakoutAssignment::whereIn('breakout_room_id', $session->breakoutRooms()->pluck('id'))->delete(); + } + + public function mediaRoomForParticipant(Participant $participant): string + { + if ($participant->breakout_room_id) { + return $participant->breakoutRoom?->media_room_name ?? $participant->session->media_room_name; + } + + return $participant->session->media_room_name; + } +} diff --git a/app/Services/Meet/Calendar/GoogleCalendarProvider.php b/app/Services/Meet/Calendar/GoogleCalendarProvider.php new file mode 100644 index 0000000..8173291 --- /dev/null +++ b/app/Services/Meet/Calendar/GoogleCalendarProvider.php @@ -0,0 +1,108 @@ + $clientId, + 'redirect_uri' => $redirect, + 'response_type' => 'code', + 'scope' => 'https://www.googleapis.com/auth/calendar.events', + 'access_type' => 'offline', + 'prompt' => 'consent', + ]); + + return 'https://accounts.google.com/o/oauth2/v2/auth?'.$params; + } + + /** + * @return array|null + */ + public function exchangeCode(string $code): ?array + { + $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'code' => $code, + 'client_id' => config('meet.calendar.google.client_id'), + 'client_secret' => config('meet.calendar.google.client_secret'), + 'redirect_uri' => config('meet.calendar.google.redirect'), + 'grant_type' => 'authorization_code', + ]); + + return $response->successful() ? $response->json() : null; + } + + public function createEvent(CalendarConnection $connection, Room $room, User $host): ?string + { + if (! $connection->access_token) { + return null; + } + + $response = Http::withToken($connection->access_token)->post( + 'https://www.googleapis.com/calendar/v3/calendars/'.urlencode($connection->calendar_id ?? 'primary').'/events', + $this->eventPayload($room, $host), + ); + + return $response->successful() ? $response->json('id') : null; + } + + public function updateEvent(CalendarConnection $connection, Room $room, User $host): bool + { + if (! $connection->access_token || ! $room->calendar_event_id) { + return false; + } + + $calendar = urlencode($connection->calendar_id ?? 'primary'); + $event = urlencode($room->calendar_event_id); + + return Http::withToken($connection->access_token)->put( + "https://www.googleapis.com/calendar/v3/calendars/{$calendar}/events/{$event}", + $this->eventPayload($room, $host), + )->successful(); + } + + public function deleteEvent(CalendarConnection $connection, Room $room): bool + { + if (! $connection->access_token || ! $room->calendar_event_id) { + return false; + } + + $calendar = urlencode($connection->calendar_id ?? 'primary'); + $event = urlencode($room->calendar_event_id); + + return Http::withToken($connection->access_token)->delete( + "https://www.googleapis.com/calendar/v3/calendars/{$calendar}/events/{$event}", + )->successful(); + } + + /** @return array */ + protected function eventPayload(Room $room, User $host): array + { + $start = ($room->scheduled_at ?? now())->toIso8601String(); + $end = ($room->scheduled_at ?? now())->addMinutes($room->duration_minutes ?? 60)->toIso8601String(); + + return [ + 'summary' => $room->title, + 'description' => trim(($room->description ?? '')."\n\nJoin: ".$room->joinUrl()), + 'location' => $room->joinUrl(), + 'start' => ['dateTime' => $start, 'timeZone' => $room->timezone], + 'end' => ['dateTime' => $end, 'timeZone' => $room->timezone], + 'organizer' => ['email' => $host->email, 'displayName' => $host->name], + ]; + } +} diff --git a/app/Services/Meet/Calendar/MailCalendarProvider.php b/app/Services/Meet/Calendar/MailCalendarProvider.php new file mode 100644 index 0000000..82f9c8a --- /dev/null +++ b/app/Services/Meet/Calendar/MailCalendarProvider.php @@ -0,0 +1,88 @@ +calendar_id ?? $host->email; + if (! $mailbox) { + return null; + } + + $response = Http::withToken((string) config('meet.calendar.mail.api_key')) + ->acceptJson() + ->timeout(10) + ->post($this->baseUrl().'/calendar/events', $this->payload($mailbox, $room)); + + if (! $response->successful()) { + Log::warning('Mail calendar create failed', ['status' => $response->status(), 'body' => $response->body()]); + + return null; + } + + return (string) ($response->json('data.id') ?? $response->json('id')); + } + + public function updateEvent(CalendarConnection $connection, Room $room, User $host): bool + { + if (! $room->calendar_event_id) { + return false; + } + + $mailbox = $connection->calendar_id ?? $host->email; + + return Http::withToken((string) config('meet.calendar.mail.api_key')) + ->acceptJson() + ->timeout(10) + ->put($this->baseUrl().'/calendar/events/'.$room->calendar_event_id, $this->payload($mailbox, $room)) + ->successful(); + } + + public function deleteEvent(CalendarConnection $connection, Room $room): bool + { + if (! $room->calendar_event_id) { + return false; + } + + return Http::withToken((string) config('meet.calendar.mail.api_key')) + ->acceptJson() + ->timeout(10) + ->delete($this->baseUrl().'/calendar/events/'.$room->calendar_event_id, [ + 'mailbox_email' => $connection->calendar_id, + 'external_ref' => $room->uuid, + ]) + ->successful(); + } + + /** + * @return array + */ + protected function payload(string $mailbox, Room $room): array + { + $starts = $room->scheduled_at ?? now(); + $duration = (int) ($room->duration_minutes ?? 60); + + return [ + 'mailbox_email' => strtolower(trim($mailbox)), + 'external_ref' => $room->uuid, + 'title' => $room->title, + 'body' => $room->description, + 'starts_at' => $starts->toIso8601String(), + 'ends_at' => $starts->copy()->addMinutes($duration)->toIso8601String(), + 'join_url' => $room->joinUrl(), + ]; + } + + protected function baseUrl(): string + { + return rtrim((string) config('meet.calendar.mail.api_url', 'https://mail.ladill.com/api/service/v1'), '/'); + } +} diff --git a/app/Services/Meet/Calendar/MicrosoftCalendarProvider.php b/app/Services/Meet/Calendar/MicrosoftCalendarProvider.php new file mode 100644 index 0000000..f690e11 --- /dev/null +++ b/app/Services/Meet/Calendar/MicrosoftCalendarProvider.php @@ -0,0 +1,106 @@ + $clientId, + 'redirect_uri' => $redirect, + 'response_type' => 'code', + 'scope' => 'offline_access Calendars.ReadWrite', + 'response_mode' => 'query', + ]); + + return "https://login.microsoftonline.com/{$tenant}/oauth2/v2.0/authorize?{$params}"; + } + + /** + * @return array|null + */ + public function exchangeCode(string $code): ?array + { + $tenant = config('meet.calendar.microsoft.tenant', 'common'); + + $response = Http::asForm()->post("https://login.microsoftonline.com/{$tenant}/oauth2/v2.0/token", [ + 'code' => $code, + 'client_id' => config('meet.calendar.microsoft.client_id'), + 'client_secret' => config('meet.calendar.microsoft.client_secret'), + 'redirect_uri' => config('meet.calendar.microsoft.redirect'), + 'grant_type' => 'authorization_code', + ]); + + return $response->successful() ? $response->json() : null; + } + + public function createEvent(CalendarConnection $connection, Room $room, User $host): ?string + { + if (! $connection->access_token) { + return null; + } + + $response = Http::withToken($connection->access_token)->post( + 'https://graph.microsoft.com/v1.0/me/events', + $this->eventPayload($room, $host), + ); + + return $response->successful() ? $response->json('id') : null; + } + + public function updateEvent(CalendarConnection $connection, Room $room, User $host): bool + { + if (! $connection->access_token || ! $room->calendar_event_id) { + return false; + } + + return Http::withToken($connection->access_token)->patch( + 'https://graph.microsoft.com/v1.0/me/events/'.$room->calendar_event_id, + $this->eventPayload($room, $host), + )->successful(); + } + + public function deleteEvent(CalendarConnection $connection, Room $room): bool + { + if (! $connection->access_token || ! $room->calendar_event_id) { + return false; + } + + return Http::withToken($connection->access_token)->delete( + 'https://graph.microsoft.com/v1.0/me/events/'.$room->calendar_event_id, + )->successful(); + } + + /** @return array */ + protected function eventPayload(Room $room, User $host): array + { + $start = ($room->scheduled_at ?? now())->toIso8601String(); + $end = ($room->scheduled_at ?? now())->addMinutes($room->duration_minutes ?? 60)->toIso8601String(); + + return [ + 'subject' => $room->title, + 'body' => [ + 'contentType' => 'Text', + 'content' => trim(($room->description ?? '')."\n\nJoin: ".$room->joinUrl()), + ], + 'start' => ['dateTime' => $start, 'timeZone' => $room->timezone], + 'end' => ['dateTime' => $end, 'timeZone' => $room->timezone], + 'location' => ['displayName' => $room->joinUrl()], + 'organizer' => ['emailAddress' => ['address' => $host->email, 'name' => $host->name]], + ]; + } +} diff --git a/app/Services/Meet/CalendarService.php b/app/Services/Meet/CalendarService.php new file mode 100644 index 0000000..ec1f2c0 --- /dev/null +++ b/app/Services/Meet/CalendarService.php @@ -0,0 +1,138 @@ +connectionFor($host); + if (! $connection) { + return; + } + + $eventId = $this->provider($connection)->createEvent($connection, $room, $host); + if ($eventId) { + $room->update(['calendar_event_id' => $eventId]); + } + } + + public function syncRoomUpdated(Room $room, User $host): void + { + $connection = $this->connectionFor($host); + if (! $connection || ! $room->calendar_event_id) { + return; + } + + $this->provider($connection)->updateEvent($connection, $room, $host); + } + + public function syncRoomCancelled(Room $room, User $host): void + { + $connection = $this->connectionFor($host); + if (! $connection || ! $room->calendar_event_id) { + return; + } + + $this->provider($connection)->deleteEvent($connection, $room); + $room->update(['calendar_event_id' => null]); + } + + public function connectionFor(User $user, ?string $provider = null): ?CalendarConnection + { + $query = CalendarConnection::where('user_ref', $user->ownerRef()); + + if ($provider) { + $query->where('provider', $provider); + } + + return $query->orderByDesc('updated_at')->first(); + } + + protected function provider(CalendarConnection $connection): GoogleCalendarProvider|MicrosoftCalendarProvider|MailCalendarProvider + { + return match ($connection->provider) { + 'microsoft' => $this->microsoft, + 'mail' => $this->mail, + default => $this->google, + }; + } + + public function connectMail(User $user, int $organizationId, ?string $mailboxEmail = null): CalendarConnection + { + $email = strtolower(trim($mailboxEmail ?: (string) $user->email)); + + return CalendarConnection::updateOrCreate( + ['user_ref' => $user->ownerRef(), 'provider' => 'mail'], + [ + 'owner_ref' => $user->ownerRef(), + 'organization_id' => $organizationId, + 'calendar_id' => $email, + 'access_token' => null, + 'refresh_token' => null, + 'expires_at' => null, + 'metadata' => ['source' => 'ladill_mail'], + ], + ); + } + + public function storeTokens(User $user, int $organizationId, string $provider, array $tokens): CalendarConnection + { + return CalendarConnection::updateOrCreate( + ['user_ref' => $user->ownerRef(), 'provider' => $provider], + [ + 'owner_ref' => $user->ownerRef(), + 'organization_id' => $organizationId, + 'access_token' => $tokens['access_token'] ?? null, + 'refresh_token' => $tokens['refresh_token'] ?? null, + 'expires_at' => isset($tokens['expires_in']) ? now()->addSeconds((int) $tokens['expires_in']) : null, + 'calendar_id' => $tokens['calendar_id'] ?? 'primary', + 'metadata' => $tokens['metadata'] ?? null, + ], + ); + } + + public function oauthRedirectUrl(string $provider): ?string + { + return match ($provider) { + 'google' => $this->google->authorizeUrl(), + 'microsoft' => $this->microsoft->authorizeUrl(), + default => null, + }; + } + + public function handleOAuthCallback(string $provider, string $code, User $user, int $organizationId): ?CalendarConnection + { + try { + $tokens = match ($provider) { + 'google' => $this->google->exchangeCode($code), + 'microsoft' => $this->microsoft->exchangeCode($code), + default => null, + }; + + if (! $tokens) { + return null; + } + + return $this->storeTokens($user, $organizationId, $provider, $tokens); + } catch (\Throwable $e) { + Log::warning('Calendar OAuth failed', ['provider' => $provider, 'error' => $e->getMessage()]); + + return null; + } + } +} diff --git a/app/Services/Meet/ChannelService.php b/app/Services/Meet/ChannelService.php new file mode 100644 index 0000000..1494261 --- /dev/null +++ b/app/Services/Meet/ChannelService.php @@ -0,0 +1,100 @@ + $data + */ + public function create(User $user, Organization $organization, array $data): Channel + { + $ownerRef = $user->ownerRef(); + $slug = Str::slug($data['name']); + + $channel = Channel::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'branch_id' => $data['branch_id'] ?? null, + 'name' => $data['name'], + 'slug' => $this->uniqueSlug($organization->id, $slug), + 'visibility' => $data['visibility'] ?? 'public', + 'description' => $data['description'] ?? null, + ]); + + $this->addMember($channel, $ownerRef, 'admin'); + + AuditLogger::record($ownerRef, 'channel.created', $organization->id, $ownerRef, Channel::class, $channel->id); + + return $channel; + } + + public function addMember(Channel $channel, string $userRef, string $role = 'member'): ChannelMember + { + return ChannelMember::firstOrCreate( + ['channel_id' => $channel->id, 'user_ref' => $userRef], + ['role' => $role], + ); + } + + public function canAccess(Channel $channel, string $userRef): bool + { + if ($channel->visibility === 'public') { + return true; + } + + return $channel->members()->where('user_ref', $userRef)->exists(); + } + + public function canPost(Channel $channel, string $userRef): bool + { + if ($channel->visibility === 'public') { + return true; + } + + return $channel->members()->where('user_ref', $userRef)->exists(); + } + + public function postMessage(Channel $channel, User $user, string $body, ?int $parentId = null): ChannelMessage + { + abort_unless($this->canPost($channel, $user->ownerRef()), 403); + + return ChannelMessage::create([ + 'owner_ref' => $channel->owner_ref, + 'channel_id' => $channel->id, + 'sender_ref' => $user->ownerRef(), + 'sender_name' => $user->name, + 'body' => trim($body), + 'parent_id' => $parentId, + ]); + } + + public function react(ChannelMessage $message, string $emoji, string $userRef): ChannelMessage + { + $metadata = $message->metadata ?? []; + $reactions = $metadata['reactions'] ?? []; + $reactions[$emoji] = array_values(array_unique(array_merge($reactions[$emoji] ?? [], [$userRef]))); + $metadata['reactions'] = $reactions; + $message->update(['metadata' => $metadata]); + + return $message->fresh(); + } + + protected function uniqueSlug(int $organizationId, string $base): string + { + $slug = $base; + $i = 1; + while (Channel::where('organization_id', $organizationId)->where('slug', $slug)->exists()) { + $slug = $base.'-'.$i++; + } + + return $slug; + } +} diff --git a/app/Services/Meet/ChatService.php b/app/Services/Meet/ChatService.php new file mode 100644 index 0000000..e4608a9 --- /dev/null +++ b/app/Services/Meet/ChatService.php @@ -0,0 +1,55 @@ + $session->owner_ref, + 'session_id' => $session->id, + 'sender_ref' => $senderRef, + 'sender_name' => $senderName, + 'type' => 'public', + 'body' => $body, + ]); + } + + public function sendReaction( + Session $session, + string $senderName, + string $emoji, + ?string $participantRef = null, + ): Reaction { + return Reaction::create([ + 'owner_ref' => $session->owner_ref, + 'session_id' => $session->id, + 'participant_ref' => $participantRef, + 'sender_name' => $senderName, + 'emoji' => $emoji, + ]); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + public function recentMessages(Session $session, int $limit = 100) + { + return $session->messages() + ->where('type', 'public') + ->orderByDesc('created_at') + ->limit($limit) + ->get() + ->reverse() + ->values(); + } +} diff --git a/app/Services/Meet/ContactService.php b/app/Services/Meet/ContactService.php new file mode 100644 index 0000000..0f91451 --- /dev/null +++ b/app/Services/Meet/ContactService.php @@ -0,0 +1,105 @@ +> + */ + public function searchOrgMembers(int $organizationId, string $ownerRef, string $query = '', int $limit = 20): Collection + { + $members = Member::owned($ownerRef) + ->where('organization_id', $organizationId) + ->when($query !== '', function ($q) use ($query) { + $q->whereIn('user_ref', User::query() + ->where(function ($u) use ($query) { + $u->where('name', 'like', "%{$query}%") + ->orWhere('email', 'like', "%{$query}%"); + }) + ->pluck('public_id')); + }) + ->limit($limit) + ->get(); + + return $members->map(function (Member $member) { + $user = User::where('public_id', $member->user_ref)->first(); + + return [ + 'user_ref' => $member->user_ref, + 'email' => $user?->email, + 'name' => $user?->name ?? $member->user_ref, + 'role' => $member->role, + 'source' => 'org', + ]; + })->filter(fn ($c) => filled($c['email'])); + } + + /** + * @return Collection> + */ + public function search(int $organizationId, string $ownerRef, string $query = '', int $limit = 20): Collection + { + $org = $this->searchOrgMembers($organizationId, $ownerRef, $query, $limit); + + if ($org->count() >= $limit) { + return $org; + } + + $platform = $this->searchPlatformContacts($query, $limit - $org->count()); + $emails = $org->pluck('email')->filter()->all(); + + return $org->merge($platform->reject(fn ($c) => in_array($c['email'], $emails, true)))->take($limit); + } + + /** + * @return Collection> + */ + public function fromGroup(ContactGroup $group): Collection + { + return $group->members->map(fn ($m) => [ + 'email' => $m->email, + 'name' => $m->display_name, + 'user_ref' => $m->user_ref, + 'source' => 'group', + ])->filter(fn ($c) => filled($c['email'])); + } + + /** + * @return Collection> + */ + protected function searchPlatformContacts(string $query, int $limit): Collection + { + $url = rtrim((string) config('identity.api_url'), '/'); + $key = config('identity.api_key'); + + if ($url === '' || $key === '' || $query === '') { + return collect(); + } + + try { + $response = Http::withToken($key) + ->timeout(5) + ->get("{$url}/team/search", ['q' => $query, 'limit' => $limit]); + + if (! $response->successful()) { + return collect(); + } + + return collect($response->json('contacts', []))->map(fn ($c) => [ + 'user_ref' => $c['public_id'] ?? null, + 'email' => $c['email'] ?? null, + 'name' => $c['name'] ?? null, + 'source' => 'platform', + ])->filter(fn ($c) => filled($c['email'])); + } catch (\Throwable) { + return collect(); + } + } +} diff --git a/app/Services/Meet/DirectMessageService.php b/app/Services/Meet/DirectMessageService.php new file mode 100644 index 0000000..f972fd8 --- /dev/null +++ b/app/Services/Meet/DirectMessageService.php @@ -0,0 +1,61 @@ +ownerRef(); + $userRef = $ownerRef; + + $existing = DirectConversation::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('type', 'dm') + ->whereHas('participants', fn ($q) => $q->where('user_ref', $userRef)) + ->whereHas('participants', fn ($q) => $q->where('user_ref', $otherUserRef)) + ->first(); + + if ($existing) { + return $existing; + } + + $conversation = DirectConversation::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'type' => 'dm', + ]); + + foreach ([$userRef, $otherUserRef] as $ref) { + DirectParticipant::create([ + 'conversation_id' => $conversation->id, + 'user_ref' => $ref, + ]); + } + + return $conversation; + } + + public function postMessage(DirectConversation $conversation, User $user, string $body, ?int $parentId = null): DirectMessage + { + abort_unless( + $conversation->participants()->where('user_ref', $user->ownerRef())->exists(), + 403, + ); + + return DirectMessage::create([ + 'owner_ref' => $conversation->owner_ref, + 'conversation_id' => $conversation->id, + 'sender_ref' => $user->ownerRef(), + 'sender_name' => $user->name, + 'body' => trim($body), + 'parent_id' => $parentId, + ]); + } +} diff --git a/app/Services/Meet/FileSharingService.php b/app/Services/Meet/FileSharingService.php new file mode 100644 index 0000000..e02ab76 --- /dev/null +++ b/app/Services/Meet/FileSharingService.php @@ -0,0 +1,104 @@ +uuid.'/'.Str::uuid().'_'.$file->getClientOriginalName(); + $disk->put($path, $file->get()); + + return SessionFile::create([ + 'owner_ref' => $room->owner_ref, + 'room_id' => $room->id, + 'session_id' => $session?->id, + 'uploaded_by_ref' => $uploadedByRef, + 'uploaded_by_name' => $uploadedByName, + 'original_name' => $file->getClientOriginalName(), + 'storage_path' => $path, + 'mime_type' => $file->getMimeType(), + 'file_size' => $file->getSize(), + 'expires_at' => $expiresInDays ? now()->addDays($expiresInDays) : null, + ]); + } + + public function linkFromDrive(Room $room, string $driveFileId, string $name, string $uploadedByRef, string $uploadedByName): SessionFile + { + return SessionFile::create([ + 'owner_ref' => $room->owner_ref, + 'room_id' => $room->id, + 'uploaded_by_ref' => $uploadedByRef, + 'uploaded_by_name' => $uploadedByName, + 'original_name' => $name, + 'storage_path' => '', + 'drive_file_id' => $driveFileId, + 'file_size' => 0, + ]); + } + + public function download(SessionFile $file, ?Participant $participant = null): string + { + abort_if($file->isExpired(), 410, 'File has expired.'); + + FileDownload::create([ + 'session_file_id' => $file->id, + 'downloaded_by_ref' => $participant?->user_ref, + 'downloaded_by_name' => $participant?->display_name, + 'ip_address' => request()?->ip(), + 'downloaded_at' => now(), + ]); + + if ($file->drive_file_id) { + return $this->driveDownloadUrl($file->drive_file_id); + } + + return Storage::disk(config('meet.files.disk', 'local'))->path($file->storage_path); + } + + public function delete(SessionFile $file): void + { + if ($file->storage_path) { + Storage::disk(config('meet.files.disk', 'local'))->delete($file->storage_path); + } + $file->delete(); + } + + protected function driveDownloadUrl(string $driveFileId): string + { + $base = rtrim((string) config('meet.files.drive_api_url'), '/'); + $key = config('meet.files.drive_api_key'); + + if ($base && $key) { + try { + $response = Http::withToken($key)->get("{$base}/files/{$driveFileId}/download-url"); + + if ($response->successful()) { + return $response->json('url', $base.'/files/'.$driveFileId); + } + } catch (\Throwable) { + // fall through + } + } + + return $base ? "{$base}/files/{$driveFileId}" : '#'; + } +} diff --git a/app/Services/Meet/IcalService.php b/app/Services/Meet/IcalService.php new file mode 100644 index 0000000..6ee4e25 --- /dev/null +++ b/app/Services/Meet/IcalService.php @@ -0,0 +1,44 @@ +scheduled_at ?? now())->utc(); + $end = (clone $start)->addMinutes($room->duration_minutes ?? 60); + $uid = $room->uuid.'@meet.ladill.com'; + + $lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Ladill//Meet//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:REQUEST', + 'BEGIN:VEVENT', + 'UID:'.$uid, + 'DTSTAMP:'.$start->format('Ymd\THis\Z'), + 'DTSTART:'.$start->format('Ymd\THis\Z'), + 'DTEND:'.$end->format('Ymd\THis\Z'), + 'SUMMARY:'.$this->escape($room->title), + 'DESCRIPTION:'.$this->escape($room->description ?? 'Join via Ladill Meet'), + 'LOCATION:'.$this->escape($room->joinUrl()), + 'ORGANIZER;CN='.$this->escape($host->name).':MAILTO:'.$host->email, + 'URL:'.$room->joinUrl(), + 'END:VEVENT', + 'END:VCALENDAR', + ]; + + return implode("\r\n", $lines); + } + + protected function escape(string $value): string + { + return str_replace(["\n", ',', ';'], ['\\n', '\\,', '\\;'], $value); + } +} diff --git a/app/Services/Meet/InvitationService.php b/app/Services/Meet/InvitationService.php new file mode 100644 index 0000000..1b01766 --- /dev/null +++ b/app/Services/Meet/InvitationService.php @@ -0,0 +1,120 @@ +> $invites + */ + public function inviteToRoom(Room $room, array $invites, User $host): void + { + $normalized = collect($invites)->map(function ($invite) { + $email = trim((string) ($invite['email'] ?? '')); + if ($email === '') { + return null; + } + + $user = User::where('email', $email)->first(); + + return [ + 'email' => $email, + 'name' => $invite['name'] ?? $invite['display_name'] ?? $user?->name, + 'role' => $invite['role'] ?? 'participant', + 'user_ref' => $invite['user_ref'] ?? $user?->ownerRef(), + ]; + })->filter()->unique('email')->values()->all(); + + $this->notifications->sendInvitations($room, $normalized, $host); + } + + public function resend(Invitation $invitation, User $host): Invitation + { + $this->notifications->sendInvitationEmail($invitation->room, $invitation, $host); + $invitation->update(['sent_at' => now()]); + + return $invitation->fresh(); + } + + public function respond(Invitation $invitation, string $status, ?string $token = null): Invitation + { + abort_unless(in_array($status, ['accepted', 'declined'], true), 422); + if ($token !== null) { + abort_unless(hash_equals((string) $invitation->rsvp_token, $token), 403); + } + + $invitation->update([ + 'status' => $status, + 'responded_at' => now(), + ]); + + return $invitation->fresh(); + } + + public function markAcceptedOnJoin(Room $room, ?User $user, ?string $email): void + { + $query = $room->invitations()->where('status', 'pending'); + + if ($user) { + $query->where(function ($q) use ($user, $email) { + $q->where('user_ref', $user->ownerRef()); + if ($email) { + $q->orWhere('email', $email); + } + }); + } elseif ($email) { + $query->where('email', $email); + } else { + return; + } + + $query->update(['status' => 'accepted', 'responded_at' => now()]); + } + + /** + * @return array> + */ + public function parseCsv(string $content): array + { + $lines = preg_split('/\r\n|\r|\n/', trim($content)) ?: []; + $invites = []; + + foreach ($lines as $index => $line) { + $line = trim($line); + if ($line === '' || ($index === 0 && str_contains(strtolower($line), 'email'))) { + continue; + } + + $parts = str_getcsv($line); + $email = trim((string) ($parts[0] ?? '')); + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + + $invites[] = [ + 'email' => $email, + 'name' => trim((string) ($parts[1] ?? '')) ?: null, + 'role' => trim((string) ($parts[2] ?? '')) ?: 'participant', + ]; + } + + return $invites; + } + + public function bulkFromCsv(Room $room, string $csvContent, User $host): int + { + $invites = $this->parseCsv($csvContent); + $this->inviteToRoom($room, $invites, $host); + + return count($invites); + } +} diff --git a/app/Services/Meet/LicenseService.php b/app/Services/Meet/LicenseService.php new file mode 100644 index 0000000..388e450 --- /dev/null +++ b/app/Services/Meet/LicenseService.php @@ -0,0 +1,67 @@ +> */ + public const TIERS = [ + 'standard' => [ + 'participant_minutes' => 10000, + 'recording_storage_bytes' => 5_368_709_120, // 5 GB + 'ai_minutes' => 120, + ], + 'professional' => [ + 'participant_minutes' => 50000, + 'recording_storage_bytes' => 26_843_545_600, // 25 GB + 'ai_minutes' => 600, + ], + 'enterprise' => [ + 'participant_minutes' => 500000, + 'recording_storage_bytes' => 107_374_182_400, // 100 GB + 'ai_minutes' => 5000, + ], + ]; + + public function quotas(Organization $organization): array + { + $tier = $organization->license_tier ?? 'standard'; + $defaults = self::TIERS[$tier] ?? self::TIERS['standard']; + + return array_merge($defaults, (array) ($organization->usage_quotas ?? [])); + } + + public function withinQuota(Organization $organization, string $metric, int $additional = 0, ?int $branchId = null): bool + { + $quotas = $this->quotas($organization); + $limit = (int) ($quotas[$metric] ?? 0); + if ($limit <= 0) { + return true; + } + + $since = now()->startOfMonth(); + $query = UsageRecord::where('organization_id', $organization->id) + ->where('metric', $metric) + ->where('recorded_at', '>=', $since); + + if ($branchId) { + $query->where('branch_id', $branchId); + } + + $used = (int) $query->sum('quantity'); + + return ($used + $additional) <= $limit; + } + + public function assertCanStartSession(Organization $organization, ?int $branchId = null): void + { + abort_unless( + $this->withinQuota($organization, 'participant_minutes', 60, $branchId), + 402, + 'Participant-minute quota exceeded for this billing period.', + ); + } +} diff --git a/app/Services/Meet/LiveStreamService.php b/app/Services/Meet/LiveStreamService.php new file mode 100644 index 0000000..1351e2e --- /dev/null +++ b/app/Services/Meet/LiveStreamService.php @@ -0,0 +1,91 @@ + */ + public const PLATFORMS = [ + 'youtube' => 'YouTube', + 'facebook' => 'Facebook Live', + 'linkedin' => 'LinkedIn Live', + 'custom' => 'Custom RTMP', + ]; + + /** @var array */ + public const LAYOUTS = [ + 'speaker' => 'Speaker view', + 'gallery' => 'Gallery view', + 'screen' => 'Screen share focus', + ]; + + /** + * @param array $data + */ + public function configure(Session $session, User $host, array $data): LiveStream + { + $platform = $data['platform'] ?? 'custom'; + $rtmpUrl = $this->defaultRtmpUrl($platform, $data['rtmp_url'] ?? null); + + return LiveStream::updateOrCreate( + ['session_id' => $session->id, 'platform' => $platform], + [ + 'owner_ref' => $session->owner_ref, + 'rtmp_url' => $rtmpUrl, + 'stream_key' => $data['stream_key'] ?? '', + 'layout' => $data['layout'] ?? 'speaker', + 'status' => 'idle', + ], + ); + } + + public function start(LiveStream $stream): LiveStream + { + $stream->update([ + 'status' => 'live', + 'started_at' => now(), + 'ended_at' => null, + 'health' => 'ok', + ]); + + AuditLogger::record( + $stream->owner_ref, + 'livestream.started', + $stream->session->room->organization_id, + $stream->owner_ref, + LiveStream::class, + $stream->id, + ); + + return $stream->fresh(); + } + + public function stop(LiveStream $stream): LiveStream + { + $stream->update([ + 'status' => 'stopped', + 'ended_at' => now(), + ]); + + return $stream->fresh(); + } + + protected function defaultRtmpUrl(string $platform, ?string $custom): string + { + if ($custom) { + return $custom; + } + + return match ($platform) { + 'youtube' => 'rtmp://a.rtmp.youtube.com/live2', + 'facebook' => 'rtmps://live-api-s.facebook.com:443/rtmp/', + 'linkedin' => 'rtmp://stream.linkedin.com:1935/live/', + default => '', + }; + } +} diff --git a/app/Services/Meet/Media/LiveKitProvider.php b/app/Services/Meet/Media/LiveKitProvider.php new file mode 100644 index 0000000..8002251 --- /dev/null +++ b/app/Services/Meet/Media/LiveKitProvider.php @@ -0,0 +1,77 @@ + $apiKey, + 'sub' => $identity, + 'iat' => $now, + 'nbf' => $now, + 'exp' => $now + 3600, + 'name' => $displayName, + 'video' => [ + 'roomJoin' => true, + 'room' => $roomName, + 'canPublish' => $grants['can_publish'] ?? true, + 'canSubscribe' => $grants['can_subscribe'] ?? true, + 'canPublishData' => $grants['can_publish_data'] ?? true, + 'roomAdmin' => $grants['room_admin'] ?? false, + ], + ]; + + return $this->encodeJwt($payload, $apiSecret); + } + + public function isConfigured(): bool + { + return filled(config('meet.media.livekit.url')) + && filled(config('meet.media.livekit.api_key')) + && filled(config('meet.media.livekit.api_secret')); + } + + public function serverUrl(): string + { + return (string) config('meet.media.livekit.url'); + } + + /** + * @param array $payload + */ + protected function encodeJwt(array $payload, string $secret): string + { + $header = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'], JSON_THROW_ON_ERROR)); + $body = $this->base64UrlEncode(json_encode($payload, JSON_THROW_ON_ERROR)); + $signature = $this->base64UrlEncode( + hash_hmac('sha256', "{$header}.{$body}", $secret, true) + ); + + return "{$header}.{$body}.{$signature}"; + } + + protected function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } +} diff --git a/app/Services/Meet/Media/MediaProviderInterface.php b/app/Services/Meet/Media/MediaProviderInterface.php new file mode 100644 index 0000000..2fe2721 --- /dev/null +++ b/app/Services/Meet/Media/MediaProviderInterface.php @@ -0,0 +1,27 @@ + $options + */ + public function createRoom(string $roomName, array $options = []): void; + + public function deleteRoom(string $roomName): void; + + /** + * @param array $grants + */ + public function generateToken( + string $roomName, + string $identity, + string $displayName, + array $grants = [], + ): string; + + public function isConfigured(): bool; + + public function serverUrl(): string; +} diff --git a/app/Services/Meet/MeetAiService.php b/app/Services/Meet/MeetAiService.php new file mode 100644 index 0000000..b54c133 --- /dev/null +++ b/app/Services/Meet/MeetAiService.php @@ -0,0 +1,146 @@ +hasPlatformRelay()); + } + + public function summarize(Transcript $transcript): AiSummary + { + $summary = AiSummary::create([ + 'owner_ref' => $transcript->owner_ref, + 'session_id' => $transcript->session_id, + 'transcript_id' => $transcript->id, + 'status' => 'processing', + ]); + + try { + $result = $this->generateSummary((string) $transcript->content); + $summary->update([ + 'status' => 'ready', + 'summary' => $result['summary'] ?? '', + 'decisions' => $result['decisions'] ?? [], + ]); + + foreach ($result['action_items'] ?? [] as $item) { + ActionItem::create([ + 'owner_ref' => $transcript->owner_ref, + 'ai_summary_id' => $summary->id, + 'assignee_name' => $item['assignee'] ?? null, + 'description' => $item['description'] ?? '', + 'due_date' => $item['due_date'] ?? null, + 'status' => 'open', + ]); + } + } catch (\Throwable $e) { + report($e); + $summary->update(['status' => 'failed', 'summary' => 'AI summary could not be generated.']); + } + + app(MeetNotificationService::class)->aiSummaryReady($summary->fresh()); + + return $summary->fresh(); + } + + /** + * @return array{summary: string, decisions: array, action_items: array} + */ + protected function generateSummary(string $transcript): array + { + if (! $this->enabled()) { + return $this->stubSummary($transcript); + } + + $prompt = <<hasLocalKey() + ? $this->viaOpenAi($prompt) + : $this->viaPlatform($prompt); + + $parsed = json_decode($reply, true); + if (! is_array($parsed)) { + return ['summary' => $reply, 'decisions' => [], 'action_items' => []]; + } + + return $parsed; + } + + protected function stubSummary(string $transcript): array + { + $preview = Str::limit($transcript, 500); + + return [ + 'summary' => "Meeting summary (AI not configured).\n\nTranscript preview:\n".$preview, + 'decisions' => [], + 'action_items' => [], + ]; + } + + protected function viaOpenAi(string $prompt): string + { + $res = Http::withToken((string) config('meet.ai.api_key'))->acceptJson()->timeout(60) + ->post('https://api.openai.com/v1/chat/completions', [ + 'model' => config('meet.ai.model', 'gpt-4o-mini'), + 'temperature' => 0.2, + 'response_format' => ['type' => 'json_object'], + 'messages' => [ + ['role' => 'system', 'content' => 'You summarize Ladill Meet transcripts. Return valid JSON only.'], + ['role' => 'user', 'content' => $prompt], + ], + ]); + + if ($res->failed()) { + throw new RuntimeException('OpenAI request failed: '.$res->status()); + } + + return trim((string) $res->json('choices.0.message.content', '')); + } + + protected function viaPlatform(string $prompt): string + { + $base = rtrim((string) config('meet.ai.platform_api_url'), '/'); + $res = Http::withToken((string) config('meet.ai.platform_api_key'))->acceptJson()->timeout(60) + ->post($base.'/afia/chat', [ + 'product' => 'meet', + 'message' => $prompt, + 'history' => [], + ]); + + if ($res->failed()) { + throw new RuntimeException('Platform AI relay failed.'); + } + + return trim((string) $res->json('reply', '')); + } + + protected function hasLocalKey(): bool + { + return (string) config('meet.ai.api_key') !== ''; + } + + protected function hasPlatformRelay(): bool + { + return rtrim((string) config('meet.ai.platform_api_url'), '/') !== '' + && (string) config('meet.ai.platform_api_key') !== ''; + } +} diff --git a/app/Services/Meet/MeetNotificationService.php b/app/Services/Meet/MeetNotificationService.php new file mode 100644 index 0000000..fb5bd33 --- /dev/null +++ b/app/Services/Meet/MeetNotificationService.php @@ -0,0 +1,142 @@ +invitations()->where('email', $email)->first(); + $invitation = $existing ?? $room->invitations()->create([ + 'owner_ref' => $room->owner_ref, + 'email' => $email, + 'user_ref' => $invite['user_ref'] ?? User::where('email', $email)->value('public_id'), + 'display_name' => $invite['name'] ?? null, + 'role' => $invite['role'] ?? 'participant', + 'status' => 'pending', + 'sent_at' => now(), + ]); + + if ($existing) { + $invitation->update(['sent_at' => now()]); + } + + $this->sendInvitationEmail($room, $invitation, $host); + + if ($user = User::where('email', $email)->first()) { + $user->notify(new MeetingInvitationNotification($room, $host)); + } + } + } + + public function sendInvitationEmail(Room $room, Invitation $invitation, User $host): void + { + $this->email->send( + $invitation->email, + 'Invitation: '.$room->title, + $this->invitationBody($room, $host, $invitation), + ); + } + + public function meetingStarted(Session $session, User $host): void + { + AuditLogger::record( + $session->owner_ref, + 'notification.meeting_started', + $session->room->organization_id, + $host->ownerRef(), + Session::class, + $session->id, + ); + } + + public function recordingReady(Recording $recording): void + { + $host = User::where('public_id', $recording->session->room->host_user_ref)->first(); + if ($host) { + $host->notify(new RecordingReadyNotification($recording)); + } + + if ($host?->email) { + $this->email->send( + $host->email, + 'Recording ready: '.$recording->session->room->title, + 'Your meeting recording is ready to view at '.route('meet.recordings.show', $recording), + ); + } + } + + public function aiSummaryReady(\App\Models\AiSummary $summary): void + { + $host = User::where('public_id', $summary->session->room->host_user_ref)->first(); + if ($host) { + $host->notify(new AiSummaryReadyNotification($summary)); + } + } + + public function sendReminder(Room $room, User $host): void + { + $this->email->send( + $host->email, + 'Reminder: '.$room->title.' starts soon', + 'Your meeting starts at '.$room->scheduled_at?->timezone($room->timezone)->format('M j, Y g:i A T')."\n\nJoin: ".$room->joinUrl(), + ); + + $host->notify(new MeetingReminderNotification($room)); + + foreach ($room->invitations()->where('status', 'accepted')->get() as $invitation) { + if (! $invitation->email) { + continue; + } + $this->email->send( + $invitation->email, + 'Reminder: '.$room->title.' starts soon', + 'You are invited to a meeting starting at '.$room->scheduled_at?->timezone($room->timezone)->format('M j, Y g:i A T')."\n\nJoin: ".$room->joinUrl(), + ); + } + } + + protected function invitationBody(Room $room, User $host, ?Invitation $invitation = null): string + { + $when = $room->scheduled_at + ? $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') + : 'Instant meeting'; + + $lines = [ + $host->name.' invited you to a Ladill Meet session.', + '', + 'Meeting: '.$room->title, + 'When: '.$when, + 'Join: '.$room->joinUrl(), + $room->passcode ? 'Passcode: '.$room->passcode : '', + ]; + + if ($invitation?->rsvp_token) { + $lines[] = ''; + $lines[] = 'RSVP: '.$invitation->rsvpUrl(); + } + + return implode("\n", array_filter($lines, fn ($l) => $l !== '')); + } +} diff --git a/app/Services/Meet/MeetPermissions.php b/app/Services/Meet/MeetPermissions.php new file mode 100644 index 0000000..4c239d9 --- /dev/null +++ b/app/Services/Meet/MeetPermissions.php @@ -0,0 +1,44 @@ +> */ + protected array $roleAbilities = [ + 'owner' => ['*'], + 'admin' => ['*'], + 'moderator' => [ + 'dashboard.view', 'meetings.view', 'meetings.create', 'meetings.manage', + 'meetings.host', 'chat.moderate', 'reports.view', + ], + 'member' => [ + 'dashboard.view', 'meetings.view', 'meetings.create', 'meetings.host', + ], + 'guest' => [ + 'meetings.view', + ], + ]; + + public function can(?Member $member, string $ability): bool + { + if ($member === null) { + return false; + } + + $abilities = $this->roleAbilities[$member->role] ?? []; + + if (in_array('*', $abilities, true)) { + return true; + } + + return in_array($ability, $abilities, true); + } + + public function isAdmin(?Member $member): bool + { + return $member !== null && in_array($member->role, ['owner', 'admin'], true); + } +} diff --git a/app/Services/Meet/OrganizationResolver.php b/app/Services/Meet/OrganizationResolver.php new file mode 100644 index 0000000..d0d5d7d --- /dev/null +++ b/app/Services/Meet/OrganizationResolver.php @@ -0,0 +1,112 @@ +ownerRef(); + + $member = Member::where('user_ref', $ref)->first(); + if ($member) { + return Organization::find($member->organization_id); + } + + return Organization::owned($ref)->first(); + } + + public function forUser(User $user): Organization + { + return $this->resolveForUser($user) + ?? throw new \RuntimeException('No organization for user.'); + } + + public function isOnboarded(User $user): bool + { + $organization = $this->resolveForUser($user); + + return $organization !== null + && (bool) data_get($organization->settings, 'onboarded', false); + } + + public function memberFor(User $user, ?Organization $organization = null): ?Member + { + $organization ??= $this->resolveForUser($user); + if (! $organization) { + return null; + } + + return Member::where('organization_id', $organization->id) + ->where('user_ref', $user->ownerRef()) + ->first(); + } + + public function ensureOwnerMember(User $user, Organization $organization): Member + { + return Member::firstOrCreate( + [ + 'organization_id' => $organization->id, + 'user_ref' => $user->ownerRef(), + ], + [ + 'owner_ref' => $user->ownerRef(), + 'role' => 'owner', + ], + ); + } + + /** + * @param array $data + */ + public function completeOnboarding(User $user, array $data): Organization + { + $ref = $user->ownerRef(); + + $organization = Organization::create([ + 'owner_ref' => $ref, + 'name' => $data['organization_name'], + 'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6), + 'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'), + 'settings' => [ + 'onboarded' => true, + 'org_type' => $data['org_type'] ?? 'business', + ], + ]); + + $this->ensureOwnerMember($user, $organization); + + $branch = Branch::create([ + 'owner_ref' => $ref, + 'organization_id' => $organization->id, + 'name' => $data['branch_name'], + 'address' => $data['branch_address'] ?? null, + 'phone' => $data['branch_phone'] ?? null, + 'is_active' => true, + ]); + + AuditLogger::record($ref, 'organization.created', $organization->id, $ref, Organization::class, $organization->id); + AuditLogger::record($ref, 'branch.created', $organization->id, $ref, Branch::class, $branch->id); + + return $organization; + } + + public function branchScope(?Member $member): ?int + { + if ($member === null) { + return null; + } + + if (in_array($member->role, ['owner', 'admin'], true)) { + return null; + } + + return $member->branch_id; + } +} diff --git a/app/Services/Meet/PollService.php b/app/Services/Meet/PollService.php new file mode 100644 index 0000000..80146ed --- /dev/null +++ b/app/Services/Meet/PollService.php @@ -0,0 +1,60 @@ + $options + */ + public function create(Session $session, string $question, array $options): MeetPoll + { + return MeetPoll::create([ + 'owner_ref' => $session->owner_ref, + 'session_id' => $session->id, + 'question' => $question, + 'options' => array_values($options), + 'status' => 'open', + ]); + } + + public function vote(MeetPoll $poll, Participant $participant, int $optionIndex): PollVote + { + abort_unless($poll->status === 'open', 422); + abort_unless(isset($poll->options[$optionIndex]), 422); + + return PollVote::updateOrCreate( + ['poll_id' => $poll->id, 'participant_uuid' => $participant->uuid], + ['option_index' => $optionIndex], + ); + } + + public function close(MeetPoll $poll): MeetPoll + { + $poll->update(['status' => 'closed']); + + return $poll->fresh(); + } + + /** + * @return array + */ + public function results(MeetPoll $poll): array + { + $counts = array_fill(0, count($poll->options ?? []), 0); + foreach ($poll->votes as $vote) { + $counts[$vote->option_index] = ($counts[$vote->option_index] ?? 0) + 1; + } + + return [ + 'poll' => $poll, + 'counts' => $counts, + 'total_votes' => $poll->votes()->count(), + ]; + } +} diff --git a/app/Services/Meet/QaService.php b/app/Services/Meet/QaService.php new file mode 100644 index 0000000..4519966 --- /dev/null +++ b/app/Services/Meet/QaService.php @@ -0,0 +1,68 @@ + $session->owner_ref, + 'session_id' => $session->id, + 'participant_uuid' => $participant->uuid, + 'asker_name' => $participant->display_name, + 'question' => $question, + 'status' => 'pending', + ]); + } + + public function approve(QaQuestion $question): QaQuestion + { + $question->update(['status' => 'approved']); + + return $question->fresh(); + } + + public function answer(QaQuestion $question, string $answer, string $answeredBy): QaQuestion + { + $question->update([ + 'status' => 'answered', + 'answer' => $answer, + 'answered_by' => $answeredBy, + ]); + + return $question->fresh(); + } + + public function dismiss(QaQuestion $question): QaQuestion + { + $question->update(['status' => 'dismissed']); + + return $question->fresh(); + } + + public function upvote(QaQuestion $question): QaQuestion + { + $question->increment('upvotes'); + + return $question->fresh(); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + public function forSession(Session $session, bool $publicOnly = false) + { + $query = $session->qaQuestions()->orderByDesc('upvotes')->orderBy('created_at'); + + if ($publicOnly) { + $query->whereIn('status', ['approved', 'answered']); + } + + return $query->get(); + } +} diff --git a/app/Services/Meet/RecordingService.php b/app/Services/Meet/RecordingService.php new file mode 100644 index 0000000..81a5ca2 --- /dev/null +++ b/app/Services/Meet/RecordingService.php @@ -0,0 +1,124 @@ +recordings()->where('status', 'recording')->first(); + if ($active) { + return $active; + } + + $recording = Recording::create([ + 'owner_ref' => $session->owner_ref, + 'session_id' => $session->id, + 'status' => 'recording', + 'layout' => $layout, + 'started_by_ref' => $host->ownerRef(), + 'started_at' => now(), + ]); + + AuditLogger::record( + $session->owner_ref, + 'recording.started', + $session->room->organization_id, + $host->ownerRef(), + Recording::class, + $recording->id, + ); + + return $recording; + } + + public function stop(Recording $recording, string $actorRef): Recording + { + if ($recording->status !== 'recording') { + return $recording; + } + + $recording->update([ + 'status' => 'processing', + 'ended_at' => now(), + 'duration_seconds' => $recording->started_at + ? (int) $recording->started_at->diffInSeconds(now()) + : null, + ]); + + AuditLogger::record( + $recording->owner_ref, + 'recording.stopped', + $recording->session->room->organization_id, + $actorRef, + Recording::class, + $recording->id, + ); + + ProcessRecording::dispatch($recording->id); + + return $recording->fresh(); + } + + public function process(Recording $recording): Recording + { + $session = $recording->session; + $filename = 'recordings/'.$session->uuid.'/'.$recording->uuid.'.mp4'; + $disk = Storage::disk(config('meet.recordings.disk', 'local')); + + // Placeholder until LiveKit Egress writes the file; create empty marker for dev. + if (! $disk->exists($filename)) { + $disk->put($filename, ''); + } + + $recording->update([ + 'status' => 'ready', + 'storage_path' => $filename, + 'file_size' => $disk->size($filename) ?: null, + 'thumbnail_path' => null, + ]); + + AuditLogger::record( + $recording->owner_ref, + 'recording.ready', + $session->room->organization_id, + null, + Recording::class, + $recording->id, + ); + + app(MeetNotificationService::class)->recordingReady($recording); + + app(\App\Services\Integrations\WebhookDispatcher::class)->recordingReady($recording); + + if ($session->room->setting('auto_ai_summary', false) || config('meet.ai.auto_summarize', true)) { + app(TranscriptService::class)->ensureForSession($session, $recording); + } + + return $recording->fresh(); + } + + public function delete(Recording $recording, string $actorRef): void + { + if ($recording->storage_path) { + Storage::disk(config('meet.recordings.disk', 'local'))->delete($recording->storage_path); + } + + AuditLogger::record( + $recording->owner_ref, + 'recording.deleted', + $recording->session->room->organization_id, + $actorRef, + Recording::class, + $recording->id, + ); + + $recording->delete(); + } +} diff --git a/app/Services/Meet/RecurringMeetingService.php b/app/Services/Meet/RecurringMeetingService.php new file mode 100644 index 0000000..7ccfb4e --- /dev/null +++ b/app/Services/Meet/RecurringMeetingService.php @@ -0,0 +1,93 @@ + $data + */ + public function createSeries(User $user, Organization $organization, array $data): Room + { + $seriesId = (string) Str::uuid(); + + $room = $this->rooms->createScheduled($user, $organization, array_merge($data, [ + 'recurring_series_id' => $seriesId, + 'recurrence_rule' => $data['recurrence_rule'], + 'recurrence_interval' => $data['recurrence_interval'] ?? 1, + 'recurrence_until' => $data['recurrence_until'] ?? null, + ])); + + $this->spawnNextOccurrences($room, 5); + + return $room; + } + + public function spawnNextOccurrences(Room $parent, int $count = 1): void + { + if (! $parent->isRecurring()) { + return; + } + + $last = Room::where('recurring_series_id', $parent->recurring_series_id) + ->orderByDesc('scheduled_at') + ->first(); + + $cursor = Carbon::parse($last?->scheduled_at ?? $parent->scheduled_at); + + for ($i = 0; $i < $count; $i++) { + $cursor = $this->nextOccurrence($cursor, $parent->recurrence_rule, (int) $parent->recurrence_interval); + + if ($parent->recurrence_until && $cursor->toDateString() > $parent->recurrence_until) { + break; + } + + if (Room::where('recurring_series_id', $parent->recurring_series_id) + ->whereDate('scheduled_at', $cursor->toDateString()) + ->exists()) { + continue; + } + + Room::create([ + 'owner_ref' => $parent->owner_ref, + 'organization_id' => $parent->organization_id, + 'branch_id' => $parent->branch_id, + 'host_user_ref' => $parent->host_user_ref, + 'title' => $parent->title, + 'description' => $parent->description, + 'type' => 'scheduled', + 'status' => 'scheduled', + 'scheduled_at' => $cursor, + 'duration_minutes' => $parent->duration_minutes, + 'timezone' => $parent->timezone, + 'passcode' => $parent->passcode, + 'settings' => $parent->settings, + 'template_id' => $parent->template_id, + 'recurring_series_id' => $parent->recurring_series_id, + 'recurrence_rule' => $parent->recurrence_rule, + 'recurrence_interval' => $parent->recurrence_interval, + 'recurrence_until' => $parent->recurrence_until, + ]); + } + } + + protected function nextOccurrence(Carbon $from, string $rule, int $interval): Carbon + { + return match ($rule) { + 'daily' => $from->copy()->addDays($interval), + 'weekly' => $from->copy()->addWeeks($interval), + 'monthly' => $from->copy()->addMonths($interval), + default => $from->copy()->addWeeks($interval), + }; + } +} diff --git a/app/Services/Meet/ReportService.php b/app/Services/Meet/ReportService.php new file mode 100644 index 0000000..9d0e457 --- /dev/null +++ b/app/Services/Meet/ReportService.php @@ -0,0 +1,247 @@ + + */ + public function orgSummary(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array + { + $rooms = $this->roomQuery($ownerRef, $organizationId, $branchId) + ->whereBetween('scheduled_at', [$from, $to]); + + $sessions = Session::owned($ownerRef) + ->whereHas('room', fn ($q) => $q->where('organization_id', $organizationId) + ->when($branchId, fn ($q2) => $q2->where('branch_id', $branchId))) + ->whereBetween('started_at', [$from, $to]); + + $recordings = Recording::owned($ownerRef) + ->whereHas('session.room', fn ($q) => $q->where('organization_id', $organizationId) + ->when($branchId, fn ($q2) => $q2->where('branch_id', $branchId))) + ->whereBetween('created_at', [$from, $to]); + + $totalMinutes = (clone $sessions)->whereNotNull('ended_at')->get()->sum(function (Session $s) { + return $s->started_at && $s->ended_at ? (int) $s->started_at->diffInMinutes($s->ended_at) : 0; + }); + + return [ + 'meetings_scheduled' => (clone $rooms)->count(), + 'meetings_completed' => (clone $rooms)->where('status', 'ended')->count(), + 'meetings_cancelled' => (clone $rooms)->where('status', 'cancelled')->count(), + 'sessions_held' => (clone $sessions)->count(), + 'total_minutes' => $totalMinutes, + 'peak_participants' => (clone $sessions)->max('peak_participants') ?? 0, + 'recordings_count' => (clone $recordings)->count(), + 'recording_storage_bytes' => (int) (clone $recordings)->where('status', 'ready')->sum('file_size'), + 'active_members' => Member::owned($ownerRef)->where('organization_id', $organizationId)->count(), + ]; + } + + /** + * @return array + */ + public function meetingsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array + { + $rooms = $this->roomQuery($ownerRef, $organizationId, $branchId) + ->whereBetween('scheduled_at', [$from, $to]) + ->with(['sessions']) + ->orderBy('scheduled_at') + ->get(); + + $rows = $rooms->map(fn (Room $room) => [ + 'title' => $room->title, + 'type' => $room->type, + 'status' => $room->status, + 'scheduled_at' => $room->scheduled_at?->toIso8601String(), + 'duration_minutes' => $room->duration_minutes, + 'sessions' => $room->sessions->count(), + 'peak_participants' => $room->sessions->max('peak_participants') ?? 0, + 'invitations' => $room->invitations()->count(), + 'accepted' => $room->invitations()->where('status', 'accepted')->count(), + ]); + + return [ + 'total' => $rows->count(), + 'by_type' => $rows->groupBy('type')->map->count(), + 'rows' => $rows, + ]; + } + + /** + * @return array + */ + public function usersReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array + { + $participants = Participant::owned($ownerRef) + ->whereHas('session.room', fn ($q) => $q->where('organization_id', $organizationId) + ->when($branchId, fn ($q2) => $q2->where('branch_id', $branchId))) + ->whereBetween('joined_at', [$from, $to]) + ->get(); + + $hosts = Room::owned($ownerRef) + ->where('organization_id', $organizationId) + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)) + ->whereBetween('scheduled_at', [$from, $to]) + ->select('host_user_ref') + ->distinct() + ->count('host_user_ref'); + + $byUser = $participants->groupBy('user_ref')->map(function (Collection $group, ?string $userRef) { + $minutes = $group->sum(fn (Participant $p) => $p->joined_at && $p->left_at + ? (int) $p->joined_at->diffInMinutes($p->left_at) + : ($p->joined_at ? (int) $p->joined_at->diffInMinutes(now()) : 0)); + + return [ + 'user_ref' => $userRef, + 'display_name' => $group->first()->display_name, + 'meetings_joined' => $group->count(), + 'minutes' => $minutes, + 'hosted' => $group->where('role', 'host')->count(), + ]; + })->sortByDesc('minutes')->values(); + + return [ + 'unique_participants' => $participants->pluck('user_ref')->filter()->unique()->count(), + 'unique_hosts' => $hosts, + 'top_users' => $byUser->take(20), + ]; + } + + /** + * @return array + */ + public function invitationsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array + { + $invites = Invitation::owned($ownerRef) + ->whereHas('room', fn ($q) => $q->where('organization_id', $organizationId) + ->when($branchId, fn ($q2) => $q2->where('branch_id', $branchId))) + ->whereBetween('created_at', [$from, $to]) + ->get(); + + $total = $invites->count(); + + return [ + 'total' => $total, + 'pending' => $invites->where('status', 'pending')->count(), + 'accepted' => $invites->where('status', 'accepted')->count(), + 'declined' => $invites->where('status', 'declined')->count(), + 'acceptance_rate' => $total > 0 ? round($invites->where('status', 'accepted')->count() / $total * 100, 1) : 0, + ]; + } + + /** + * @return array + */ + public function branchRollup(string $ownerRef, int $organizationId, Carbon $from, Carbon $to): array + { + return \App\Models\Branch::owned($ownerRef) + ->where('organization_id', $organizationId) + ->where('is_active', true) + ->orderBy('name') + ->get() + ->map(fn ($branch) => array_merge( + ['branch_id' => $branch->id, 'branch_name' => $branch->name], + $this->orgSummary($ownerRef, $organizationId, $from, $to, $branch->id), + )) + ->all(); + } + + /** + * @param array $data + */ + public function toCsv(string $type, array $data): string + { + return match ($type) { + 'meetings' => $this->meetingsCsv($data), + 'users' => $this->usersCsv($data), + 'summary' => $this->summaryCsv($data), + 'branches' => $this->branchesCsv($data), + default => '', + }; + } + + /** @param array $data */ + protected function meetingsCsv(array $data): string + { + $rows = [['Title', 'Type', 'Status', 'Scheduled', 'Duration', 'Sessions', 'Peak', 'Invites', 'Accepted']]; + foreach ($data['rows'] ?? [] as $row) { + $rows[] = [ + $row['title'], $row['type'], $row['status'], $row['scheduled_at'] ?? '', + (string) ($row['duration_minutes'] ?? ''), (string) $row['sessions'], + (string) $row['peak_participants'], (string) $row['invitations'], (string) $row['accepted'], + ]; + } + + return $this->encodeCsv($rows); + } + + /** @param array $data */ + protected function usersCsv(array $data): string + { + $rows = [['Name', 'User ref', 'Meetings joined', 'Minutes', 'Hosted']]; + foreach ($data['top_users'] ?? [] as $row) { + $rows[] = [ + $row['display_name'], $row['user_ref'] ?? '', (string) $row['meetings_joined'], + (string) $row['minutes'], (string) $row['hosted'], + ]; + } + + return $this->encodeCsv($rows); + } + + /** @param array $data */ + protected function summaryCsv(array $data): string + { + $rows = [['Metric', 'Value']]; + foreach ($data as $key => $value) { + if (is_array($value)) { + continue; + } + $rows[] = [$key, (string) $value]; + } + + return $this->encodeCsv($rows); + } + + /** @param array> $data */ + protected function branchesCsv(array $data): string + { + $rows = [['Branch', 'Scheduled', 'Completed', 'Sessions', 'Minutes', 'Recordings']]; + foreach ($data as $row) { + $rows[] = [ + $row['branch_name'], (string) $row['meetings_scheduled'], (string) $row['meetings_completed'], + (string) $row['sessions_held'], (string) $row['total_minutes'], (string) $row['recordings_count'], + ]; + } + + return $this->encodeCsv($rows); + } + + /** @param array> $rows */ + protected function encodeCsv(array $rows): string + { + return collect($rows)->map(fn ($r) => implode(',', array_map( + fn ($c) => '"'.str_replace('"', '""', (string) $c).'"', + $r, + )))->implode("\n"); + } + + protected function roomQuery(string $ownerRef, int $organizationId, ?int $branchId) + { + return Room::owned($ownerRef) + ->where('organization_id', $organizationId) + ->when($branchId, fn ($q) => $q->where('branch_id', $branchId)); + } +} diff --git a/app/Services/Meet/RoomService.php b/app/Services/Meet/RoomService.php new file mode 100644 index 0000000..3fbac2f --- /dev/null +++ b/app/Services/Meet/RoomService.php @@ -0,0 +1,114 @@ + $data + */ + public function createInstant(User $user, Organization $organization, array $data = []): Room + { + return $this->create($user, $organization, array_merge($data, [ + 'type' => 'instant', + 'status' => 'scheduled', + 'title' => $data['title'] ?? 'Instant meeting', + ])); + } + + /** + * @param array $data + */ + public function createScheduled(User $user, Organization $organization, array $data): Room + { + return $this->create($user, $organization, array_merge($data, [ + 'type' => 'scheduled', + 'status' => 'scheduled', + ])); + } + + /** + * @param array $data + */ + public function create(User $user, Organization $organization, array $data): Room + { + $ownerRef = $user->ownerRef(); + $settings = array_merge(config('meet.default_settings'), $data['settings'] ?? []); + + $room = Room::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'branch_id' => $data['branch_id'] ?? null, + 'host_user_ref' => $ownerRef, + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'type' => $data['type'] ?? 'instant', + 'status' => $data['status'] ?? 'scheduled', + 'slug' => $data['slug'] ?? null, + 'scheduled_at' => $data['scheduled_at'] ?? now(), + 'duration_minutes' => $data['duration_minutes'] ?? 60, + 'timezone' => $data['timezone'] ?? $organization->timezone, + 'passcode' => isset($data['passcode']) ? (string) $data['passcode'] : null, + 'settings' => $settings, + 'source' => $data['source'] ?? null, + ]); + + AuditLogger::record($ownerRef, 'room.created', $organization->id, $ownerRef, Room::class, $room->id); + + app(\App\Services\Integrations\WebhookDispatcher::class)->roomCreated($room); + + return $room; + } + + public function ensurePersonalRoom(User $user, Organization $organization): Room + { + $ownerRef = $user->ownerRef(); + $slug = 'personal-'.Str::slug($organization->slug); + + return Room::firstOrCreate( + [ + 'owner_ref' => $ownerRef, + 'organization_id' => $organization->id, + 'type' => 'personal', + 'slug' => $slug, + ], + [ + 'host_user_ref' => $ownerRef, + 'title' => $user->name."'s meeting room", + 'status' => 'scheduled', + 'scheduled_at' => now(), + 'timezone' => $organization->timezone, + 'settings' => config('meet.default_settings'), + ], + ); + } + + public function cancel(Room $room, string $actorRef, ?User $host = null): Room + { + $room->update(['status' => 'cancelled']); + AuditLogger::record($room->owner_ref, 'room.cancelled', $room->organization_id, $actorRef, Room::class, $room->id); + + if ($host) { + app(CalendarService::class)->syncRoomCancelled($room, $host); + } + + app(\App\Services\Integrations\WebhookDispatcher::class)->roomCancelled($room); + + return $room; + } + + public function mediaRoomName(Room $room, ?string $sessionUuid = null): string + { + return 'meet-'.$room->uuid.($sessionUuid ? '-'.$sessionUuid : ''); + } +} diff --git a/app/Services/Meet/SecurityPolicyService.php b/app/Services/Meet/SecurityPolicyService.php new file mode 100644 index 0000000..5e4a86b --- /dev/null +++ b/app/Services/Meet/SecurityPolicyService.php @@ -0,0 +1,78 @@ +organization; + $policy = $this->mergedPolicy($org, $room); + + if ($policy['org_only'] && ! $user) { + return [false, 'This meeting requires a Ladill account.']; + } + + if ($policy['authenticated_only'] && ! $user) { + return [false, 'Please sign in to join this meeting.']; + } + + if (! empty($policy['allowed_domains']) && $email) { + $domain = Str($email)->after('@')->lower()->toString(); + $allowed = collect($policy['allowed_domains'])->map(fn ($d) => strtolower(trim($d)))->filter(); + if ($allowed->isNotEmpty() && ! $allowed->contains($domain)) { + return [false, 'Your email domain is not allowed to join this meeting.']; + } + } + + if ($room->passcode && ! session()->get("meet.passcode.{$room->uuid}")) { + return [false, 'passcode_required']; + } + + if ($room->activeSession()?->is_locked && ! $this->isHost($room, $user)) { + return [false, 'This meeting is locked.']; + } + + if ($policy['recording_host_only'] && ! $this->isHost($room, $user)) { + // enforced at recording start + } + + return [true, null]; + } + + public function isHost(Room $room, ?User $user): bool + { + return $user && $user->ownerRef() === $room->host_user_ref; + } + + /** + * @param array $policy + */ + public function updateOrganizationPolicy(Organization $org, array $policy): Organization + { + $org->update([ + 'security_policy' => array_merge($org->security_policy ?? [], $policy), + ]); + + AuditLogger::record($org->owner_ref, 'organization.security_updated', $org->id, auth()->user()?->ownerRef()); + + return $org->fresh(); + } + + /** + * @return array + */ + protected function mergedPolicy(Organization $org, Room $room): array + { + return array_merge( + config('meet.security_defaults'), + $org->security_policy ?? [], + $room->settings['security'] ?? [], + ); + } +} diff --git a/app/Services/Meet/SessionService.php b/app/Services/Meet/SessionService.php new file mode 100644 index 0000000..fa3b794 --- /dev/null +++ b/app/Services/Meet/SessionService.php @@ -0,0 +1,213 @@ +activeSession(); + if ($existing) { + return $existing; + } + + app(LicenseService::class)->assertCanStartSession($room->organization, $room->branch_id); + + $session = Session::create([ + 'owner_ref' => $room->owner_ref, + 'room_id' => $room->id, + 'media_room_name' => $this->rooms->mediaRoomName($room), + 'status' => 'live', + 'started_at' => now(), + ]); + + $room->update(['status' => 'live']); + + $this->media->createRoom($session->media_room_name); + + $this->joinParticipant($session, $host, 'host'); + + if ($room->setting('auto_record', false)) { + app(RecordingService::class)->start($session, $host); + } + + app(MeetNotificationService::class)->meetingStarted($session, $host); + + app(\App\Services\Integrations\WebhookDispatcher::class)->meetingStarted($session); + + AuditLogger::record($room->owner_ref, 'session.started', $room->organization_id, $host->ownerRef(), Session::class, $session->id); + + return $session; + } + + public function end(Session $session, string $actorRef): Session + { + $session->update([ + 'status' => 'ended', + 'ended_at' => now(), + ]); + + $session->room->update(['status' => 'ended']); + + $session->participants() + ->where('status', 'joined') + ->update(['status' => 'left', 'left_at' => now()]); + + foreach ($session->recordings()->where('status', 'recording')->get() as $recording) { + app(RecordingService::class)->stop($recording, $actorRef); + } + + if ($session->room->isRecurring()) { + app(RecurringMeetingService::class)->spawnNextOccurrences($session->room, 1); + } + + AuditLogger::record($session->owner_ref, 'session.ended', $session->room->organization_id, $actorRef, Session::class, $session->id); + + app(\App\Services\Integrations\WebhookDispatcher::class)->meetingEnded($session); + + app(UsageMeteringService::class)->recordSessionUsage($session); + + return $session; + } + + public function joinParticipant( + Session $session, + ?User $user, + string $role, + ?string $displayName = null, + ?string $email = null, + ): Participant { + $userRef = $user?->ownerRef(); + + $existing = $session->participants() + ->when($userRef, fn ($q) => $q->where('user_ref', $userRef)) + ->when(! $userRef && $email, fn ($q) => $q->where('email', $email)) + ->whereIn('status', ['invited', 'waiting', 'joined']) + ->first(); + + if ($existing) { + $existing->update([ + 'status' => 'joined', + 'joined_at' => now(), + 'left_at' => null, + 'display_name' => $displayName ?? $existing->display_name, + 'ip_address' => request()?->ip(), + ]); + + return $existing; + } + + $participant = Participant::create([ + 'owner_ref' => $session->owner_ref, + 'session_id' => $session->id, + 'user_ref' => $userRef, + 'display_name' => $displayName ?? $user?->name ?? 'Guest', + 'email' => $email, + 'role' => $role, + 'status' => 'joined', + 'joined_at' => now(), + 'ip_address' => request()?->ip(), + ]); + + $this->updatePeakParticipants($session); + + AuditLogger::record( + $session->owner_ref, + 'participant.joined', + $session->room->organization_id, + $userRef, + Participant::class, + $participant->id, + ); + + return $participant; + } + + public function leaveParticipant(Participant $participant): Participant + { + $participant->update([ + 'status' => 'left', + 'left_at' => now(), + 'hand_raised' => false, + ]); + + AuditLogger::record( + $participant->owner_ref, + 'participant.left', + $participant->session->room->organization_id, + $participant->user_ref, + Participant::class, + $participant->id, + ); + + return $participant; + } + + public function generateMediaToken(Participant $participant): string + { + $session = $participant->session; + $room = $session->room; + $isHost = $participant->isHost(); + $breakoutService = app(BreakoutService::class); + $webinarService = app(WebinarService::class); + $mediaRoom = $breakoutService->mediaRoomForParticipant($participant); + $canPublish = $webinarService->canPublish($room, $participant) && $participant->canPublishMedia(); + + return $this->media->generateToken( + $mediaRoom, + $participant->uuid, + $participant->display_name, + [ + 'can_publish' => $canPublish, + 'can_subscribe' => true, + 'can_publish_data' => true, + 'room_admin' => $isHost, + ], + ); + } + + public function getOrStartSession(Room $room, User $host): Session + { + if ($room->activeSession()) { + return $room->activeSession(); + } + + return $this->start($room, $host); + } + + public function lock(Session $session, string $actorRef): Session + { + $session->update(['is_locked' => true, 'locked_at' => now()]); + AuditLogger::record($session->owner_ref, 'meeting.locked', $session->room->organization_id, $actorRef, Session::class, $session->id); + + return $session; + } + + public function unlock(Session $session, string $actorRef): Session + { + $session->update(['is_locked' => false, 'locked_at' => null]); + AuditLogger::record($session->owner_ref, 'meeting.unlocked', $session->room->organization_id, $actorRef, Session::class, $session->id); + + return $session; + } + + protected function updatePeakParticipants(Session $session): void + { + $count = $session->participants()->where('status', 'joined')->count(); + if ($count > $session->peak_participants) { + $session->update(['peak_participants' => $count]); + } + } +} diff --git a/app/Services/Meet/TemplateService.php b/app/Services/Meet/TemplateService.php new file mode 100644 index 0000000..a3ad96d --- /dev/null +++ b/app/Services/Meet/TemplateService.php @@ -0,0 +1,42 @@ + $data + */ + public function create(User $user, Organization $organization, array $data): Template + { + if ($data['is_default'] ?? false) { + Template::owned($user->ownerRef()) + ->where('organization_id', $organization->id) + ->update(['is_default' => false]); + } + + return Template::create([ + 'owner_ref' => $user->ownerRef(), + 'organization_id' => $organization->id, + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'duration_minutes' => $data['duration_minutes'] ?? 60, + 'settings' => array_merge(config('meet.default_settings'), $data['settings'] ?? []), + 'is_default' => (bool) ($data['is_default'] ?? false), + ]); + } + + public function applyToRoomData(Template $template, array $data): array + { + return array_merge($data, [ + 'duration_minutes' => $data['duration_minutes'] ?? $template->duration_minutes, + 'settings' => array_merge($template->mergedSettings(), $data['settings'] ?? []), + 'template_id' => $template->id, + ]); + } +} diff --git a/app/Services/Meet/TownHallService.php b/app/Services/Meet/TownHallService.php new file mode 100644 index 0000000..3e330af --- /dev/null +++ b/app/Services/Meet/TownHallService.php @@ -0,0 +1,110 @@ +type === 'town_hall', 422); + + $session = Session::create([ + 'owner_ref' => $room->owner_ref, + 'room_id' => $room->id, + 'media_room_name' => app(RoomService::class)->mediaRoomName($room, 'green'), + 'status' => 'live', + 'session_mode' => 'green_room', + 'presenter_refs' => [$host->ownerRef()], + 'started_at' => now(), + ]); + + $room->update(['status' => 'live']); + + app(\App\Services\Meet\Media\MediaProviderInterface::class)->createRoom($session->media_room_name); + + app(SessionService::class)->joinParticipant($session, $host, 'host'); + + return $session; + } + + public function goLive(Session $greenRoomSession, User $host): Session + { + abort_unless($greenRoomSession->session_mode === 'green_room', 422); + + $room = $greenRoomSession->room; + $presenters = $greenRoomSession->presenter_refs ?? []; + + $greenRoomSession->update(['status' => 'ended', 'ended_at' => now()]); + + $liveSession = Session::create([ + 'owner_ref' => $room->owner_ref, + 'room_id' => $room->id, + 'media_room_name' => app(RoomService::class)->mediaRoomName($room), + 'status' => 'live', + 'session_mode' => 'live', + 'presenter_refs' => $presenters, + 'started_at' => now(), + ]); + + $room->update(['status' => 'live']); + + app(\App\Services\Meet\Media\MediaProviderInterface::class)->createRoom($liveSession->media_room_name); + + app(SessionService::class)->joinParticipant($liveSession, $host, 'host'); + + foreach ($greenRoomSession->participants()->where('status', 'joined')->get() as $participant) { + if ($participant->user_ref && ! in_array($participant->user_ref, $presenters, true)) { + continue; + } + } + + return $liveSession; + } + + public function handoffCoHost(Session $session, Participant $from, string $toUserRef): Participant + { + abort_unless(in_array($from->role, ['host', 'co_host'], true), 403); + + $target = $session->participants() + ->where('user_ref', $toUserRef) + ->where('status', 'joined') + ->firstOrFail(); + + $from->update(['role' => 'participant']); + $target->update(['role' => 'co_host']); + + $presenters = collect($session->presenter_refs ?? []) + ->reject(fn ($ref) => $ref === $from->user_ref) + ->push($toUserRef) + ->unique() + ->values() + ->all(); + + $session->update(['presenter_refs' => $presenters]); + + return $target->fresh(); + } + + public function addPresenter(Session $session, string $userRef): Session + { + $presenters = collect($session->presenter_refs ?? []) + ->push($userRef) + ->unique() + ->values() + ->all(); + + $session->update(['presenter_refs' => $presenters]); + + $session->participants() + ->where('user_ref', $userRef) + ->where('status', 'joined') + ->update(['role' => 'panelist']); + + return $session->fresh(); + } +} diff --git a/app/Services/Meet/TranscriptService.php b/app/Services/Meet/TranscriptService.php new file mode 100644 index 0000000..40b3488 --- /dev/null +++ b/app/Services/Meet/TranscriptService.php @@ -0,0 +1,71 @@ +transcripts()->where('status', '!=', 'failed')->latest()->first(); + if ($existing) { + return $existing; + } + + $transcript = Transcript::create([ + 'owner_ref' => $session->owner_ref, + 'session_id' => $session->id, + 'recording_id' => $recording?->id, + 'status' => 'processing', + 'live_captions_enabled' => (bool) $session->room->setting('live_captions', false), + ]); + + $this->populateFromChat($transcript, $session); + + GenerateAiSummary::dispatch($transcript->id); + + return $transcript->fresh(); + } + + public function appendSegment(Transcript $transcript, string $speaker, string $text): Transcript + { + $segments = $transcript->segments ?? []; + $segments[] = [ + 'speaker' => $speaker, + 'text' => trim($text), + 'at' => now()->toIso8601String(), + ]; + + $content = collect($segments)->map(fn ($s) => ($s['speaker'] ?? 'Speaker').': '.($s['text'] ?? ''))->implode("\n"); + + $transcript->update([ + 'segments' => $segments, + 'content' => $content, + 'status' => 'ready', + ]); + + return $transcript; + } + + protected function populateFromChat(Transcript $transcript, Session $session): void + { + $messages = $session->messages()->orderBy('created_at')->get(); + $segments = $messages->map(fn ($m) => [ + 'speaker' => $m->sender_name, + 'text' => $m->body, + 'at' => $m->created_at->toIso8601String(), + ])->all(); + + $content = $messages->map(fn ($m) => $m->sender_name.': '.$m->body)->implode("\n"); + + $transcript->update([ + 'segments' => $segments, + 'content' => $content ?: 'No transcript content available.', + 'status' => 'ready', + ]); + } +} diff --git a/app/Services/Meet/UsageMeteringService.php b/app/Services/Meet/UsageMeteringService.php new file mode 100644 index 0000000..d6ba958 --- /dev/null +++ b/app/Services/Meet/UsageMeteringService.php @@ -0,0 +1,119 @@ +room; + $organization = $room->organization; + $minutes = $this->participantMinutes($session); + + UsageRecord::create([ + 'owner_ref' => $session->owner_ref, + 'organization_id' => $organization->id, + 'branch_id' => $room->branch_id, + 'metric' => 'participant_minutes', + 'quantity' => $minutes, + 'metadata' => ['session_uuid' => $session->uuid, 'room_uuid' => $room->uuid], + 'recorded_at' => now(), + ]); + + $this->maybeDebit($organization, $minutes, 'participant_minutes', $session->uuid); + } + + public function recordRecordingStorage(Organization $organization, int $bytes, string $recordingUuid): void + { + UsageRecord::create([ + 'owner_ref' => $organization->owner_ref, + 'organization_id' => $organization->id, + 'metric' => 'recording_storage_bytes', + 'quantity' => $bytes, + 'metadata' => ['recording_uuid' => $recordingUuid], + 'recorded_at' => now(), + ]); + } + + public function recordAiMinutes(Organization $organization, int $minutes, string $sessionUuid): void + { + UsageRecord::create([ + 'owner_ref' => $organization->owner_ref, + 'organization_id' => $organization->id, + 'metric' => 'ai_minutes', + 'quantity' => $minutes, + 'metadata' => ['session_uuid' => $sessionUuid], + 'recorded_at' => now(), + ]); + + $this->maybeDebit($organization, $minutes, 'ai_minutes', $sessionUuid); + } + + public function usageSummary(Organization $organization, ?int $branchId = null, int $days = 30): array + { + $since = now()->subDays($days); + $query = UsageRecord::where('organization_id', $organization->id) + ->where('recorded_at', '>=', $since); + + if ($branchId) { + $query->where('branch_id', $branchId); + } + + return $query + ->selectRaw('metric, SUM(quantity) as total') + ->groupBy('metric') + ->pluck('total', 'metric') + ->all(); + } + + protected function participantMinutes(Session $session): int + { + if (! $session->started_at) { + return 0; + } + + $ended = $session->ended_at ?? now(); + $durationMinutes = max(1, (int) ceil($session->started_at->diffInSeconds($ended) / 60)); + $participants = max(1, $session->participants()->count()); + + return $durationMinutes * $participants; + } + + protected function maybeDebit(Organization $organization, int $quantity, string $metric, string $reference): void + { + $rates = config('meet.billing.rates', []); + $rateMinor = (int) ($rates[$metric] ?? 0); + if ($rateMinor <= 0) { + return; + } + + $amountMinor = $quantity * $rateMinor; + + try { + $this->billing->debit( + $organization->owner_ref, + $amountMinor, + 'meet_'.$metric, + 'meet-usage-'.$reference, + 'Ladill Meet '.$metric, + ); + } catch (\Throwable $e) { + Log::warning('Meet usage billing debit failed', [ + 'organization' => $organization->id, + 'metric' => $metric, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Meet/WebinarService.php b/app/Services/Meet/WebinarService.php new file mode 100644 index 0000000..68039ce --- /dev/null +++ b/app/Services/Meet/WebinarService.php @@ -0,0 +1,72 @@ +type === 'webinar', 422, 'Not a webinar room.'); + + $autoApprove = $room->setting('webinar_auto_approve', false); + + return WebinarRegistration::updateOrCreate( + ['room_id' => $room->id, 'email' => $email], + [ + 'owner_ref' => $room->owner_ref, + 'display_name' => $displayName, + 'status' => $autoApprove ? 'approved' : 'pending', + 'approved_at' => $autoApprove ? now() : null, + 'metadata' => $metadata, + ], + ); + } + + public function approve(WebinarRegistration $registration, User $host): WebinarRegistration + { + $registration->update(['status' => 'approved', 'approved_at' => now()]); + + return $registration->fresh(); + } + + public function reject(WebinarRegistration $registration): WebinarRegistration + { + $registration->update(['status' => 'rejected']); + + return $registration->fresh(); + } + + public function findApprovedRegistration(Room $room, string $email): ?WebinarRegistration + { + return WebinarRegistration::where('room_id', $room->id) + ->where('email', $email) + ->where('status', 'approved') + ->first(); + } + + public function findByAccessToken(string $token): ?WebinarRegistration + { + return WebinarRegistration::where('access_token', $token)->where('status', 'approved')->first(); + } + + public function isWebinarAttendee(Room $room, Participant $participant): bool + { + return $room->type === 'webinar' && $participant->role === 'attendee'; + } + + public function canPublish(Room $room, Participant $participant): bool + { + if ($room->type !== 'webinar') { + return true; + } + + return in_array($participant->role, ['host', 'co_host', 'panelist'], true); + } +} diff --git a/app/Services/Meet/WhiteboardService.php b/app/Services/Meet/WhiteboardService.php new file mode 100644 index 0000000..031ce4e --- /dev/null +++ b/app/Services/Meet/WhiteboardService.php @@ -0,0 +1,66 @@ + $session->id], + [ + 'owner_ref' => $session->owner_ref, + 'room_id' => $session->room_id, + 'template' => $template, + 'state' => $this->templateState($template), + ], + ); + } + + public function ensureForRoom(Room $room, string $template = 'blank'): Whiteboard + { + return Whiteboard::firstOrCreate( + ['room_id' => $room->id, 'session_id' => null], + [ + 'owner_ref' => $room->owner_ref, + 'template' => $template, + 'state' => $this->templateState($template), + ], + ); + } + + /** + * @param array $state + */ + public function saveState(Whiteboard $whiteboard, array $state): Whiteboard + { + $whiteboard->update(['state' => $state]); + + return $whiteboard->fresh(); + } + + /** + * @return array + */ + protected function templateState(string $template): array + { + return match ($template) { + 'grid' => ['template' => 'grid', 'strokes' => [], 'objects' => [], 'background' => '#f8fafc'], + 'retrospective' => [ + 'template' => 'retrospective', + 'strokes' => [], + 'objects' => [ + ['type' => 'column', 'label' => 'Went well', 'x' => 40, 'y' => 40], + ['type' => 'column', 'label' => 'To improve', 'x' => 340, 'y' => 40], + ['type' => 'column', 'label' => 'Actions', 'x' => 640, 'y' => 40], + ], + 'background' => '#ffffff', + ], + default => ['template' => 'blank', 'strokes' => [], 'objects' => [], 'background' => '#ffffff'], + }; + } +} diff --git a/app/Services/Meet/WorkspaceSearchService.php b/app/Services/Meet/WorkspaceSearchService.php new file mode 100644 index 0000000..a73adae --- /dev/null +++ b/app/Services/Meet/WorkspaceSearchService.php @@ -0,0 +1,56 @@ + [], 'messages' => []]; + } + + $like = '%'.$term.'%'; + + $channelMessages = ChannelMessage::owned($ownerRef) + ->whereHas('channel', fn ($q) => $q->where('organization_id', $organization->id)) + ->where('body', 'like', $like) + ->orderByDesc('created_at') + ->limit($limit) + ->with('channel:id,uuid,name') + ->get() + ->map(fn (ChannelMessage $m) => [ + 'type' => 'channel', + 'uuid' => $m->uuid, + 'body' => $m->body, + 'channel' => $m->channel?->name, + 'channel_uuid' => $m->channel?->uuid, + 'created_at' => $m->created_at?->toIso8601String(), + ]); + + $directMessages = DirectMessage::owned($ownerRef) + ->whereHas('conversation', fn ($q) => $q->where('organization_id', $organization->id)) + ->where('body', 'like', $like) + ->orderByDesc('created_at') + ->limit($limit) + ->with('conversation:id,uuid') + ->get() + ->map(fn (DirectMessage $m) => [ + 'type' => 'dm', + 'uuid' => $m->uuid, + 'body' => $m->body, + 'conversation_uuid' => $m->conversation?->uuid, + 'created_at' => $m->created_at?->toIso8601String(), + ]); + + return [ + 'messages' => $channelMessages->merge($directMessages)->sortByDesc('created_at')->values()->take($limit)->all(), + ]; + } +} diff --git a/app/Support/LadillAppUrl.php b/app/Support/LadillAppUrl.php new file mode 100644 index 0000000..c894f57 --- /dev/null +++ b/app/Support/LadillAppUrl.php @@ -0,0 +1,15 @@ + $title, + ]; + } +} diff --git a/app/Support/OrganizationBranding.php b/app/Support/OrganizationBranding.php new file mode 100644 index 0000000..e4597b1 --- /dev/null +++ b/app/Support/OrganizationBranding.php @@ -0,0 +1,50 @@ +logo_path && Storage::disk('public')->exists($organization->logo_path)) { + $version = $organization->updated_at?->getTimestamp() ?? time(); + + return Storage::disk('public')->url($organization->logo_path).'?v='.$version; + } + + $path = public_path(self::DEFAULT_LOGO); + + return asset(self::DEFAULT_LOGO).'?v='.(@filemtime($path) ?: '1'); + } + + public static function logoAlt(Organization $organization): string + { + return $organization->logo_path ? $organization->name : 'Ladill Meet'; + } + + public static function hasCustomLogo(Organization $organization): bool + { + return $organization->logo_path !== null + && Storage::disk('public')->exists($organization->logo_path); + } + + public static function storeLogo(Organization $organization, UploadedFile $file): string + { + self::deleteStoredLogo($organization); + + return $file->store('care/organizations/'.$organization->id, 'public'); + } + + public static function deleteStoredLogo(Organization $organization): void + { + if ($organization->logo_path) { + Storage::disk('public')->delete($organization->logo_path); + } + } +} diff --git a/app/Support/UserProfileMenu.php b/app/Support/UserProfileMenu.php new file mode 100644 index 0000000..9461caf --- /dev/null +++ b/app/Support/UserProfileMenu.php @@ -0,0 +1,163 @@ +> + */ + public static function items(?Authenticatable $user = null): array + { + $user ??= auth()->user(); + + if (! $user) { + return []; + } + + $items = []; + + foreach ([ + ['label' => 'Home', 'path' => '', 'host' => 'home'], + ['label' => 'Profile', 'path' => 'profile'], + ['label' => 'Account Settings', 'path' => 'account-settings'], + ['label' => 'Dashboard', 'path' => 'dashboard'], + ['label' => 'Billing', 'path' => 'billing'], + ] as $link) { + $href = self::platformUrl($link['host'] ?? 'account', $link['path']); + + if (self::isCurrentMenuLink($link, $href)) { + continue; + } + + $items[] = [ + 'type' => 'link', + 'label' => $link['label'], + 'href' => $href, + ]; + } + + if (Route::has((string) config('billing.wallet_balance_route', 'wallet.balance'))) { + $items[] = ['type' => 'wallet']; + } + + if (self::userIsAdmin($user)) { + $adminHref = self::platformUrl('account', 'admin'); + + if (! self::isCurrentMenuLink(['path' => 'admin', 'host' => 'account'], $adminHref)) { + $items[] = [ + 'type' => 'link', + 'label' => 'Admin', + 'href' => $adminHref, + ]; + } + } + + if (Route::has('logout')) { + $items[] = [ + 'type' => 'logout', + 'label' => 'Logout', + 'action' => route('logout'), + ]; + } + + return $items; + } + + private static function platformUrl(string $host, string $path = ''): string + { + if ($host === 'home') { + if (function_exists('ladill_home_url')) { + return ladill_home_url($path); + } + + $domain = config('app.home_domain'); + if (! $domain) { + $platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: ''); + $domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain'); + } + + return self::absoluteUrl((string) $domain, $path); + } + + if (function_exists('ladill_account_url')) { + return ladill_account_url($path); + } + + return self::absoluteUrl((string) config('app.account_domain'), $path); + } + + /** + * Hide a menu link when the signed-in user is already on that destination. + * + * @param array{label?: string, path?: string, host?: string} $link + */ + private static function isCurrentMenuLink(array $link, string $href): bool + { + if (app()->runningInConsole() && ! app()->runningUnitTests()) { + return false; + } + + $request = request(); + if (! $request) { + return false; + } + + $linkHost = strtolower((string) parse_url($href, PHP_URL_HOST)); + $currentHost = strtolower($request->getHost()); + + if ($linkHost === '' || $linkHost !== $currentHost) { + return false; + } + + $currentPath = trim($request->getPathInfo(), '/'); + $targetPath = trim((string) parse_url($href, PHP_URL_PATH), '/'); + + if (($link['host'] ?? 'account') === 'home') { + return $currentPath === ''; + } + + if (($link['path'] ?? '') === 'dashboard') { + return in_array($currentPath, ['dashboard', 'account'], true) + || ($targetPath !== '' && self::pathMatchesSection($currentPath, $targetPath)); + } + + return self::pathMatchesSection($currentPath, $targetPath); + } + + private static function pathMatchesSection(string $currentPath, string $targetPath): bool + { + if ($targetPath === '') { + return $currentPath === ''; + } + + return $currentPath === $targetPath + || str_starts_with($currentPath, $targetPath.'/'); + } + + private static function absoluteUrl(string $domain, string $path = ''): string + { + $base = 'https://'.trim($domain, '/'); + + if ($path === '') { + return $base; + } + + return $base.'/'.ltrim($path, '/'); + } + + private static function userIsAdmin(Authenticatable $user): bool + { + if (method_exists($user, 'isAdmin')) { + return $user->isAdmin(); + } + + return (bool) ($user->is_admin ?? false); + } +} diff --git a/app/Support/helpers.php b/app/Support/helpers.php new file mode 100644 index 0000000..6ae16b2 --- /dev/null +++ b/app/Support/helpers.php @@ -0,0 +1,55 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..16408be --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,72 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + then: function () { + \Illuminate\Support\Facades\Route::bind('room', function (string $value) { + return \App\Models\Room::where('uuid', $value)->firstOrFail(); + }); + \Illuminate\Support\Facades\Route::bind('session', function (string $value) { + return \App\Models\Session::where('uuid', $value)->firstOrFail(); + }); + \Illuminate\Support\Facades\Route::bind('recording', function (string $value) { + return \App\Models\Recording::where('uuid', $value)->firstOrFail(); + }); + \Illuminate\Support\Facades\Route::bind('summary', function (string $value) { + return \App\Models\AiSummary::where('uuid', $value)->firstOrFail(); + }); + \Illuminate\Support\Facades\Route::bind('template', function (string $value) { + return \App\Models\Template::where('uuid', $value)->firstOrFail(); + }); + \Illuminate\Support\Facades\Route::bind('invitation', function (string $value) { + return \App\Models\Invitation::where('uuid', $value)->firstOrFail(); + }); + \Illuminate\Support\Facades\Route::bind('webhook', function (string $value) { + return \App\Models\WebhookEndpoint::where('uuid', $value)->firstOrFail(); + }); + \Illuminate\Support\Facades\Route::bind('poll', function (string $value) { + return \App\Models\MeetPoll::where('uuid', $value)->firstOrFail(); + }); + \Illuminate\Support\Facades\Route::bind('breakout', function (string $value) { + return \App\Models\BreakoutRoom::where('uuid', $value)->firstOrFail(); + }); + \Illuminate\Support\Facades\Route::bind('question', function (string $value) { + return \App\Models\QaQuestion::where('uuid', $value)->firstOrFail(); + }); + \Illuminate\Support\Facades\Route::bind('file', function (string $value) { + return \App\Models\SessionFile::where('uuid', $value)->firstOrFail(); + }); + \Illuminate\Support\Facades\Route::bind('registration', function (string $value) { + return \App\Models\WebinarRegistration::where('uuid', $value)->firstOrFail(); + }); + }, + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [ + 'redirect' => $request->fullUrl(), + ])); + $middleware->web(append: [ + SetActingAccount::class, + ]); + $middleware->alias([ + 'auth.service' => AuthenticateService::class, + 'platform.session' => EnsurePlatformSession::class, + 'meet.setup' => \App\Http\Middleware\EnsureOrganizationSetup::class, + 'meet.ability' => \App\Http\Middleware\EnsureMeetAbility::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "chillerlan/php-qrcode", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-qrcode.git", + "reference": "7b66282572fc14075c0507d74d9837dab25b38d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/7b66282572fc14075c0507d74d9837dab25b38d6", + "reference": "7b66282572fc14075c0507d74d9837dab25b38d6", + "shasum": "" + }, + "require": { + "chillerlan/php-settings-container": "^2.1.6 || ^3.2.1", + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "chillerlan/php-authenticator": "^4.3.1 || ^5.2.1", + "ext-fileinfo": "*", + "phan/phan": "^5.5.2", + "phpcompatibility/php-compatibility": "10.x-dev", + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^9.6", + "setasign/fpdf": "^1.8.2", + "slevomat/coding-standard": "^8.23.0", + "squizlabs/php_codesniffer": "^4.0.0" + }, + "suggest": { + "chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.", + "setasign/fpdf": "Required to use the QR FPDF output.", + "simple-icons/simple-icons": "SVG icons that you can use to embed as logos in the QR Code" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\QRCode\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "Apache-2.0" + ], + "authors": [ + { + "name": "Kazuhiko Arase", + "homepage": "https://github.com/kazuhikoarase/qrcode-generator" + }, + { + "name": "ZXing Authors", + "homepage": "https://github.com/zxing/zxing" + }, + { + "name": "Ashot Khanamiryan", + "homepage": "https://github.com/khanamiryan/php-qrcode-detector-decoder" + }, + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + }, + { + "name": "Contributors", + "homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors" + } + ], + "description": "A QR Code generator and reader with a user-friendly API. PHP 7.4+", + "homepage": "https://github.com/chillerlan/php-qrcode", + "keywords": [ + "phpqrcode", + "qr", + "qr code", + "qr-reader", + "qrcode", + "qrcode-generator", + "qrcode-reader" + ], + "support": { + "docs": "https://php-qrcode.readthedocs.io", + "issues": "https://github.com/chillerlan/php-qrcode/issues", + "source": "https://github.com/chillerlan/php-qrcode" + }, + "funding": [ + { + "url": "https://ko-fi.com/codemasher", + "type": "Ko-Fi" + } + ], + "time": "2025-11-23T23:51:44+00:00" + }, + { + "name": "chillerlan/php-settings-container", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-settings-container.git", + "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/a0a487cbf5344f721eb504bf0f59bada40c381b7", + "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.1" + }, + "require-dev": { + "phan/phan": "^5.5.2", + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^2.1.31", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpunit/phpunit": "^10.5", + "slevomat/coding-standard": "^8.22", + "squizlabs/php_codesniffer": "^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\Settings\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + } + ], + "description": "A container class for immutable settings objects. Not a DI container.", + "homepage": "https://github.com/chillerlan/php-settings-container", + "keywords": [ + "Settings", + "configuration", + "container", + "helper", + "property hook" + ], + "support": { + "issues": "https://github.com/chillerlan/php-settings-container/issues", + "source": "https://github.com/chillerlan/php-settings-container" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2026-03-20T21:10:52+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.11.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "c987f8ce84b8434fa430795eca0f3430663da72b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/c987f8ce84b8434fa430795eca0f3430663da72b", + "reference": "c987f8ce84b8434fa430795eca0f3430663da72b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.11", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.4", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.11.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:40:51+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:23:43+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.11.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.11.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:30:48+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.6" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-05-23T22:00:21+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.61.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "e8472ca9774452fe50841d9bdced060679f4d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/e8472ca9774452fe50841d9bdced060679f4d58d", + "reference": "e8472ca9774452fe50841d9bdced060679f4d58d", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.9.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.1.41", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-04T14:22:52+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.18", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.18" + }, + "time": "2026-05-19T00:47:18+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.13", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-04-16T14:03:50+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.11.1" + }, + "time": "2026-02-06T14:12:35+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-03-19T13:16:38+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.34.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" + }, + "time": "2026-05-14T10:28:08+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.11.4", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-04-07T09:57:54+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.4" + }, + "time": "2026-05-11T20:49:54+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.52", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-04-27T07:02:15+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.23", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + }, + "time": "2026-05-23T13:41:31+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T08:56:14+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "e0be088d22278583a82da281886e8c3592fbf149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "bc354f47c62301e990b7874fa662326368508e2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", + "reference": "bc354f47c62301e990b7874fa662326368508e2c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:20:33+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "9df847980c436451f4f51d1284491bb4356dd989" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", + "reference": "9df847980c436451f4f51d1284491bb4356dd989", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T08:31:43+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T07:20:23+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:22:37+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T15:22:23+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "8339098cae28673c15cce00d80734af0453054e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", + "reference": "8339098cae28673c15cce00d80734af0453054e2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:20:33+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2676b524340abcfe4d6151ec698463cebafee439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T15:19:22+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T13:44:50+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.29.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-04-20T15:26:14+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.62.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", + "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2026-05-27T04:02:01+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.4", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.8 || ^8.0.8" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-04-21T14:04:20+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..6a83f43 --- /dev/null +++ b/config/app.php @@ -0,0 +1,132 @@ + env('APP_NAME', 'Ladill Meet'), + + /* + |-------------------------------------------------------------------------- + | 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' => '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(',', (string) 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'), + ], + + // Platform identity surfaces (Zoho One model). Ladill Meet (meet.ladill.com). + 'platform_domain' => env('PLATFORM_DOMAIN', parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com'), + 'auth_domain' => env('AUTH_DOMAIN', 'auth.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), + 'account_domain' => env('ACCOUNT_DOMAIN', 'account.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), + 'meet_domain' => env('MEET_DOMAIN', parse_url((string) env('APP_URL', 'https://meet.ladill.com'), PHP_URL_HOST) ?: 'meet.ladill.com'), + +]; diff --git a/config/arkesel.php b/config/arkesel.php new file mode 100644 index 0000000..b2dca9c --- /dev/null +++ b/config/arkesel.php @@ -0,0 +1,13 @@ + env('ARKESEL_API_KEY', ''), + 'sender_id' => env('ARKESEL_SENDER_ID', 'Ladill'), + 'base_url' => env('ARKESEL_BASE_URL', 'https://sms.arkesel.com'), + + // Default country code for normalising local numbers to E.164-without-plus. + 'default_country_code' => env('SMS_DEFAULT_COUNTRY_CODE', '233'), +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + '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', 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 number 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), + +]; diff --git a/config/billing.php b/config/billing.php new file mode 100644 index 0000000..40fe958 --- /dev/null +++ b/config/billing.php @@ -0,0 +1,9 @@ + env('BILLING_API_URL', 'https://ladill.com/api/billing'), + 'api_key' => env('BILLING_API_KEY_MEET'), + 'service' => 'meet', + 'wallet_balance_route' => 'meet.wallet', + 'currency' => 'GHS', +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..b32aead --- /dev/null +++ b/config/cache.php @@ -0,0 +1,117 @@ + 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", + | "failover", "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', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | 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((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..7b97fd2 --- /dev/null +++ b/config/database.php @@ -0,0 +1,204 @@ + 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', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + '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([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + // Read-only access to platform admin settings (Paystack keys, etc.). + 'platform' => [ + 'driver' => 'mysql', + 'host' => env('PLATFORM_DB_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('PLATFORM_DB_PORT', env('DB_PORT', '3306')), + 'database' => env('PLATFORM_DB_DATABASE', 'ladilldb'), + 'username' => env('PLATFORM_DB_USERNAME', env('DB_USERNAME', 'root')), + 'password' => env('PLATFORM_DB_PASSWORD', env('DB_PASSWORD', '')), + 'unix_socket' => env('PLATFORM_DB_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([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : 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([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : 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' => env('DB_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((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + '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'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + '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'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..4ebaa0b --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,87 @@ + 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, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => 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, + 'report' => false, + ], + + 'qr' => [ + 'driver' => 'local', + 'root' => storage_path('app/private/qr'), + 'throw' => false, + 'report' => 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'), + ], + +]; diff --git a/config/identity.php b/config/identity.php new file mode 100644 index 0000000..5422734 --- /dev/null +++ b/config/identity.php @@ -0,0 +1,7 @@ + env('IDENTITY_API_URL', 'https://ladill.com/api'), + 'api_key' => env('IDENTITY_API_KEY_MEET'), +]; diff --git a/config/ladill.php b/config/ladill.php new file mode 100644 index 0000000..7e9e2bf --- /dev/null +++ b/config/ladill.php @@ -0,0 +1,6 @@ + 'care', + 'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/care'), +]; diff --git a/config/ladill_launcher.php b/config/ladill_launcher.php new file mode 100644 index 0000000..f100662 --- /dev/null +++ b/config/ladill_launcher.php @@ -0,0 +1,42 @@ +. +*/ + +$root = config('app.platform_domain', 'ladill.com'); + +return [ + 'apps' => [ + ['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'], + ['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'], + ['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'], + ['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'], + ['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'], + ['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'], + ['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'], + ['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'], + ['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'], + ['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'], + ['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'], + ['name' => 'Frontdesk', 'url' => 'https://frontdesk.'.$root.'/sso/connect?redirect='.urlencode('https://frontdesk.'.$root.'/dashboard'), 'icon' => 'frontdesk.svg'], + ['name' => 'Care', 'url' => 'https://care.'.$root.'/sso/connect?redirect='.urlencode('https://care.'.$root.'/dashboard'), 'icon' => 'care.svg'], + ['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'], + ['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'], + ['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'], + ['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'], + ['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'], + ['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'], + ['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'], + ], +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..b09cb25 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | 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(',', (string) 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', env('APP_NAME', 'Laravel')), + '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, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + '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'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..4df3820 --- /dev/null +++ b/config/mail.php @@ -0,0 +1,132 @@ + $mailScheme, + $mailScheme !== '' => null, + in_array($mailEncryption, ['ssl', 'smtps'], true) => 'smtps', + in_array($mailEncryption, ['tls', 'starttls', 'smtp'], true) => 'smtp', + default => null, +}; + +return [ + + /* + |-------------------------------------------------------------------------- + | Default Mailer + |-------------------------------------------------------------------------- + | + | This option controls the default mailer that is used to send all email + | messages unless another mailer is explicitly specified when sending + | the message. All additional mailers can be configured within the + | "mailers" array. Examples of each type of mailer are provided. + | + */ + + 'default' => 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' => $smtpScheme, + '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((string) 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', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | 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', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/meet.php b/config/meet.php new file mode 100644 index 0000000..603b12a --- /dev/null +++ b/config/meet.php @@ -0,0 +1,205 @@ + [ + 'owner' => 'Owner', + 'admin' => 'Administrator', + 'moderator' => 'Moderator', + 'member' => 'Member', + 'guest' => 'Guest', + ], + + 'room_types' => [ + 'instant' => 'Instant meeting', + 'scheduled' => 'Scheduled meeting', + 'personal' => 'Personal room', + 'recurring' => 'Recurring meeting', + 'webinar' => 'Webinar', + 'town_hall' => 'Town hall', + ], + + 'room_statuses' => [ + 'scheduled' => 'Scheduled', + 'live' => 'Live', + 'ended' => 'Ended', + 'cancelled' => 'Cancelled', + 'recording_processing' => 'Recording processing', + ], + + 'recording_statuses' => [ + 'recording' => 'Recording', + 'processing' => 'Processing', + 'ready' => 'Ready', + 'failed' => 'Failed', + ], + + 'recurrence_rules' => [ + 'daily' => 'Daily', + 'weekly' => 'Weekly', + 'monthly' => 'Monthly', + ], + + 'participant_roles' => [ + 'host' => 'Host', + 'co_host' => 'Co-host', + 'panelist' => 'Panelist', + 'participant' => 'Participant', + 'attendee' => 'Attendee', + 'guest' => 'Guest', + ], + + 'session_statuses' => [ + 'live' => 'Live', + 'ended' => 'Ended', + ], + + 'default_settings' => [ + 'waiting_room' => true, + 'join_before_host' => false, + 'mute_on_join' => true, + 'video_off_on_join' => false, + 'allow_screen_share' => true, + 'allow_chat' => true, + 'allow_reactions' => true, + 'lock_meeting' => false, + 'auto_record' => false, + 'live_captions' => false, + 'auto_ai_summary' => true, + 'lobby_mode' => false, + 'webinar_auto_approve' => false, + 'stage_mode' => true, + ], + + 'breakouts' => [ + 'default_timer_minutes' => 15, + 'max_rooms' => 20, + ], + + 'files' => [ + 'disk' => env('MEET_FILES_DISK', 'local'), + 'default_expiry_days' => (int) env('MEET_FILES_EXPIRY_DAYS', 30), + 'drive_api_url' => env('LADILL_DRIVE_API_URL'), + 'drive_api_key' => env('LADILL_DRIVE_API_KEY_MEET'), + ], + + 'whiteboard_templates' => [ + 'blank' => 'Blank canvas', + 'grid' => 'Grid', + 'retrospective' => 'Retrospective', + ], + + 'security_defaults' => [ + 'org_only' => false, + 'authenticated_only' => false, + 'allowed_domains' => [], + 'recording_host_only' => true, + 'guest_recording_disabled' => true, + 'watermark_enabled' => false, + 'session_idle_minutes' => 120, + ], + + 'recordings' => [ + 'disk' => env('MEET_RECORDINGS_DISK', 'local'), + ], + + 'ai' => [ + 'enabled' => env('MEET_AI_ENABLED', true), + 'auto_summarize' => env('MEET_AI_AUTO_SUMMARIZE', true), + 'api_key' => env('MEET_AI_API_KEY', env('OPENAI_API_KEY')), + 'model' => env('MEET_AI_MODEL', 'gpt-4o-mini'), + 'platform_api_url' => env('IDENTITY_API_URL', 'https://ladill.com/api'), + 'platform_api_key' => env('IDENTITY_API_KEY_MEET'), + ], + + 'media' => [ + 'provider' => env('MEET_MEDIA_PROVIDER', 'livekit'), + 'livekit' => [ + 'url' => env('LIVEKIT_URL', ''), + 'api_key' => env('LIVEKIT_API_KEY', ''), + 'api_secret' => env('LIVEKIT_API_SECRET', ''), + ], + ], + + 'report_types' => [ + 'summary' => 'Organization summary', + 'meetings' => 'Meeting analytics', + 'users' => 'Participant analytics', + 'invitations' => 'Invitation & RSVP', + 'branches' => 'Branch rollup', + ], + + 'service_api_keys' => [ + 'care' => env('MEET_API_KEY_CARE'), + 'crm' => env('MEET_API_KEY_CRM'), + 'bookings' => env('MEET_API_KEY_BOOKINGS'), + 'events' => env('MEET_API_KEY_EVENTS'), + 'frontdesk' => env('MEET_API_KEY_FRONTDESK'), + 'hr' => env('MEET_API_KEY_HR'), + 'support' => env('MEET_API_KEY_SUPPORT'), + ], + + 'webhook_events' => [ + 'meeting.scheduled', + 'meeting.started', + 'meeting.ended', + 'meeting.cancelled', + 'recording.ready', + ], + + 'calendar' => [ + 'google' => [ + 'client_id' => env('GOOGLE_CALENDAR_CLIENT_ID'), + 'client_secret' => env('GOOGLE_CALENDAR_CLIENT_SECRET'), + 'redirect' => env('GOOGLE_CALENDAR_REDIRECT_URI'), + ], + 'microsoft' => [ + 'client_id' => env('MICROSOFT_CALENDAR_CLIENT_ID'), + 'client_secret' => env('MICROSOFT_CALENDAR_CLIENT_SECRET'), + 'redirect' => env('MICROSOFT_CALENDAR_REDIRECT_URI'), + 'tenant' => env('MICROSOFT_CALENDAR_TENANT', 'common'), + ], + 'mail' => [ + 'api_url' => env('LADILL_MAIL_API_URL', 'https://mail.ladill.com/api/service/v1'), + 'api_key' => env('MAIL_API_KEY_MEET'), + ], + ], + + 'live_stream' => [ + 'platforms' => ['youtube', 'facebook', 'linkedin', 'custom'], + 'layouts' => ['speaker', 'gallery', 'screen'], + ], + + 'billing' => [ + 'rates' => [ + 'participant_minutes' => (int) env('MEET_RATE_PARTICIPANT_MINUTE_MINOR', 0), + 'ai_minutes' => (int) env('MEET_RATE_AI_MINUTE_MINOR', 0), + ], + ], + + 'audit_actions' => [ + 'organization.created' => 'Organization created', + 'organization.updated' => 'Organization updated', + 'organization.security_updated' => 'Security policy updated', + 'branch.created' => 'Branch created', + 'branch.updated' => 'Branch updated', + 'member.created' => 'Member added', + 'member.deleted' => 'Member removed', + 'room.created' => 'Meeting created', + 'room.updated' => 'Meeting updated', + 'room.cancelled' => 'Meeting cancelled', + 'session.started' => 'Meeting started', + 'session.ended' => 'Meeting ended', + 'participant.joined' => 'Participant joined', + 'participant.left' => 'Participant left', + 'participant.removed' => 'Participant removed', + 'meeting.locked' => 'Meeting locked', + 'meeting.unlocked' => 'Meeting unlocked', + 'recording.started' => 'Recording started', + 'recording.stopped' => 'Recording stopped', + 'recording.ready' => 'Recording ready', + 'recording.deleted' => 'Recording deleted', + 'notification.meeting_started' => 'Meeting started notification', + ], + +]; diff --git a/config/mobile-topbar.php b/config/mobile-topbar.php new file mode 100644 index 0000000..688b4c7 --- /dev/null +++ b/config/mobile-topbar.php @@ -0,0 +1,5 @@ + 'Meet', +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | 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", + | "deferred", "background", "failover", "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, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | 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', + ], + +]; diff --git a/config/service_events.php b/config/service_events.php new file mode 100644 index 0000000..f93bf20 --- /dev/null +++ b/config/service_events.php @@ -0,0 +1,9 @@ + env('SERVICE_EVENTS_INBOUND_SECRET', ''), +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..5ad6df2 --- /dev/null +++ b/config/services.php @@ -0,0 +1,44 @@ + [ + 'issuer' => 'https://'.config('app.auth_domain'), + 'client_id' => env('LADILL_SSO_CLIENT_ID'), + 'client_secret' => env('LADILL_SSO_CLIENT_SECRET'), + 'redirect' => rtrim((string) env('APP_URL', 'https://care.ladill.com'), '/').'/sso/callback', + ], + + // Central Ladill identity API (auth.ladill.com /api/identity/auth/*), + // gated by the shared first-party service key. + 'ladill_identity' => [ + 'url' => 'https://'.config('app.auth_domain'), + 'key' => env('IDENTITY_API_KEY_CARE'), + ], + + // Outbound SMS to contacts uses Arkesel (the platform provider) — see + // config/arkesel.php and App\Services\Comms\SmsService. + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..570bbab --- /dev/null +++ b/config/session.php @@ -0,0 +1,217 @@ + 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' => (int) env('SESSION_LIFETIME', 1440), + + '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: "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((string) 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 without 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), + +]; diff --git a/config/signed_out.php b/config/signed_out.php new file mode 100644 index 0000000..6b00a5c --- /dev/null +++ b/config/signed_out.php @@ -0,0 +1,7 @@ + 'Ladill Meet', + 'logo' => 'images/logo/ladillmeet-logo.svg', + 'description' => 'Your Ladill Meet session has ended. Sign in again to manage your meetings.', +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..a138076 --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,52 @@ +id(); + $table->uuid('public_id')->unique(); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->string('avatar_url')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password')->nullable(); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->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('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..ed758bd --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->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('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2026_06_23_120000_create_notifications_table.php b/database/migrations/2026_06_23_120000_create_notifications_table.php new file mode 100644 index 0000000..52e3b00 --- /dev/null +++ b/database/migrations/2026_06_23_120000_create_notifications_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_06_30_100000_create_meet_core_tables.php b/database/migrations/2026_06_30_100000_create_meet_core_tables.php new file mode 100644 index 0000000..1c5da5f --- /dev/null +++ b/database/migrations/2026_06_30_100000_create_meet_core_tables.php @@ -0,0 +1,75 @@ +id(); + $table->string('owner_ref')->index(); + $table->string('name'); + $table->string('slug')->index(); + $table->string('logo_path')->nullable(); + $table->string('timezone')->default('UTC'); + $table->json('settings')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['owner_ref', 'slug']); + }); + + Schema::create('meet_branches', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('meet_organizations')->cascadeOnDelete(); + $table->string('name'); + $table->string('code')->nullable(); + $table->string('address')->nullable(); + $table->string('phone')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('meet_members', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('meet_organizations')->cascadeOnDelete(); + $table->string('user_ref')->index(); + $table->string('role'); + $table->foreignId('branch_id')->nullable()->constrained('meet_branches')->nullOnDelete(); + $table->timestamps(); + $table->unique(['organization_id', 'user_ref']); + }); + + Schema::create('meet_audit_logs', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->nullable()->constrained('meet_organizations')->nullOnDelete(); + $table->string('actor_ref')->nullable(); + $table->string('action'); + $table->string('subject_type')->nullable(); + $table->unsignedBigInteger('subject_id')->nullable(); + $table->json('metadata')->nullable(); + $table->string('ip_address')->nullable(); + $table->timestamp('created_at')->useCurrent(); + $table->index(['owner_ref', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('meet_audit_logs'); + Schema::dropIfExists('meet_members'); + Schema::dropIfExists('meet_branches'); + Schema::dropIfExists('meet_organizations'); + } +}; diff --git a/database/migrations/2026_06_30_110000_create_meet_meeting_tables.php b/database/migrations/2026_06_30_110000_create_meet_meeting_tables.php new file mode 100644 index 0000000..fbba1a8 --- /dev/null +++ b/database/migrations/2026_06_30_110000_create_meet_meeting_tables.php @@ -0,0 +1,90 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('meet_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->nullable()->constrained('meet_branches')->nullOnDelete(); + $table->string('host_user_ref')->index(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('type')->default('instant'); // instant, scheduled, personal, recurring + $table->string('status')->default('scheduled'); // scheduled, live, ended, cancelled + $table->string('slug')->nullable()->index(); + $table->timestamp('scheduled_at')->nullable()->index(); + $table->unsignedInteger('duration_minutes')->nullable(); + $table->string('timezone')->default('UTC'); + $table->string('passcode')->nullable(); + $table->json('settings')->nullable(); + $table->json('source')->nullable(); // {app, entity_type, entity_id} + $table->timestamps(); + $table->softDeletes(); + $table->index(['owner_ref', 'status', 'scheduled_at']); + }); + + Schema::create('meet_sessions', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('room_id')->constrained('meet_rooms')->cascadeOnDelete(); + $table->string('media_room_name')->index(); + $table->string('status')->default('live'); // live, ended + $table->timestamp('started_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->unsignedInteger('peak_participants')->default(0); + $table->timestamps(); + }); + + Schema::create('meet_participants', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('session_id')->constrained('meet_sessions')->cascadeOnDelete(); + $table->string('user_ref')->nullable()->index(); + $table->string('display_name'); + $table->string('email')->nullable(); + $table->string('role')->default('participant'); // host, co_host, participant, guest + $table->string('status')->default('invited'); // invited, waiting, joined, left, removed + $table->timestamp('joined_at')->nullable(); + $table->timestamp('left_at')->nullable(); + $table->string('device_info')->nullable(); + $table->string('ip_address')->nullable(); + $table->boolean('hand_raised')->default(false); + $table->boolean('is_muted')->default(false); + $table->boolean('is_video_off')->default(false); + $table->timestamps(); + $table->index(['session_id', 'status']); + }); + + Schema::create('meet_invitations', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('room_id')->constrained('meet_rooms')->cascadeOnDelete(); + $table->string('email')->nullable(); + $table->string('user_ref')->nullable(); + $table->string('display_name')->nullable(); + $table->string('role')->default('participant'); + $table->string('status')->default('pending'); // pending, accepted, declined + $table->timestamp('sent_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('meet_invitations'); + Schema::dropIfExists('meet_participants'); + Schema::dropIfExists('meet_sessions'); + Schema::dropIfExists('meet_rooms'); + } +}; diff --git a/database/migrations/2026_06_30_120000_create_meet_collaboration_tables.php b/database/migrations/2026_06_30_120000_create_meet_collaboration_tables.php new file mode 100644 index 0000000..d24cc05 --- /dev/null +++ b/database/migrations/2026_06_30_120000_create_meet_collaboration_tables.php @@ -0,0 +1,43 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('session_id')->constrained('meet_sessions')->cascadeOnDelete(); + $table->string('sender_ref')->nullable(); + $table->string('sender_name'); + $table->string('type')->default('public'); // public, private + $table->string('recipient_ref')->nullable(); + $table->text('body'); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->index(['session_id', 'created_at']); + }); + + Schema::create('meet_reactions', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('session_id')->constrained('meet_sessions')->cascadeOnDelete(); + $table->string('participant_ref')->nullable(); + $table->string('sender_name'); + $table->string('emoji', 32); + $table->timestamps(); + $table->index(['session_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('meet_reactions'); + Schema::dropIfExists('meet_messages'); + } +}; diff --git a/database/migrations/2026_06_30_130000_create_meet_recordings_and_ai_tables.php b/database/migrations/2026_06_30_130000_create_meet_recordings_and_ai_tables.php new file mode 100644 index 0000000..bbc3ad1 --- /dev/null +++ b/database/migrations/2026_06_30_130000_create_meet_recordings_and_ai_tables.php @@ -0,0 +1,122 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('session_id')->constrained('meet_sessions')->cascadeOnDelete(); + $table->string('status')->default('recording'); // recording, processing, ready, failed + $table->string('layout')->default('gallery'); + $table->string('storage_path')->nullable(); + $table->string('thumbnail_path')->nullable(); + $table->unsignedBigInteger('file_size')->nullable(); + $table->unsignedInteger('duration_seconds')->nullable(); + $table->string('started_by_ref')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->text('failure_reason')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->index(['owner_ref', 'status']); + }); + + Schema::create('meet_transcripts', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('session_id')->constrained('meet_sessions')->cascadeOnDelete(); + $table->foreignId('recording_id')->nullable()->constrained('meet_recordings')->nullOnDelete(); + $table->string('status')->default('pending'); // pending, processing, ready, failed + $table->longText('content')->nullable(); // plain searchable text + $table->json('segments')->nullable(); // [{speaker, text, start, end}] + $table->boolean('live_captions_enabled')->default(false); + $table->timestamps(); + }); + + Schema::create('meet_ai_summaries', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('session_id')->constrained('meet_sessions')->cascadeOnDelete(); + $table->foreignId('transcript_id')->nullable()->constrained('meet_transcripts')->nullOnDelete(); + $table->string('status')->default('pending'); // pending, processing, ready, failed + $table->text('summary')->nullable(); + $table->json('decisions')->nullable(); + $table->timestamps(); + }); + + Schema::create('meet_action_items', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('ai_summary_id')->constrained('meet_ai_summaries')->cascadeOnDelete(); + $table->string('assignee_ref')->nullable(); + $table->string('assignee_name')->nullable(); + $table->text('description'); + $table->date('due_date')->nullable(); + $table->string('status')->default('open'); // open, done, cancelled + $table->timestamps(); + }); + + Schema::create('meet_templates', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('meet_organizations')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->unsignedInteger('duration_minutes')->default(60); + $table->json('settings')->nullable(); + $table->boolean('is_default')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::table('meet_rooms', function (Blueprint $table) { + $table->foreignId('template_id')->nullable()->after('source')->constrained('meet_templates')->nullOnDelete(); + $table->uuid('recurring_series_id')->nullable()->after('template_id')->index(); + $table->string('recurrence_rule')->nullable()->after('recurring_series_id'); // daily, weekly, monthly + $table->unsignedTinyInteger('recurrence_interval')->default(1)->after('recurrence_rule'); + $table->date('recurrence_until')->nullable()->after('recurrence_interval'); + }); + + Schema::table('meet_sessions', function (Blueprint $table) { + $table->boolean('is_locked')->default(false)->after('peak_participants'); + $table->timestamp('locked_at')->nullable()->after('is_locked'); + }); + + Schema::table('meet_organizations', function (Blueprint $table) { + $table->json('security_policy')->nullable()->after('settings'); + }); + } + + public function down(): void + { + Schema::table('meet_organizations', function (Blueprint $table) { + $table->dropColumn('security_policy'); + }); + + Schema::table('meet_sessions', function (Blueprint $table) { + $table->dropColumn(['is_locked', 'locked_at']); + }); + + Schema::table('meet_rooms', function (Blueprint $table) { + $table->dropConstrainedForeignId('template_id'); + $table->dropColumn(['recurring_series_id', 'recurrence_rule', 'recurrence_interval', 'recurrence_until']); + }); + + Schema::dropIfExists('meet_action_items'); + Schema::dropIfExists('meet_ai_summaries'); + Schema::dropIfExists('meet_transcripts'); + Schema::dropIfExists('meet_recordings'); + Schema::dropIfExists('meet_templates'); + } +}; diff --git a/database/migrations/2026_06_30_140000_create_meet_integrations_tables.php b/database/migrations/2026_06_30_140000_create_meet_integrations_tables.php new file mode 100644 index 0000000..7046fb7 --- /dev/null +++ b/database/migrations/2026_06_30_140000_create_meet_integrations_tables.php @@ -0,0 +1,85 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('meet_organizations')->cascadeOnDelete(); + $table->string('user_ref')->index(); + $table->string('provider'); // google, microsoft, apple + $table->text('access_token')->nullable(); + $table->text('refresh_token')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->string('calendar_id')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['user_ref', 'provider']); + }); + + Schema::create('meet_contact_groups', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('meet_organizations')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('meet_contact_group_members', function (Blueprint $table) { + $table->id(); + $table->foreignId('contact_group_id')->constrained('meet_contact_groups')->cascadeOnDelete(); + $table->string('email')->nullable(); + $table->string('user_ref')->nullable(); + $table->string('display_name')->nullable(); + $table->timestamps(); + $table->index(['contact_group_id', 'email']); + }); + + Schema::create('meet_webhook_endpoints', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('meet_organizations')->cascadeOnDelete(); + $table->string('url'); + $table->string('secret')->nullable(); + $table->json('events')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::table('meet_invitations', function (Blueprint $table) { + $table->string('rsvp_token')->nullable()->unique()->after('status'); + $table->timestamp('responded_at')->nullable()->after('rsvp_token'); + $table->string('calendar_event_id')->nullable()->after('responded_at'); + }); + + Schema::table('meet_rooms', function (Blueprint $table) { + $table->string('calendar_event_id')->nullable()->after('source'); + }); + } + + public function down(): void + { + Schema::table('meet_rooms', function (Blueprint $table) { + $table->dropColumn('calendar_event_id'); + }); + + Schema::table('meet_invitations', function (Blueprint $table) { + $table->dropColumn(['rsvp_token', 'responded_at', 'calendar_event_id']); + }); + + Schema::dropIfExists('meet_webhook_endpoints'); + Schema::dropIfExists('meet_contact_group_members'); + Schema::dropIfExists('meet_contact_groups'); + Schema::dropIfExists('meet_calendar_connections'); + } +}; diff --git a/database/migrations/2026_06_30_150000_create_meet_webinar_breakout_file_whiteboard_tables.php b/database/migrations/2026_06_30_150000_create_meet_webinar_breakout_file_whiteboard_tables.php new file mode 100644 index 0000000..8be5421 --- /dev/null +++ b/database/migrations/2026_06_30_150000_create_meet_webinar_breakout_file_whiteboard_tables.php @@ -0,0 +1,157 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('room_id')->constrained('meet_rooms')->cascadeOnDelete(); + $table->string('email'); + $table->string('display_name'); + $table->string('status')->default('pending'); // pending, approved, rejected + $table->string('access_token')->nullable()->unique(); + $table->timestamp('approved_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['room_id', 'email']); + }); + + Schema::create('meet_qa_questions', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('session_id')->constrained('meet_sessions')->cascadeOnDelete(); + $table->string('participant_uuid')->nullable(); + $table->string('asker_name'); + $table->text('question'); + $table->string('status')->default('pending'); // pending, approved, answered, dismissed + $table->text('answer')->nullable(); + $table->string('answered_by')->nullable(); + $table->unsignedInteger('upvotes')->default(0); + $table->timestamps(); + }); + + Schema::create('meet_polls', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('session_id')->constrained('meet_sessions')->cascadeOnDelete(); + $table->string('question'); + $table->json('options'); + $table->string('status')->default('open'); // open, closed + $table->timestamps(); + }); + + Schema::create('meet_poll_votes', function (Blueprint $table) { + $table->id(); + $table->foreignId('poll_id')->constrained('meet_polls')->cascadeOnDelete(); + $table->string('participant_uuid'); + $table->unsignedTinyInteger('option_index'); + $table->timestamps(); + $table->unique(['poll_id', 'participant_uuid']); + }); + + Schema::create('meet_breakout_rooms', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('session_id')->constrained('meet_sessions')->cascadeOnDelete(); + $table->string('name'); + $table->string('media_room_name'); + $table->string('status')->default('open'); // open, closed + $table->timestamp('closes_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('meet_breakout_assignments', function (Blueprint $table) { + $table->id(); + $table->foreignId('breakout_room_id')->constrained('meet_breakout_rooms')->cascadeOnDelete(); + $table->foreignId('participant_id')->constrained('meet_participants')->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['breakout_room_id', 'participant_id']); + }); + + Schema::create('meet_session_files', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('room_id')->constrained('meet_rooms')->cascadeOnDelete(); + $table->foreignId('session_id')->nullable()->constrained('meet_sessions')->nullOnDelete(); + $table->string('uploaded_by_ref')->nullable(); + $table->string('uploaded_by_name'); + $table->string('original_name'); + $table->string('storage_path'); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('file_size')->default(0); + $table->string('drive_file_id')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('meet_file_downloads', function (Blueprint $table) { + $table->id(); + $table->foreignId('session_file_id')->constrained('meet_session_files')->cascadeOnDelete(); + $table->string('downloaded_by_ref')->nullable(); + $table->string('downloaded_by_name')->nullable(); + $table->string('ip_address')->nullable(); + $table->timestamp('downloaded_at'); + }); + + Schema::create('meet_whiteboards', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('room_id')->constrained('meet_rooms')->cascadeOnDelete(); + $table->foreignId('session_id')->nullable()->constrained('meet_sessions')->nullOnDelete(); + $table->string('template')->default('blank'); + $table->json('state')->nullable(); + $table->timestamps(); + }); + + Schema::table('meet_sessions', function (Blueprint $table) { + $table->foreignId('parent_session_id')->nullable()->after('room_id')->constrained('meet_sessions')->nullOnDelete(); + $table->boolean('is_breakout')->default(false)->after('status'); + }); + + Schema::table('meet_messages', function (Blueprint $table) { + $table->foreignId('breakout_room_id')->nullable()->after('session_id')->constrained('meet_breakout_rooms')->nullOnDelete(); + }); + + Schema::table('meet_participants', function (Blueprint $table) { + $table->foreignId('breakout_room_id')->nullable()->after('session_id')->constrained('meet_breakout_rooms')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('meet_participants', function (Blueprint $table) { + $table->dropConstrainedForeignId('breakout_room_id'); + }); + + Schema::table('meet_messages', function (Blueprint $table) { + $table->dropConstrainedForeignId('breakout_room_id'); + }); + + Schema::table('meet_sessions', function (Blueprint $table) { + $table->dropConstrainedForeignId('parent_session_id'); + $table->dropColumn('is_breakout'); + }); + + Schema::dropIfExists('meet_whiteboards'); + Schema::dropIfExists('meet_file_downloads'); + Schema::dropIfExists('meet_session_files'); + Schema::dropIfExists('meet_breakout_assignments'); + Schema::dropIfExists('meet_breakout_rooms'); + Schema::dropIfExists('meet_poll_votes'); + Schema::dropIfExists('meet_polls'); + Schema::dropIfExists('meet_qa_questions'); + Schema::dropIfExists('meet_webinar_registrations'); + } +}; diff --git a/database/migrations/2026_06_30_160000_create_meet_workspaces_streaming_billing_tables.php b/database/migrations/2026_06_30_160000_create_meet_workspaces_streaming_billing_tables.php new file mode 100644 index 0000000..262745f --- /dev/null +++ b/database/migrations/2026_06_30_160000_create_meet_workspaces_streaming_billing_tables.php @@ -0,0 +1,138 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('meet_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->nullable()->constrained('meet_branches')->nullOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->string('visibility')->default('public'); // public, private + $table->text('description')->nullable(); + $table->timestamps(); + $table->unique(['organization_id', 'slug']); + }); + + Schema::create('meet_channel_members', function (Blueprint $table) { + $table->id(); + $table->foreignId('channel_id')->constrained('meet_channels')->cascadeOnDelete(); + $table->string('user_ref')->index(); + $table->string('role')->default('member'); // admin, moderator, member + $table->timestamps(); + $table->unique(['channel_id', 'user_ref']); + }); + + Schema::create('meet_channel_messages', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('channel_id')->constrained('meet_channels')->cascadeOnDelete(); + $table->string('sender_ref')->nullable(); + $table->string('sender_name'); + $table->text('body'); + $table->foreignId('parent_id')->nullable()->constrained('meet_channel_messages')->nullOnDelete(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->index(['channel_id', 'created_at']); + }); + + Schema::create('meet_direct_conversations', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('meet_organizations')->cascadeOnDelete(); + $table->string('type')->default('dm'); // dm, group + $table->string('title')->nullable(); + $table->timestamps(); + }); + + Schema::create('meet_direct_participants', function (Blueprint $table) { + $table->id(); + $table->foreignId('conversation_id')->constrained('meet_direct_conversations')->cascadeOnDelete(); + $table->string('user_ref')->index(); + $table->timestamps(); + $table->unique(['conversation_id', 'user_ref']); + }); + + Schema::create('meet_direct_messages', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('conversation_id')->constrained('meet_direct_conversations')->cascadeOnDelete(); + $table->string('sender_ref')->nullable(); + $table->string('sender_name'); + $table->text('body'); + $table->foreignId('parent_id')->nullable()->constrained('meet_direct_messages')->nullOnDelete(); + $table->timestamps(); + $table->index(['conversation_id', 'created_at']); + }); + + Schema::create('meet_live_streams', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('session_id')->constrained('meet_sessions')->cascadeOnDelete(); + $table->string('platform'); // youtube, facebook, linkedin, custom + $table->string('rtmp_url'); + $table->text('stream_key'); + $table->string('status')->default('idle'); // idle, live, stopped, failed + $table->string('layout')->default('speaker'); + $table->string('health')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('meet_usage_records', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('meet_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->nullable()->constrained('meet_branches')->nullOnDelete(); + $table->string('metric'); // participant_minutes, recording_storage_bytes, ai_minutes + $table->unsignedBigInteger('quantity')->default(0); + $table->json('metadata')->nullable(); + $table->timestamp('recorded_at'); + $table->timestamps(); + $table->index(['organization_id', 'metric', 'recorded_at']); + }); + + Schema::table('meet_organizations', function (Blueprint $table) { + $table->string('license_tier')->default('standard')->after('timezone'); + $table->json('usage_quotas')->nullable()->after('license_tier'); + }); + + Schema::table('meet_sessions', function (Blueprint $table) { + $table->string('session_mode')->default('live')->after('status'); // live, green_room, practice + $table->json('presenter_refs')->nullable()->after('session_mode'); + }); + } + + public function down(): void + { + Schema::table('meet_sessions', function (Blueprint $table) { + $table->dropColumn(['session_mode', 'presenter_refs']); + }); + + Schema::table('meet_organizations', function (Blueprint $table) { + $table->dropColumn(['license_tier', 'usage_quotas']); + }); + + Schema::dropIfExists('meet_usage_records'); + Schema::dropIfExists('meet_live_streams'); + Schema::dropIfExists('meet_direct_messages'); + Schema::dropIfExists('meet_direct_participants'); + Schema::dropIfExists('meet_direct_conversations'); + Schema::dropIfExists('meet_channel_messages'); + Schema::dropIfExists('meet_channel_members'); + Schema::dropIfExists('meet_channels'); + } +}; diff --git a/deploy/bin/ladill-hosting-admin b/deploy/bin/ladill-hosting-admin new file mode 100755 index 0000000..88b5ff1 --- /dev/null +++ b/deploy/bin/ladill-hosting-admin @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' >&2 +Usage: + ladill-hosting-admin self-check + ladill-hosting-admin mkdir + ladill-hosting-admin chown + ladill-hosting-admin chmod + ladill-hosting-admin write-file + ladill-hosting-admin symlink + ladill-hosting-admin nginx-test + ladill-hosting-admin reload-nginx + ladill-hosting-admin certbot-webroot + ladill-hosting-admin certbot-renew + ladill-hosting-admin run-cmd + ladill-hosting-admin read-file +EOF + exit 64 +} + +require_abs_path() { + local path="${1:-}" + + if [[ -z "$path" || "${path#/}" == "$path" ]]; then + echo "Expected an absolute path, got: $path" >&2 + exit 64 + fi +} + +ensure_domain() { + local value="${1:-}" + + if [[ ! "$value" =~ ^[A-Za-z0-9.-]+$ ]]; then + echo "Invalid domain: $value" >&2 + exit 64 + fi +} + +ensure_owner_group() { + local value="${1:-}" + + if [[ ! "$value" =~ ^[A-Za-z_][A-Za-z0-9_-]*:[A-Za-z_][A-Za-z0-9_-]*$ ]]; then + echo "Invalid owner:group value: $value" >&2 + exit 64 + fi +} + +ensure_mode() { + local value="${1:-}" + + if [[ ! "$value" =~ ^[0-7]{3,4}$ && ! "$value" =~ ^[ugoa,+-=rwxX]+$ ]]; then + echo "Invalid chmod mode: $value" >&2 + exit 64 + fi +} + +command="${1:-}" + +case "$command" in + self-check) + exit 0 + ;; + mkdir) + [[ $# -eq 2 ]] || usage + require_abs_path "$2" + exec mkdir -p "$2" + ;; + chown) + [[ $# -eq 3 ]] || usage + ensure_owner_group "$2" + require_abs_path "$3" + exec chown -R "$2" "$3" + ;; + chmod) + [[ $# -eq 3 ]] || usage + ensure_mode "$2" + require_abs_path "$3" + exec chmod "$2" "$3" + ;; + write-file) + [[ $# -eq 3 ]] || usage + require_abs_path "$2" + tmpfile="$(mktemp)" + trap 'rm -f "$tmpfile"' EXIT + printf '%s' "$3" | base64 --decode > "$tmpfile" + install -m 0644 "$tmpfile" "$2" + rm -f "$tmpfile" + trap - EXIT + ;; + symlink) + [[ $# -eq 3 ]] || usage + require_abs_path "$2" + require_abs_path "$3" + exec ln -sfn "$2" "$3" + ;; + nginx-test) + exec nginx -t + ;; + reload-nginx) + exec systemctl reload nginx + ;; + certbot-webroot) + [[ $# -eq 4 ]] || usage + ensure_domain "$3" + require_abs_path "$4" + exec certbot certonly --webroot --non-interactive --agree-tos --no-eff-email -m "$2" -w "$4" -d "$3" -d "www.$3" + ;; + certbot-renew) + exec certbot renew --quiet --no-random-sleep-on-renew + ;; + run-cmd) + [[ $# -ge 2 ]] || usage + shift + exec bash -c "$*" + ;; + read-file) + [[ $# -eq 2 ]] || usage + require_abs_path "$2" + exec cat "$2" + ;; + *) + usage + ;; +esac diff --git a/deploy/bin/ladill-hosting-user b/deploy/bin/ladill-hosting-user new file mode 100755 index 0000000..bc3a45a --- /dev/null +++ b/deploy/bin/ladill-hosting-user @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: ladill-hosting-user " >&2 + exit 64 +} + +[[ $# -eq 2 ]] || usage + +username="$1" +payload_b64="$2" + +if [[ ! "$username" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then + echo "Invalid username: $username" >&2 + exit 64 +fi + +command="$(printf '%s' "$payload_b64" | base64 --decode)" + +exec runuser -u "$username" -- bash -lc "$command" diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..190c6ee --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,301 @@ +#!/usr/bin/env bash +# Fast on-host release deploy (same model as climpme/web/deploy/deploy.sh). +set -Eeuo pipefail + +APP_ROOT="${LADILL_APP_ROOT:-/var/www/ladill-meet}" +RELEASES_DIR="$APP_ROOT/releases" +SHARED_DIR="$APP_ROOT/shared" +CURRENT_LINK="$APP_ROOT/current" +RELEASE_ARCHIVE="${LADILL_RELEASE_ARCHIVE:-/tmp/ladill-release.tgz}" +KEEP_RELEASES="${LADILL_KEEP_RELEASES:-5}" + +STAMP="$(date +%Y%m%d%H%M%S)" +NEW_RELEASE="$RELEASES_DIR/$STAMP" + +log() { + printf '[%s] %s\n' "$(date '+%F %T')" "$*" +} + +bootstrap_shared_env() { + local candidate="" + + if [ -f "$SHARED_DIR/.env" ]; then + return 0 + fi + + for candidate in "$CURRENT_LINK/.env" "$APP_ROOT/.env"; do + if [ -f "$candidate" ]; then + cp -fL "$candidate" "$SHARED_DIR/.env" + log "Bootstrapped shared .env from $candidate" + return 0 + fi + done + + return 1 +} + +ensure_writable_paths() { + local paths=("$@") + + [ "${#paths[@]}" -gt 0 ] || return 0 + + chmod -R ug+rwX "${paths[@]}" 2>/dev/null || true + + if command -v find >/dev/null 2>&1; then + find "${paths[@]}" -type d -exec chmod ug+rwx {} + 2>/dev/null || true + find "${paths[@]}" -type d -exec chmod g+s {} + 2>/dev/null || true + fi +} + +normalize_shared_permissions() { + local owner="${LADILL_DEPLOY_USER:-deploy}" + local group="${LADILL_DEPLOY_GROUP:-www-data}" + local shared_paths=("$SHARED_DIR/storage" "$SHARED_DIR/bootstrap-cache") + + id -u "$owner" >/dev/null 2>&1 || return 0 + getent group "$group" >/dev/null 2>&1 || return 0 + + if chown -R "$owner:$group" "${shared_paths[@]}" 2>/dev/null; then + : + elif command -v sudo >/dev/null 2>&1; then + sudo -n chown -R "$owner:$group" "${shared_paths[@]}" 2>/dev/null || true + fi + + ensure_writable_paths "${shared_paths[@]}" +} + +run_artisan() { + local command="$1" + + if [ -f "$NEW_RELEASE/artisan" ]; then + (cd "$NEW_RELEASE" && php artisan ${command}) + fi +} + +uses_sqlite() { + [ -f "$SHARED_DIR/.env" ] && grep -Eq '^DB_CONNECTION=sqlite([[:space:]]*)$' "$SHARED_DIR/.env" +} + +sqlite_database_setting() { + [ -f "$SHARED_DIR/.env" ] || return 1 + + grep -E '^DB_DATABASE=' "$SHARED_DIR/.env" | tail -n 1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//" +} + +prepare_sqlite_database() { + local configured_path="" + local db_name="" + local shared_sqlite_path="" + local current_sqlite_path="" + local release_sqlite_path="" + + configured_path="$(sqlite_database_setting || true)" + [ -n "$configured_path" ] || configured_path="database/database.sqlite" + + if [[ "$configured_path" = /* ]]; then + shared_sqlite_path="$configured_path" + mkdir -p "$(dirname "$shared_sqlite_path")" + else + db_name="$(basename "$configured_path")" + shared_sqlite_path="$SHARED_DIR/database/$db_name" + current_sqlite_path="$CURRENT_LINK/$configured_path" + release_sqlite_path="$NEW_RELEASE/$configured_path" + + mkdir -p "$SHARED_DIR/database" "$(dirname "$release_sqlite_path")" + + if [ ! -f "$shared_sqlite_path" ]; then + if [ -f "$current_sqlite_path" ]; then + cp -fL "$current_sqlite_path" "$shared_sqlite_path" + log "Bootstrapped shared sqlite database from $current_sqlite_path" + elif [ -f "$APP_ROOT/$configured_path" ]; then + cp -fL "$APP_ROOT/$configured_path" "$shared_sqlite_path" + log "Bootstrapped shared sqlite database from $APP_ROOT/$configured_path" + else + touch "$shared_sqlite_path" + log "Created shared sqlite database at $shared_sqlite_path" + fi + fi + + rm -f "$release_sqlite_path" + ln -sfn "$shared_sqlite_path" "$release_sqlite_path" + ensure_writable_paths "$SHARED_DIR/database" + return 0 + fi + + if [ ! -f "$shared_sqlite_path" ]; then + touch "$shared_sqlite_path" + log "Created shared sqlite database at $shared_sqlite_path" + fi + + ensure_writable_paths "$(dirname "$shared_sqlite_path")" +} + +resolve_npm() { + if command -v npm >/dev/null 2>&1; then command -v npm; return 0; fi + local candidate + for candidate in "${NODE_ROOT:-/tmp/ladill-node-22-r1}/bin/npm" /tmp/ladill-node-*/bin/npm; do + [ -x "$candidate" ] && { echo "$candidate"; return 0; } + done + return 1 +} + +# Ensure compiled Vite assets exist in the release. CI builds them into the +# archive; this only (re)builds when the manifest is missing — e.g. a manual +# on-host deploy — so a release without assets is never promoted (which would +# make every @vite page 500). Runs before the current symlink is switched, so a +# failure here leaves the previous good release live. +build_frontend_assets() { + if [ -f "$NEW_RELEASE/public/build/manifest.json" ]; then + log "Frontend assets present — skipping vite build" + return 0 + fi + + [ -f "$NEW_RELEASE/package.json" ] || { log "No package.json — skipping vite build"; return 0; } + + local npm_bin + if ! npm_bin="$(resolve_npm)"; then + echo "ERROR: vite manifest missing and npm not found; cannot build frontend assets (pages would 500)." >&2 + return 1 + fi + + log "Building frontend assets with $npm_bin" + ( + cd "$NEW_RELEASE" + export PATH="$(dirname "$npm_bin"):$PATH" + if [ -f package-lock.json ]; then + npm ci --no-audit --no-fund + else + npm install --no-audit --no-fund + fi + npm run build + ) +} + +log "Preparing release $STAMP" +[ -f "$RELEASE_ARCHIVE" ] || { echo "Release archive not found: $RELEASE_ARCHIVE" >&2; exit 1; } + +mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$NEW_RELEASE" +tar -xzf "$RELEASE_ARCHIVE" -C "$NEW_RELEASE" +chmod -R u+rwX "$NEW_RELEASE" 2>/dev/null || true + +log "Linking shared paths" +mkdir -p "$SHARED_DIR/storage/"{app/public,app/private,framework/{cache/data,sessions,testing,views},logs} +mkdir -p "$SHARED_DIR/bootstrap-cache" +normalize_shared_permissions + +if bootstrap_shared_env; then + ln -sfn "$SHARED_DIR/.env" "$NEW_RELEASE/.env" +else + echo "Missing deployment environment file. Expected $SHARED_DIR/.env or an existing $CURRENT_LINK/.env / $APP_ROOT/.env to bootstrap from." >&2 + exit 1 +fi + +rm -rf "$NEW_RELEASE/storage" +ln -sfn "$SHARED_DIR/storage" "$NEW_RELEASE/storage" +mkdir -p "$NEW_RELEASE/bootstrap" +rm -rf "$NEW_RELEASE/bootstrap/cache" +ln -sfn "$SHARED_DIR/bootstrap-cache" "$NEW_RELEASE/bootstrap/cache" +normalize_shared_permissions + +if uses_sqlite; then + log "Preparing shared sqlite database" + prepare_sqlite_database + if id -u www-data >/dev/null 2>&1; then + chgrp www-data "$SHARED_DIR/database" 2>/dev/null || true + find "$SHARED_DIR/database" -type f -exec chgrp www-data {} + 2>/dev/null || true + find "$SHARED_DIR/database" -type d -exec chgrp www-data {} + 2>/dev/null || true + fi +fi + +log "Installing PHP dependencies" +if [ -f "$NEW_RELEASE/composer.json" ]; then + if [ "$(id -u)" -eq 0 ]; then + export COMPOSER_ALLOW_SUPERUSER=1 + fi + export COMPOSER_HOME="${COMPOSER_HOME:-/home/deploy/.composer}" + ( + cd "$NEW_RELEASE" + composer install \ + --no-dev \ + --prefer-dist \ + --no-interaction \ + --no-progress \ + --optimize-autoloader \ + --classmap-authoritative + ) +fi + +log "Ensuring frontend assets" +if ! build_frontend_assets; then + echo "Frontend asset build failed — keeping previous release live" >&2 + exit 1 +fi + +log "Running migrations" +if ! run_artisan "migrate --force"; then + log "Migration failed — clearing shared bootstrap cache" + run_artisan "optimize:clear" || true + exit 1 +fi + +switch_current_release() { + # Remove a root-owned symlink left from manual bootstrap so deploy can replace it. + if [ -e "$CURRENT_LINK" ] || [ -L "$CURRENT_LINK" ]; then + rm -f "$CURRENT_LINK" 2>/dev/null || true + fi + + if ln -sfnT "$NEW_RELEASE" "$CURRENT_LINK" 2>/dev/null; then + return 0 + fi + + if command -v sudo >/dev/null 2>&1 && sudo -n rm -f "$CURRENT_LINK" && sudo -n ln -sfnT "$NEW_RELEASE" "$CURRENT_LINK"; then + return 0 + fi + + echo "Unable to update current release symlink at $CURRENT_LINK (ensure $APP_ROOT is owned by the deploy user)" >&2 + return 1 +} + +log "Switching current release" +if ! switch_current_release; then + echo "Failed to point $CURRENT_LINK at $NEW_RELEASE" >&2 + exit 1 +fi + +LIVE_REV="$(cat "$CURRENT_LINK/REVISION" 2>/dev/null || echo unknown)" +log "Live release: $STAMP (revision $LIVE_REV)" + +log "Optimizing Laravel" +if [ -L "$CURRENT_LINK" ] && [ -f "$CURRENT_LINK/artisan" ]; then + ( + cd "$CURRENT_LINK" + php artisan storage:link || true + php artisan optimize:clear || true + php artisan config:cache || true + php artisan route:cache || true + php artisan view:cache || true + ) +fi + +log "Reloading services" +if command -v systemctl >/dev/null 2>&1; then + systemctl reload php8.4-fpm 2>/dev/null || sudo -n systemctl reload php8.4-fpm + systemctl reload nginx 2>/dev/null || sudo -n systemctl reload nginx +fi + +log "Restarting queues" +if [ -L "$CURRENT_LINK" ] && [ -f "$CURRENT_LINK/artisan" ]; then + (cd "$CURRENT_LINK" && php artisan queue:restart) || true +fi +if command -v supervisorctl >/dev/null 2>&1; then + supervisorctl restart ladill-care-worker:* 2>/dev/null || true +fi + +log "Cleaning old releases" +mapfile -t OLD_RELEASES < <(ls -1dt "$RELEASES_DIR"/* 2>/dev/null | tail -n "+$((KEEP_RELEASES + 1))" || true) +if [ "${#OLD_RELEASES[@]}" -gt 0 ]; then + chmod -R u+rwX "${OLD_RELEASES[@]}" 2>/dev/null || true + rm -rf "${OLD_RELEASES[@]}" 2>/dev/null || true +fi + +log "Deploy completed: $STAMP" diff --git a/deploy/sudoers.ladill-hosting.example b/deploy/sudoers.ladill-hosting.example new file mode 100644 index 0000000..4dc5ce2 --- /dev/null +++ b/deploy/sudoers.ladill-hosting.example @@ -0,0 +1,7 @@ +# Replace `www-data` with the actual PHP-FPM/web user that runs the app. +# Install as `/etc/sudoers.d/ladill-hosting` with mode `0440`. +# +# These helpers are root-owned fixed entry points installed by the deploy script. + +www-data ALL=(root) NOPASSWD: /usr/local/bin/ladill-hosting-admin * +www-data ALL=(root) NOPASSWD: /usr/local/bin/ladill-hosting-user * diff --git a/deployment/setup-service-subdomain-nginx.sh b/deployment/setup-service-subdomain-nginx.sh new file mode 100755 index 0000000..afde633 --- /dev/null +++ b/deployment/setup-service-subdomain-nginx.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# +# Provision an nginx vhost for one of Ladill's own service subdomains +# (auth./account./pay./qr./... .ladill.com), reusing the existing +# *.ladill.com wildcard TLS certificate. +# +# The monolith phase needs nothing here — the ladill.com vhost already +# serves *.ladill.com over the wildcard cert. Use this when a service gets +# its OWN app (separate Laravel app dir, or a backend on another host/port), +# so its subdomain stops falling through to the catch-all and routes to the +# real service instead. +# +# Idempotent + safe: writes to sites-available, validates with `nginx -t`, +# and only reloads on success (restores the previous config on failure). +# +# Usage: +# setup-service-subdomain-nginx.sh --app [--fpm ] +# setup-service-subdomain-nginx.sh --proxy +# +# Options: +# --app DIR Serve a Laravel app from DIR/public via PHP-FPM. +# --fpm SOCK PHP-FPM socket for --app (default: /run/php/php8.4-fpm-ladill.sock). +# --proxy URL Reverse-proxy the subdomain to URL (e.g. http://127.0.0.1:9001). +# --zone ZONE Apex zone (default: ladill.com). +# --cert NAME Let's Encrypt lineage under /etc/letsencrypt/live (default: ladill-wildcard). +# --dry-run Print the rendered vhost and exit without writing anything. +# +# Examples: +# setup-service-subdomain-nginx.sh auth --app /var/www/auth.ladill.com/current +# setup-service-subdomain-nginx.sh pay --proxy http://127.0.0.1:9002 +set -euo pipefail + +SUB="${1:-}"; shift || true +[ -n "$SUB" ] || { echo "ERROR: missing (e.g. 'auth')"; exit 2; } + +MODE=""; APP_DIR=""; PROXY_URL="" +FPM_SOCK="/run/php/php8.4-fpm-ladill.sock" +ZONE="ladill.com" +CERT="ladill-wildcard" +DRY_RUN=0 + +while [ $# -gt 0 ]; do + case "$1" in + --app) MODE="app"; APP_DIR="${2:?--app needs a dir}"; shift 2;; + --proxy) MODE="proxy"; PROXY_URL="${2:?--proxy needs a url}"; shift 2;; + --fpm) FPM_SOCK="${2:?}"; shift 2;; + --zone) ZONE="${2:?}"; shift 2;; + --cert) CERT="${2:?}"; shift 2;; + --dry-run) DRY_RUN=1; shift;; + *) echo "ERROR: unknown arg '$1'"; exit 2;; + esac +done + +[ -n "$MODE" ] || { echo "ERROR: choose a backend: --app or --proxy "; exit 2; } + +FQDN="${SUB}.${ZONE}" +CERT_DIR="/etc/letsencrypt/live/${CERT}" +WEBROOT="${APP_DIR:-/var/www/${ZONE}/current}/public" + +if [ "$MODE" = "app" ]; then + BODY=$(cat <> Backed up existing vhost to $BK" +fi + +printf '%s\n' "$VHOST" > "$AVAIL" +ln -sfn "$AVAIL" "$ENABLED" + +if nginx -t; then + systemctl reload nginx + echo ">> ${FQDN} vhost active (${MODE})." +else + echo "ERROR: nginx config test failed — rolling back." + if [ -n "${BK:-}" ] && [ -f "${BK:-}" ]; then + cp -a "$BK" "$AVAIL" + else + rm -f "$AVAIL" "$ENABLED" + fi + nginx -t && systemctl reload nginx || true + exit 1 +fi diff --git a/deployment/sudoers.ladill-care-deploy.example b/deployment/sudoers.ladill-care-deploy.example new file mode 100644 index 0000000..729caaf --- /dev/null +++ b/deployment/sudoers.ladill-care-deploy.example @@ -0,0 +1,3 @@ +# Allow the deploy user to switch ladill-care releases without a password. +# Required only if APP_ROOT was bootstrapped as root (e.g. manual first deploy). +deploy ALL=(ALL) NOPASSWD: /bin/ln -sfnT /var/www/ladill-care/releases/* /var/www/ladill-care/current, /bin/rm -f /var/www/ladill-care/current diff --git a/deployment/supervisor/ladill-care-worker.conf b/deployment/supervisor/ladill-care-worker.conf new file mode 100644 index 0000000..2b9d186 --- /dev/null +++ b/deployment/supervisor/ladill-care-worker.conf @@ -0,0 +1,14 @@ +[program:ladill-care-worker] +process_name=%(program_name)s_%(process_num)02d +command=php /var/www/ladill-care/current/artisan queue:work --sleep=3 --tries=3 --timeout=300 --max-time=3600 +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +user=deploy +numprocs=1 +redirect_stderr=true +stdout_logfile=/var/www/ladill-care/shared/storage/logs/worker.log +stdout_logfile_maxbytes=10MB +stdout_logfile_backups=5 +stopwaitsecs=3600 diff --git a/docs/openapi/meet.yaml b/docs/openapi/meet.yaml new file mode 100644 index 0000000..6e68571 --- /dev/null +++ b/docs/openapi/meet.yaml @@ -0,0 +1,79 @@ +openapi: 3.1.0 +info: + title: Ladill Meet API + version: 0.2.0 + description: REST API for Ladill Meet — video meetings, ecosystem integrations, and webhooks. +servers: + - url: https://meet.ladill.com/api/v1 + - url: https://meet.ladill.com/api/service/v1 +paths: + /rooms: + get: + summary: List meetings (user token) + security: + - sanctum: [] + post: + summary: Create a meeting (user token) + security: + - sanctum: [] + /rooms/{uuid}: + get: + summary: Get meeting details + security: + - sanctum: [] + /rooms/{uuid}/start: + post: + summary: Start a meeting session + security: + - sanctum: [] + /service/v1/rooms: + post: + summary: Create meeting from sibling Ladill app + description: Requires service API key (Bearer MEET_API_KEY_*). Include source.app and source.entity_id. + security: + - serviceKey: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [owner_ref, organization_id, host_user_ref, title, source] + properties: + owner_ref: { type: string } + organization_id: { type: integer } + host_user_ref: { type: string } + title: { type: string } + scheduled_at: { type: string, format: date-time } + source: + type: object + required: [app] + properties: + app: { type: string, example: care } + entity_type: { type: string, example: appointment } + entity_id: { type: string } + invite_emails: + type: array + items: { type: string, format: email } + responses: + '201': + description: Room created with join_url and embed_url + /service/v1/rooms/{uuid}: + get: + summary: Get meeting by UUID (service key) + security: + - serviceKey: [] + /service/v1/rooms/{uuid}/start: + post: + summary: Start session (service key) + security: + - serviceKey: [] +components: + securitySchemes: + sanctum: + type: http + scheme: bearer + serviceKey: + type: http + scheme: bearer + description: Per-app key from MEET_API_KEY_CARE, MEET_API_KEY_CRM, etc. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c5d1065 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2564 @@ +{ + "name": "ladill-frontdesk", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@alpinejs/collapse": "^3.15.12", + "@tailwindcss/forms": "^0.5.11", + "alpinejs": "^3.15.12", + "html5-qrcode": "^2.3.8", + "qr-code-styling": "^1.9.2", + "qrcode-generator": "^2.0.4" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + } + }, + "node_modules/@alpinejs/collapse": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/@alpinejs/collapse/-/collapse-3.15.12.tgz", + "integrity": "sha512-BKNANLtNXuWYOSAnajSKLPjTsmHRNrv0ALFTbpmqt2/klHFooPhctSwkhFVPQb7rZ8BjEKHmNaBwnSbgtpk6xg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alpinejs": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz", + "integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz", + "integrity": "sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^7.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qr-code-styling": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/qr-code-styling/-/qr-code-styling-1.9.2.tgz", + "integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==", + "license": "MIT", + "dependencies": { + "qrcode-generator": "^1.4.4" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/qr-code-styling/node_modules/qrcode-generator": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.5.2.tgz", + "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==", + "license": "MIT" + }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9b46fd4 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + }, + "dependencies": { + "@alpinejs/collapse": "^3.15.12", + "@tailwindcss/forms": "^0.5.11", + "alpinejs": "^3.15.12", + "livekit-client": "^2.15.4", + "html5-qrcode": "^2.3.8", + "qr-code-styling": "^1.9.2", + "qrcode-generator": "^2.0.4" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..973fa99 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,38 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # 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] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a7431d9cae7849faf3bcbefb2ae520d39944bc93 GIT binary patch literal 270398 zcmeHw34B|{wf@LiNm4=xTUx*@r7hiPOG_#3zXr;C|CYAULda%G-r_y`zRPwNXYmp{ ziEYVCoE;JffdXMkz)8HXNeEEj0V$Le3grQ12~fE2J6Dn|$-1&^OY&Wg@;i~Nxw?1e z%=yloIdkTW5GMSimq}b9zH`N3aS=X$$N7a2QO@t_Pay_7-}rysav-rTQ6$$Vo8oKZ zN5$4!zF|IQ{x05s8Cz@qJt!8mAhyn2i1)pqedb#8)41k2(DV5FJ&+V6opJr{SzLbr zv=6iklw+>5%*OjYpxZz{#WTKXskK~?*qAtEQq5%D@}3*U9Eb!5(wY;+oc1)4&}faa z95WBL)LBPb>Mh>_&VX^tOkghGTx%gLJ`QRIy$1R>C|GQV;=2FfdGCN;038F7j;H|T zqK>Jk>#eABH0n9bQfnTZ)D&kDQzOAWeg<cpitm9ocH(yb$ko-66grpP=>b5fG)Tde}BhZXTH$V0KL{=)?P<(jhO=> z;6QvwyhwU6$%HX-3^4yQkQLZi2YLcT*!cqVB`BOYjnuurLOaM__#E^m=rPPu*5cg^ z-S8u{eHbVNT;*FZ2h_;{OB2TTCUX>Iek8`fZ-bKXxg2y9MC0Vgph)3SEzkJ~ZKnCd zLEwKC=nj1T25g0q@eNi}T)kDDoyoOg4g|r0`CrWzzgl>!u+&?}U`~7^#=JDhH~GaZxQ;9O!zGeL(sE^Bl`A=*Ov;FZ{^bXdOA>(Mf`M$uH)B+Bq=s&?FIm%xtpO zS(>Upud3z8e>{|KIsEmv$z*D&s=ByG5nw-66>v|#D+MvivicqKpaS| zPZl>keX{`Ou_kB!E^t2!^V%c$?4HMK33qC&EBQSC4tgAOgGA7G%ty_`r_`~}z(6ol zT~p)RPCKml|=ThdPl@^hHoH=ugnO7hUhTUc@)Vt4=<+ zLi*zX`K0D_qzX%&Wd!D^H(;)c^=^3${zJU$0bg2b!@t0D*jZR7t+V_9Ho-Y;6X*|f zYG_f$nKW_F!xISiV_@6g0jh@Vy$#~Ae&F>Ut(E=*R0bdGZ>{y#bH~?A6iH2qY7iZ+ zgN8W}TWb}Qk6E!!ZygKyqBVN*<}9e}?zB#~UzNN|8;;-=bP z3i$9ZHXpNO;&0kl^C^h2st+Deywy?Ybqmc4pyy4b>-lAD4kW)kRZM(xGS=`c7Xte< zzkeE}g*`T`m-?ocJ^}t|kHtOK`na!6I5NrD05E9lk(Z9?9amAwC|2F#5^E9Z~~NWZM2*><;WEMg213H zL#&dEUerbmThyy2k|8l$+pdZ=4$GCRmy0`D|Vt-K<@*98s zyH!6FnVotAjXql#x|Ox$d|+~^>F}t z8nK<0(eU4-p}$@Mkzd!hFZplgNnd5)Wyn(|_7{$xP&Y};Xv|a}2n<*w^3x_BL_Ci( zZ+0c#*|*Oa?<>Z8`rRk+Csjk{zK@(_21J{>nhdNH?gybiZpS;t?6Z%LvELJh`&&+S zWqoX&<^RXG#SOAFvK|-+t$>()VQH`qhmVi;>%0c)Z@=>InTNfRfzz04Ccz#!hhqgI zs{zQ*2O0zHuLABVZlgE-#@8~xLL~$5L-umuce>E&e`4QYI5h$3S#xUw?bE#me5crb z-lNM{4i%RBdLKIPY&UFyYhyWPFl_KoIFTTdUz%#dnEzAo`za9n_Zjx53Yr0@cb!9VhxJs-ZdNRD0F5v=}a4l*Y+7lTGyqNPj(A@ z*ymds*&h%{8+eTSgnf?VXY6Yo`|kRa4k*Q3b}ah?x@&-`jqzmf3+$~L<+AztJWa;F z8#{V=r!xq$K*K=YL15r-C{GvWNF!nUuK;g24>x09FPQgj2hC^6CpeOIfK%a7tQ4*9 zBerK6c*5g8W8W7udi$Gvg3CbX7+nWgv4=XbK7sP}B!L%if%Mkb+#c>ze=17IQwCX@bpvH+b<+#y;ylgUJA$BcBSN*AVi38R=zhwu;QxQcbb-=HGz_ ze>GUY`x_7VJ7d}jufz7e{kErnPy5*op#p$)%GpEoD-fT-V@$bceb?3Rr(gsBjN)F5 z@N%v3UkU8jfLIQg2P^}%=0jNb{uW5rD!@`}6`(P|eFcbf^)dDt`%W1kEbN61aM2W< z>Hvy+vecW0pq&&0LGe)B_sj#9fi4;N0^@%!a$JqnTRy>rhB#63x5YH)p9~_uC-*z^ zfMuY&3{ZSfdO~C3U~9cT{DD*Jlf?L=_rUJQ`6;#L7a04j`&b47%E0U3&ux>B%4gVV z+8==Y=pq*Fz<$0f&?ffxb=UVi1~O0l$-q&}m#^0PULnefjP=ctnAg}@=LgjJ{$zl^ zGp=qRf8Zu7_QY6l&V?3V33Uk~tv+=S+Cj0sypP|wvdns{Z+-th#?3VI5u7=yvAp6G z(@blbpq~Kyr$8(N%mbEz-pjx%!2YkMXza{k(pR9dpnDnntov97`XvL0%=OkwL7JEb z#KGqme>|S-{&^r?=kLAF=W&vG<|zZ814pZ|PiVN>_X$}V%wqBpIkx{dsPk(e&vx)< z<_F6_-(=t|I&XOLGn0`gN4<8yl-gLz<1-Gpf0D7!x{qZbq%!af)&svzxw6#wvSQpv z{i2Y^XEFNyKiu~r?Ry?K`!;UsiEorYa53iAgRL#yitqge=rxd@_|9$N{#1($ zyn-?IW;5p5Wi)VK#n@-v$1)IB8TbqMdmcy?@F#!7gvag{@J~!cohYxF zT687XgZn~{GVmG3{#gkP2~qJ4@dK^{NDrYt(RgoW?6dA;83?-!P%P~_V1M{PDcaNmc$@AWvIahrm()6T{oGuHxR6EWV79_X0BJ6@b15}$|@sK+g+(`m*& zW1nRpEHXfAX^5e>%*9@yi!B&$)sQpXcJC~)u6n_knUAFIvmUWZ&;zW;gVW>8C+1U- zGGKWJ*w4ov+8?I1rkDoW-uHAiJhC896x)~Fxv6By$7`zRN*M>IN|vJ*)&nftLCQ9d z7Xj=gn&Ur+wZ7ZTM=isqs4O?|sy*{We$}FL%L{GKY}v9@A}TIfEZKI?lv1BemPYHS#QKTCinV?dBNBk_M#Ou@1G9a|7;-4`zs?oK)!)_4`xUS&&09p^i6h{ zZ`$VDYruRqZ2hZJ-pCZN_k)3*Dz0&C$`YW9;PXF3em;K~59T|X6RfS8FHL_eS+X9b zxPe%fX>H3i_g~NbM`zwtAs^;15*rc*TaXu9ORvi6HKHolCaMaur<4{i|1=n!2fBvz zM{(&ADSOv!DeZ|QmY<&Ghk2w;9?=~C39RVdh4J2Z>Brdb zs}pEWP+(sqE!{g^O2T>}_`_?4+LUwFPu~4TF}u%UEiVIkJIDQL?>!>vg``eAY3;+# zHLFDVM%xXJjVnIz#c-(p&MRLi%{h=J#n;5~oWNU_7+c!n#~bkN(K(*iAST|V)iK{w zN8PtNHwyT#3KiD<-G^cb>H9U?=1MajNx^y{$A^+V#H1M>e2Bi?j#Hx24 z-xJ$Cea70IC@Ne01FZSIXV2Y00!7cs2L0xQD1!u+#i9hKLO)D z^73fQw-*Ti+56^*Gq>@|8Jf3bd{#=h_Upm*e0;-tff8{qwgM#Pqy+e~Nlodn)z zA}8-9@f~vh%Rq3_Tp8B-L`6Z?l^FXQeX$v-ziF*t9o7ry9!Tf)LY@z(MIXEk>=(k$ z|FOAYiU~Hq=CIvUh4bx;#NL&;qAEZ85Agf-80#BOeLy;Z;s#bBmN*k}1I}1tjvIgs zss#^5sR!k^+za2-tr+*Qo~IvM-&Y@06lRI)yp_Wo8*RJ$6YmpBy+A%ftA86n@DHe=nCgbji~_sLG$7F`#cnw4gEcw zG01DT&XZ<8oCe>3#k1e|GtUkBD+B)k_BX@UzYhCz25DuFjuM`G^Zoj53k7ogjD+v+ zfl$W#g@)7C4Dj!bXFpKPZWAcUTxT8o$HNma$JeiaPe~sXI~EHh z`M{cg)_kn{^?9FPsPP@_{YVc`?x6+yGo?7rOU!yKI6d|%aGwp^@yf{>$itz8-B5gA zotGnatY38@;(cp_#hhBNBj3PG#1cF6le3SIZK7^-qxUezZ-;O1dd%~OfkFWxP2Xc@ z?kcf!!|HFt{(oPsxDK{n?sZG@4P-p-j2mDKbi;rVZ3K$h#T?&qM_gU}h@|>>*!ud2 z*YCITf$d*em_1v)e*fU|j(O?`niCXapYifNGvvI)tOE?I1O9{DTPM)}=@|1bnee1K zbL{pT!~M9yBg-~MZOzMh#65O;;z$pl^WYnpdmuxKmwf{~CoqUEq*#5NQD<3ask2^V zsW%S>^#djP{Anff`+=@To{wksfb~FapjhJRkJ3J4&P%Ls9bmy(I+h)1&5XcIHO)=XK1NW z)ojhr7TfZ2uCx~|Zy6{o4&-x553JidUz+m}Vu{H&P|Lmnjp@NJ;oJKsZ2f%H`}?r< z2lIMf@S0}(`jukmx>Yw~-T#w;#Nj|bo8|5jkY&<&#_$bj31f11t8-?DP%rK4Vp` z8GeEBz82Vzx7J%n!)7x$lKa+ttoMmMYjTGo-_Le6;7=`ekoOrQFY&?$GWk5?;NoZ_ zWq(kt{!6g$7Xkm@upZIQ8CrcqXn21`_}{U1^+?40pHvI>)KU+M9iqCdtDGf+Jzy7T@+^MZT z+u zy+V3mL&ZXA{{9Rpu7=Jr4J!tWIOm|;Td#p~upj@L^tKdJLSsU|Wu0%}&4E4Z){2@H z)h4X}Z_`4qpZ?-Dl)tAr0j(EOe)4Gt-RBvz9teRwg#6l;O3=^X;~N6q>MgYVSI9W9 zjbeV+ue`dfXn8{@v0E;_->CPX+*?l|$L61{&DL`)O}r;JWI$KA zQS}DI{jJZw4Zgp3jD*>6w3YNg0b+@lxXv@?IYH32(C5HC&eAbwSZXbo+_2{+k=B~3 zAhG;)C^@hdJ^9`MqG z6tlYvYxyy8?eRk_jcn@&!fH6mb`-1;CztGrvKQNy^hU1v+Te2#xz`J`cEdM-{N#{% zqthqw>m7k#@6X`Hxjf#71KGjdlk$AS_CL>FwBjIRI(SU?g;&lTWQ*l}#!1HAXZ$?y zJ`32t#C$9kwmy&f!67v~S5@a_BiC=vIPCd*r7tqgzv-LK*ig9uzJYW(KY47OA@&)c zLHtf3Z2jvd*IJ@5{)b1P`L4aak*#qKq$FNGbzzJFJoe}zoM#Mt zz(=SHdw}-m)nmN30{df)a%N`O0UP266qYU$+w)fb1aW^KFgCrh8A*P~aRbOtPO-#^ z@C|5K2T<;<7eI?J$G>7C;&s{94>4#%c)R60r-_P*9*0^R`4!xzd5$fdVTECI1@gHP*V9;Vc~%DoWHe@J;UexQHxHZIRWi6&XLbE zo{IeBs-F{lggQP3TmQGP^GC9+9~PvB<))3(90F&2@1Su84GYb`#F~JRtj{Bk2ETjCWzYCti?l_B_uR zcvD0C(Ces2HpcvG#@A-@I)0e&8SdNG%qlS<_t!zjfADzh39sQOm-`2Ef?W6pW{_{- zD8&*F$U4D0!2LGl;rI#sdPCW_7jA@x_x?}KFE&->XFtjKS2zAWbqeJhpjhJcC#JgA z3nO9=P!67h!26xB^+$lhE0}!uh;ZP^`31m#)>AzH*AD(k7m&U##6II?_u)L_!|~yq z570XPDd0W>@q3q;pPD2X`w@X=L>^MQWvL0i|EE017yhgbT|hdU)(puvF#o{}`OG3< zHI%yGH14;S&dW8|#v^8zWA!5f$_PFLST}(f|2pt}y3aEXfp6e_;C?s8{F@NLHyji} ziR4E`iUSX=%o7JzZ8#sXfAu`(Yh%n0)K4@gC`Ml5>|L|_nu84ca?Q^G_v0<~meH1j zON527A1OFS{5jk5a|Gym;QysSa>ZrZ(hW2(pxi@?5jQ}&hk}?BU>)B=bNo4&+h0cO z`25m82axZda{m7eE0_15pk*2ZCf zUYz~~pIaz%fbp+G%=`E;-0M2z8^}ZsGIQ5?#*};OKE&_b8rKj%)LNGytUT@;N;5G2 z^^bqIoj`hk@)Fy2%lnK^Os+Ri9^X81%pJ!k7)s{3v0WTs{2K@Vqz91WMS5u2dZ}ji zrnKFmliLV_U7bDM`mu4T2ky%tCRlOn&Pmx zq&j|le0J5K*7S05a_&|`$-mJX%lOwf{^=~oooiP~hvsdRn$yZ8jQMWtyWjrI!cWBhA(4nSv%(RuAO$A4l$q11#qJ}?_9`V#kgwl&p0w=KQ=!jq|XQwQ4u zy3-a9E!|*3?r+}zt1%mZ_Wf1nXGxDN$&(s0N<)QtfA{$Wdf-S~ihVqMgy){H7K!Hc z3foph{kb9{do59Yg%pa*ugq&Q-Zr*9kFk;ZFZ z=2HHzEad;x-Z-c=-+W)|OY3>t*R7NuUr^v$#}C;Y-yg<<`HtcSPQf>j(VFTQ_uTkg z(UQh`K#NMGaoQH-|F&`dZv5tR#)>`SvL} zVeEVAg^!^F9tHgtG!kS?fT_II*e4=(tXp|md7bIh(M_SE$G3+MsbXKX>d!01-Q5%YTCz-k`*W}Dbnyk>C8mSs7?$}nHodp&{q zm2{x*`*PKU;ZW4&1L%MQumxh^8ywF5!GY5O_VQH%V?WVk@hjI947b(1`Y@Yelqex3eY{!0bd4^WiAU&H@MgH z;8&HNoKO35!@56jpfTLv=aX;XBxp9`2F9Jj{z7~zz^t;1r1k7_&n00Lv& zR|jq?St9StrI_7kT>Ekv_aV`Hufqmd)s|}iR?Ad-6nJV-bVjDx$N?a#^0SL6pOmkR z^WXk;f_q=CoO>&{JiBTbU$s;R%0YGldkWH^3&!!BAbh$2erf^A1lB)gy!*$yCtoU% zd+RUAy+!+SbrP?uX1(CtXPjcc13KUw)&rr}1Er1?qSU_PYQzEj%~Q_#v$rmwIsX0G zUHfv`)(?rTAFS?q5B#oxkMM>T_6>wo2~^-L@U8hdBOOJyXBqR}n5XqTtc^%})~uEe z&nc2~@G$NpihE!EfLLOC7Gj96JPyAA`XZ!QFyhT94jA+QVP(ZD_A&mu@K1TsC}#KZ zg@vwtx!umt@|8{gJ2LAYv|iYb^??M?`MgfhKdn`4Un=ffkt<3zFP{S9I3Vl=uB3gr z%hx;S_>qxm)*Zd)Gx-J{#95}lYnxUvtT~y_Gw!PHeeT9M{e+T;?_|Lfp>i{Q>h=0nx zwJR6~)Rpv#||vEOteW2Ygp+?Pi0*sxkWv2@EI_y7wH z6$gYl{#JQkZobrrb$pGCcU(_pT}0;@AI6-((w0#^`b^mYadIx7R}zd)aP0+}g|W~O z`G9F(?t|EuTR%;e`L!A2$`}uZH}69aJOEqZ*0v1Cu!aQo4fLb~%8OSB&`*&M^ndg` z2f#Xqyf3$ARv_Roxj)yOK^$>D)(pS*{JQ<7j#+FADCvO*8rF#I_M9mA z01oL9|1_`Jv6lAbZj{fu6$lD&GC~BBln&d;Lj;`iW{f_O~RQ)Uu#OsramSw9mL2dyH@F$gCLLhB-k8+XJ#XpjhClg6wgaBR{8Q{9_Iy zpO@?0ms`gBbQt%(x}WwJzK%HJb$GwJDWh^wGjfs|{Z(DCQaqJi8ijShWm;SZbneT| zk>q{3X&k=;+4hxt{@a@;qzCBCqRh6m$_tuPN{p@ouonO}Kz`OAkrVI(E!Y4Qw@3A? zq`3`KacUa)Eb4>VoYXSBss6x##0-(8tt{f5JY^ z@gK-qCmoxA}Isp8#n`^ea7E|e_$v`*Lq-ULAKbIpYt88rJouo8=$0^ z=J>0m!*h$I7M!8QxM#f=p)p@cw>Zx;PO;NI<4d2Nv_a_AM_5{tB~o8sPiF(HCtq8n zbbxz3kM`vrT;!VL^H`@OkNo|>$Oi2*eg-tTJ+xKq7{cjjeqxfxs5n4 zm*rlwa?gF|ZTEZuSw0LMFcD{&js|IZPOz^qPvqs#rG0_PI1A&8{^|f4^XV)d%Dr`L z2A`M9dQZ1{kMp{zRtZbm-FmG z9vC0S;sN;vXrJ*^(Aaj^0$STsKxc&PSikz~@B=*G8~oG0T*|#g=jB2NvfLX>?zz9c z``f+G`1jBQqd=OO6YRtp0WAxvOz;EDK|D~8`vUElt zM|fGw)Kb;^2KMIWiH(~VB0o@025bPYcmO%~7WU=Jxwqtfxoqn*_F4A@Ap;ak+=jVv z9M%jk0I7OTu(xo%xVK>K80-tEbK3wF1zGaG-1^L9#LJ86N^~=v-@(K=jCz^9vizJv6WAA>Nl8l0G-;n8)q*Z|_muz{{8uECl{9`>64xI6kLZHTJ-niTlNoX&Xgb zvg2md^EKA{tOHmE!Xg8og0D|wE-)EB!cmWwwTOmUJ5-?uD1MW2@SE`*&dLc981MA!wOiEn22+ zrM1ers2}?RjVq&S$BoBK|EGLY(M zz#RK3HSZGw=H(p7umOIJ&xl=M9l$(b83=(4ybDbIxxunQw56$Y?05SDI%aM~oYJ;I zI4fx;+D+%DsZW=3ow!f5BLj36=@zX0kJ3usAgU*Pl;`37UdBJ`KbC=h$-vRp)beXU z?jct7?-UnEIso(|^!*9$|9E#rYVog`&mu0!9*g=@ z9@J3thVREb)w2wIggO7B){M#_T0K{+9kGF!2Mh=Hi$FAw(Q|)s+qi#2B?E+&Dq!aV zknhl{nZG+`RtZ`YY)^MwhW0$p_-FmcG7yvuv|+#BwQA1)5v&qAF}++g#=#$$>bMDg ze~SA*sQwStIKubQo=x%==FE4M4@o22Iarvf`nrzH3UO*K&O}Ugj0gVTVjaLbfMvk1 z415Bsy?Im-U^KQ=j&zM z_tg>9E)eY#B!3|95iqW-hYB-#xew*~+7J8q8z5iU=<08@llCrM3VH;jm;U2+a9{RE z1{zz^96xNZZW0}{20o`pI2DdFHn5!XgxH?a<29cXYFcnN0!#el*IJ5}AAd;!f^ z7lbaL*rpc@-v9j>H~6>OwmZ(i*Kg^VUJ<3&Ilq$PIkjxJc)8#q6a0a!IHF-v_XP=e8%84O8=;UM2j7;`>&_2ewKQ#R%U68-UjZdoM$bJ8kmpePDkv=p0Ze zprqM*bYwWh@l3~1%zqN01OCZ#0rm&zTn5f!yjzd4Zv=AXXco7jtoZS14smiOop)wW zL*IX>^Zw^CTbpBcDEjR)%yqW__ZKksy)}p&8<6f83EjAybpYD{IvfMe;(W}K){OEo zEosK_^?Rc{@L#U@pmU)EZ0P@g>re(lF^2Fxv}ZrixL*RB|AMBZGV#L7eSv{s;L_$4 zhX9|>0roRM|6m=!wt&WE;4HAe88Ur7$iT=BzUk}}WXx+E^KKiB@?Vy=W;n)DybHgA z=RilML!6i)`vjA~!?$%J4{o{OckS8mz3sCPv9_1TzP{iw?|UWff$YdA9}0f}oh|qx z>j1V3G$I3(?`|pLTn)dz?~Cc){{8Cq!$LxgkSk8`CGeH@O7&J3^YzTLNXY=%sxvY6 zk1~2}Pj9iVbUoHgMC+`|DA-cJ0&kxH(O&6D^(Q}vc^#|_yn#Gd=Jr&_kai8`>{Y^_ zTD~WJ3i>9nzYFvk>j2h)Dw2VA#IN1amR>%@XnT1gr617Oq9c>e7O`K9&$%Fu738rw z*s(cW*L{xly+g1~uX|~6mFd)Mb)U}_DXfO`IqlObMO$XYaPWN^bO4=^5U#%E`!a6> z$pGc++X9>Pa>~_9=icxuG8~ZS0%_$#!2dskPOuJO9T;I5co+8m9N7A!+1`%~=6#<+ zc|=;%D=3Emky5%ACR0sZ1`pmpl!kgo?}d)|0r!H%fobGJoM()m$=I6!BOxnhNGM<3OKsILZE zUikU?>4P_c`(U)qC%F*?`zZM)Ykqq%m+Z<@ltP)Mrt4+ZE<v|p1cO~@%xsv^5N|Xn+3@Yzx2a_j6i&;b`?ZkLI_p9S$=B4g@-_aOs2APYZ-3{Wg-H+kVN z^vMBQD+EPhJ@*Rqe>VF5MbMX^aE>#4Z*}mO_KiJ;e!mO-KjM|lJsi8QPnh@ED$Ml+ zXc%PSr=Wbu#TgK<7peoNq1KmV=hcJa(dQQ(Pb%qAKKKItbD(M3R?%)P63wIsQtUT@ zwt_h4pnh}!#qiL2-gM9oP&RajNA))7ZmDoi0>B}p3++H zv*=62?K-}FV*U7Z-S zkAtZ{`NbUQ4F@1Q@;(o|4{b>;|C!4_a1wvB9#E$qaQpQr?t2`FVm^A)<$Nu3z%K_p z{Dj}fJ`mbxO#2Gxe24yyRs8$@@sM;eou|DA{c<<@>Eaj6h2+!q+mHMubD%#QaQO!Y zx22U|3b~tv_2UQd{wj#q4*Q4W-s(X*AF>(z&qb{6ji6EO8Jv5sKeElg?Hvc`oH7v3 zc(7Ca$Wl-ph;qX9R+jnt;CM#svTuO4g3N8{j?1Kb;4vPgb{!LSW3 zgP)n!4XQvCPsI5Lg2Qzn{YUowTcBg0g^1VrG2YLm+;9A14ycI(bml_`2)<^@0Ur%H zB|lUdr~$;e34_-KG|ry}JqWVFzWQ~0n&WFt$@VDDv8yJD=eh*qz=`x7;zas(vJ0-p z{*t>O%lY`+fp_v#Q%p*ranDF)g!fNDZ(~0H7x?j}T4<9tZskL>Mp<=4tKZRnSpTDfp29AKtf-%lTa7bjDe4 zY~QP2s=0=JT;Rsk9cyWm@}Z|}rAttnEx;R_)0A=128FFJwNM#$dzC+KO=Mo z>q6k=7tjS(yk~*RK=*@=fGB?f?O7yyA(Hcl_whW!coXO`&`wYu@I4FlydC~evTM$P zJwKSvXlea1%ki1@J zsbZtOzViIzD`&lE|z zc|G0_3u`>9htIzE_bK-6BcI22tAGddopOsZwXr}}zu&0wj3he1*kOF%;8KhY5V$f-&_PNSyl*(NU%u+0O zGElMD#lRlQ71`&KFh%yc7}!&}B70p7R%D-x!9A6qR&Jln-~i<^gKLz3FPCR2m&^AL zP%i60<)1rMP=N9n2ylSzk*gP=T&`Y#?vWKhfbwWOUJB5CF@ejS;;r0$&JjYn+^L{X zEAmHvyrf)iqv}aDO22njPr2Ns9ub=#}>HUgFBKx_aoy^6c5Cok?u-OIhW#p9*#>?@2yfMy>lP-G1*=!K`irU@}%Dp(`r`)Roe#*VN&`-Hn1#3k2SF}e3{gr!G&|kS{1^tzKcA?+$ zn63((7JgnH74%!~(S?4?JvijI+@pg2%eyM*fBf#^kpJ>74*4(ds(?bd&CMYN^SYBm z3gw*~QYi1_P*3)iQn{N$O66`2DV4h`sAPY-E2vc7Dda-o<>rt=ai@DnsodR#SxOhU zdPuR{=Da|$+*QFErTe=$q*(6a&}pULyJSqcJVvgdf&k*K&Ms6gcXCKU!0>x#1(nO4 zT^OKT=8%#A;Q=y-6a~cP9tcqG>GJm_9Ae{`6`^Vo9>$;++a+^f2n$G+Aevpf$ z)4pH3|LzHau5v{NyHt!KgJt$gic?^>3R7fYw6p$-4D6{q#`XIecY}T2Z9SB`ij^4X zVvo`sxNC(#fk7_zC^E=B1S$C_qTTgVU{FkFd6rMZoqyUo%TN0)?=1GufX?PC_|V(~ z5N_iaLY>W5@Bv15J>PEwV?4^U{4~JkQSPsS9>xB=7#`L4+W?R1`)xq9=l%UOAjYe_ zrdtDSUgg~y;K6;mu$zc^RnJcaJB7>j7~ki|xW0G4`Bu>Vz5CrqN%&N+8wY)=hr4)J zAliN3&Ufz$*gC&=fAFe+SLZpe@RC8_*3%_f9yPEzf8+K4>8|o_t)mM)9?-pYbV1hx nZ1iWhS7!_S-X52`D-hG|r_L{2H&FVy`v%bpKX*03i#PuVof8-c literal 0 HcmV?d00001 diff --git a/public/images/ladill-icons/bird.svg b/public/images/ladill-icons/bird.svg new file mode 100644 index 0000000..ad8a1a1 --- /dev/null +++ b/public/images/ladill-icons/bird.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/ladill-icons/domains.svg b/public/images/ladill-icons/domains.svg new file mode 100644 index 0000000..ba4956e --- /dev/null +++ b/public/images/ladill-icons/domains.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/ladill-icons/events.svg b/public/images/ladill-icons/events.svg new file mode 100644 index 0000000..6465988 --- /dev/null +++ b/public/images/ladill-icons/events.svg @@ -0,0 +1,2 @@ + + diff --git a/public/images/ladill-icons/pro.svg b/public/images/ladill-icons/pro.svg new file mode 100644 index 0000000..8ab5fc4 --- /dev/null +++ b/public/images/ladill-icons/pro.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/ladill-icons/qrplus.svg b/public/images/ladill-icons/qrplus.svg new file mode 100644 index 0000000..023c58d --- /dev/null +++ b/public/images/ladill-icons/qrplus.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/ladill-icons/server.svg b/public/images/ladill-icons/server.svg new file mode 100644 index 0000000..5fbb2ac --- /dev/null +++ b/public/images/ladill-icons/server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/ladill-icons/wordpress.svg b/public/images/ladill-icons/wordpress.svg new file mode 100644 index 0000000..0be90e4 --- /dev/null +++ b/public/images/ladill-icons/wordpress.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/launcher-icons/accounting.svg b/public/images/launcher-icons/accounting.svg new file mode 100644 index 0000000..68b5263 --- /dev/null +++ b/public/images/launcher-icons/accounting.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/bird.svg b/public/images/launcher-icons/bird.svg new file mode 100644 index 0000000..ba4956e --- /dev/null +++ b/public/images/launcher-icons/bird.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/care.svg b/public/images/launcher-icons/care.svg new file mode 100644 index 0000000..080dbb9 --- /dev/null +++ b/public/images/launcher-icons/care.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/crm.svg b/public/images/launcher-icons/crm.svg new file mode 100644 index 0000000..5a15828 --- /dev/null +++ b/public/images/launcher-icons/crm.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/domains.svg b/public/images/launcher-icons/domains.svg new file mode 100644 index 0000000..ad8a1a1 --- /dev/null +++ b/public/images/launcher-icons/domains.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/email.svg b/public/images/launcher-icons/email.svg new file mode 100644 index 0000000..72877c8 --- /dev/null +++ b/public/images/launcher-icons/email.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/events.svg b/public/images/launcher-icons/events.svg new file mode 100644 index 0000000..feb13f8 --- /dev/null +++ b/public/images/launcher-icons/events.svg @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/frontdesk.svg b/public/images/launcher-icons/frontdesk.svg new file mode 100644 index 0000000..ea54262 --- /dev/null +++ b/public/images/launcher-icons/frontdesk.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/give.svg b/public/images/launcher-icons/give.svg new file mode 100644 index 0000000..8985473 --- /dev/null +++ b/public/images/launcher-icons/give.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/hosting.svg b/public/images/launcher-icons/hosting.svg new file mode 100644 index 0000000..bd7b96f --- /dev/null +++ b/public/images/launcher-icons/hosting.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/invoice.svg b/public/images/launcher-icons/invoice.svg new file mode 100644 index 0000000..8c0e070 --- /dev/null +++ b/public/images/launcher-icons/invoice.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/link.svg b/public/images/launcher-icons/link.svg new file mode 100644 index 0000000..4122dc4 --- /dev/null +++ b/public/images/launcher-icons/link.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/mail.svg b/public/images/launcher-icons/mail.svg new file mode 100644 index 0000000..17ee6ca --- /dev/null +++ b/public/images/launcher-icons/mail.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/meet.svg b/public/images/launcher-icons/meet.svg new file mode 100644 index 0000000..25b226d --- /dev/null +++ b/public/images/launcher-icons/meet.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/merchant.svg b/public/images/launcher-icons/merchant.svg new file mode 100644 index 0000000..5029df3 --- /dev/null +++ b/public/images/launcher-icons/merchant.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/mini.svg b/public/images/launcher-icons/mini.svg new file mode 100644 index 0000000..62957e6 --- /dev/null +++ b/public/images/launcher-icons/mini.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/pos.svg b/public/images/launcher-icons/pos.svg new file mode 100644 index 0000000..3bdb6c6 --- /dev/null +++ b/public/images/launcher-icons/pos.svg @@ -0,0 +1,30 @@ + + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/qrplus.svg b/public/images/launcher-icons/qrplus.svg new file mode 100644 index 0000000..023c58d --- /dev/null +++ b/public/images/launcher-icons/qrplus.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/servers.svg b/public/images/launcher-icons/servers.svg new file mode 100644 index 0000000..169dc36 --- /dev/null +++ b/public/images/launcher-icons/servers.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/sms.svg b/public/images/launcher-icons/sms.svg new file mode 100644 index 0000000..bc628c1 --- /dev/null +++ b/public/images/launcher-icons/sms.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/transfer.svg b/public/images/launcher-icons/transfer.svg new file mode 100644 index 0000000..1a18372 --- /dev/null +++ b/public/images/launcher-icons/transfer.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillcare-logo.svg b/public/images/logo/ladillcare-logo.svg new file mode 100644 index 0000000..eac1c60 --- /dev/null +++ b/public/images/logo/ladillcare-logo.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillcrm-logo.svg b/public/images/logo/ladillcrm-logo.svg new file mode 100644 index 0000000..1fc582f --- /dev/null +++ b/public/images/logo/ladillcrm-logo.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladilldomains-logo-white.svg b/public/images/logo/ladilldomains-logo-white.svg new file mode 100644 index 0000000..0ba849c --- /dev/null +++ b/public/images/logo/ladilldomains-logo-white.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladilldomains-logo.svg b/public/images/logo/ladilldomains-logo.svg new file mode 100644 index 0000000..72e4745 --- /dev/null +++ b/public/images/logo/ladilldomains-logo.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillemail-logo.svg b/public/images/logo/ladillemail-logo.svg new file mode 100644 index 0000000..f5d4f15 --- /dev/null +++ b/public/images/logo/ladillemail-logo.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillevents-logo.svg b/public/images/logo/ladillevents-logo.svg new file mode 100644 index 0000000..e23909f --- /dev/null +++ b/public/images/logo/ladillevents-logo.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillfrontdesk-logo.svg b/public/images/logo/ladillfrontdesk-logo.svg new file mode 100644 index 0000000..0008bdf --- /dev/null +++ b/public/images/logo/ladillfrontdesk-logo.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillhosting-logo.svg b/public/images/logo/ladillhosting-logo.svg new file mode 100644 index 0000000..6a66d01 --- /dev/null +++ b/public/images/logo/ladillhosting-logo.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillinvoice-logo.svg b/public/images/logo/ladillinvoice-logo.svg new file mode 100644 index 0000000..991b473 --- /dev/null +++ b/public/images/logo/ladillinvoice-logo.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillmeet-logo.svg b/public/images/logo/ladillmeet-logo.svg new file mode 100644 index 0000000..012387a --- /dev/null +++ b/public/images/logo/ladillmeet-logo.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillmini-logo-email.png b/public/images/logo/ladillmini-logo-email.png new file mode 100644 index 0000000000000000000000000000000000000000..c22d791eb39c354ccdf920ad021f2b341bcf5b72 GIT binary patch literal 3302 zcmVou@P)_Z1)Eers31uNKn2NGK(+!x1!OD0SHZc1oCn758Dm+0EZMUC{oeEAF6%h5EzPHq zG$SDZ0001&oKB}%cC54GB|H8*6)2tea{3DW|28}3*%1YNe^GWUxvX!cdsw3e001BY zr_=LEfkF_{Tn2N~?C2Qipuf9M_~R0#k81VPekWNC@Vr1^@sc@ep{d{y9)FD2hVdKn#`;0{{R}2m*TsQ4h-hY!l2z z@e|pf2fTg&0H_fJUNJN6N%?b6#zYM0JD4*70Dw9{;CX{p&+cJrxa@N)!X63(;7bRF z5;+-$^Wg?^~T|QqG3YXD+@EO(#tki!?0Lw8}EavGy5R8;ZEWxr+0b*;ljmDm_Va-m z%emxbM}{vOfUYp9r$hF5*<^g=#VPmb;V~GE&_ewU~q0ag6FaIyxewc<7u6pojqoM z?hBp*3BgStCi=OIH-)-(%#K@{$fs3C(}7i>svgBu0MHMPZ0J=+kd`?fNANr%Hrmct z?{NAW>(K)ig~vWVRIWQW<+Av|06?&!@R3qrgtt!KQB}ln zKFqVQy)Xxk&1-&l^d15P0kleCNG~pYoKL1EA+6NlVTy^UUaW8&@X$?{LU_!cyR4^9 z83|r(@&+m94Ib%Tj(yJY0@V++WK{FKHB*iANe~X%@u#DlHZvZ!P%g=)o%_V+lEXh@ z_`^AEiUJLpT}yiNG@o#==s3tyf>38<6G&T`aU1!;9oxLUBz#a=U#WfgsA~`e;M)^e z3G*oNhz$Yt2C33NaStx_T;4gC*VMzzv*MmMmdzJT*~shmx|c46mEP~^J1>%RzfmgNyQof< zr(M@fgZE@*f*=4NF7LZ=pDyI1SD0PjY2+#!?ib-ccyT4{QX+t_qHq-Qn2V)q%%Ag^ z$yN>RI^KI>E8Of|Pj&6Z#j;!o0&rQu*N!T99doYy6GsiwXWDnAUUKR}6flPXT=sV6E&W_x`K{Naw_IM(oCUx4sLFE`6=@h0`@`RM zyZ}HIc};>v-qBzF{~wb3FF%XCU0BU8!9xHp#9-Nhah=+F3J`-{dI@#&3Exs>7Zx;B z5%PrmE<0#9yi3}BjkfOOgZ}$cd`_hA6r%8K>p?>hKvOZGhSyb27~_029f=okBMJv0 zUmm*3hf5SGt@k_x6&mCbP|to*Csy=tbPfxH?7YafO=8FhG!X;aT=G&Rk| z8axLI-lJhM*@6ga7r$9VAqb!qOqa@$tE*Z!eQyyDkOwNM8k9+nnuN<*=brB^%L|%8 zsj%NG>Ru%IJZBML;GnS>6ht9ah{8&k?XQ6$BVg?vP~+20Nt_nk;Y6dN?|8gaH+ocA z79QG)4TS4TcS$0jryAP@?!1Il$oEH(iNVISx7-qqM5^#Cs;&{6t~!Ds09)n;J)ldQ z#z?~h1}^@c;@@?5&D*hXrKSy&$95qK?T7-^C*8S#1J|X{*tnS7=1$h8VZDbSfJW$4 zv^;lzlO7NmSe^Cavi-F4xegrx+p|$`(m3h+NO;$dDBKJA#Ojwi7+~>J;c&M#o+z(s zXGQ^{5CmWhyP=WBsrN4OtZ-%5@i2Z$KA&keP*)Rr@yEwt$7Lg98kW$ zK@xa*lMt^7K>$8TJvE|(1F^Cd8WGTSL3RD0=Y553O(6&XR3e#EXhpy@ubbd4h28Up zAOH}iaW@_g(liiLPes#=dAhDI!~IXst_uJJ0R)3;C?wJxs%c9QRK4)n z({qX)lnnp~0tgzN$KW17~VhX&q3zCJzCIQ6-yS7W$~9M;Q&@d*OPNe9VJ^So^nQQ+?`9h6rm zIUX7F7uyaDfA2-;MN99{EuquIIZbrlUHyj?D7)TK?%@whI2lKeQ2L%@tGETuMmPe!#WM` z0E|`;%00_}2lyPv+PEpj^Dm(vh!(MkJ+QG&3iaU!_s%0mVaf`_iUd zkBgJQ*IZsx(MpBh4sZYrQV?Dh0?~T{-h{*VeWx_EB0k5>P3g)@Tv!OtBo_wgin4;8skn3W7z0` z)goWs>st67j&Z%s<;R999!32_bRY%*f{^vR6zjO32JrlTqY8a9vh>a`h1*T$Q}#X< z9?DBG7PpSQg@nC=tBUvy0D{HmDkIhK0T~P*Yt7pt@!az}gyadC8rkg6J;xl(8@%U) zMR&(>50OT_vU+Yj*?Y-x1+otEImyCZNpYT^; z_|WDVSJi{0%jd}NeJbfN6wjm1NmTY^t31N0q=~-jwj@F0u{@V6Ez`*lvB3?zd-(lz zQFJ_9{~66005CLs%pp7)s^4wTp4rE$5mX%j00f~Pa!rhal-R}UQz$9 zQXnAh0001jpur{w>Uo5M(D ky4IXS)&T%uwEQo?0Gr(_e5Em=M*si-07*qoM6N<$g38eo9smFU literal 0 HcmV?d00001 diff --git a/public/images/logo/ladillmini-logo.svg b/public/images/logo/ladillmini-logo.svg new file mode 100644 index 0000000..76652b5 --- /dev/null +++ b/public/images/logo/ladillmini-logo.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillqrplus-logo-white.svg b/public/images/logo/ladillqrplus-logo-white.svg new file mode 100644 index 0000000..3ea536b --- /dev/null +++ b/public/images/logo/ladillqrplus-logo-white.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillqrplus-logo.svg b/public/images/logo/ladillqrplus-logo.svg new file mode 100644 index 0000000..d9b81fd --- /dev/null +++ b/public/images/logo/ladillqrplus-logo.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/qr-icons/app.svg b/public/images/qr-icons/app.svg new file mode 100644 index 0000000..cf2c12f --- /dev/null +++ b/public/images/qr-icons/app.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/qr-icons/business-profile.svg b/public/images/qr-icons/business-profile.svg new file mode 100644 index 0000000..77284d8 --- /dev/null +++ b/public/images/qr-icons/business-profile.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/qr-icons/facebook.svg b/public/images/qr-icons/facebook.svg new file mode 100644 index 0000000..94a3b7b --- /dev/null +++ b/public/images/qr-icons/facebook.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/qr-icons/instagram.svg b/public/images/qr-icons/instagram.svg new file mode 100644 index 0000000..658eb6e --- /dev/null +++ b/public/images/qr-icons/instagram.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/qr-icons/linkedin.svg b/public/images/qr-icons/linkedin.svg new file mode 100644 index 0000000..875694f --- /dev/null +++ b/public/images/qr-icons/linkedin.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/qr-icons/list.svg b/public/images/qr-icons/list.svg new file mode 100644 index 0000000..ccf7f95 --- /dev/null +++ b/public/images/qr-icons/list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/qr-icons/terms.svg b/public/images/qr-icons/terms.svg new file mode 100644 index 0000000..6b352e0 --- /dev/null +++ b/public/images/qr-icons/terms.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/qr-icons/tik-tok.svg b/public/images/qr-icons/tik-tok.svg new file mode 100644 index 0000000..7e6913c --- /dev/null +++ b/public/images/qr-icons/tik-tok.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/qr-icons/twitter.svg b/public/images/qr-icons/twitter.svg new file mode 100644 index 0000000..2e05e5f --- /dev/null +++ b/public/images/qr-icons/twitter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/qr-icons/website.svg b/public/images/qr-icons/website.svg new file mode 100644 index 0000000..e6b7459 --- /dev/null +++ b/public/images/qr-icons/website.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/qr-icons/whatsapp.svg b/public/images/qr-icons/whatsapp.svg new file mode 100644 index 0000000..17ee32c --- /dev/null +++ b/public/images/qr-icons/whatsapp.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/qr-icons/wifi.svg b/public/images/qr-icons/wifi.svg new file mode 100644 index 0000000..d56723c --- /dev/null +++ b/public/images/qr-icons/wifi.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/qr-icons/youtube.svg b/public/images/qr-icons/youtube.svg new file mode 100644 index 0000000..cf2e8d9 --- /dev/null +++ b/public/images/qr-icons/youtube.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..4547a36 --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,223 @@ +@import 'tailwindcss'; +@plugin '@tailwindcss/forms'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source '../**/*.blade.php'; +@source '../**/*.js'; + +@theme { + --font-sans: 'Figtree', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} + +[x-cloak] { + display: none !important; +} + +/* QR create/show: flush mobile action bar to the physical screen bottom (mobile only) */ +@media (max-width: 1023px) { + .mobile-action-bar { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 50; + display: flex; + flex-direction: column; + background-color: #fff; + } + + .mobile-action-bar__toolbar { + display: flex; + height: 4rem; + flex-shrink: 0; + align-items: center; + gap: 0.75rem; + border-top: 1px solid rgb(226 232 240); + background-color: #fff; + padding-inline: 1rem; + } + + .mobile-action-bar__inset-fill { + flex-shrink: 0; + width: 100%; + background-color: #fff; + height: env(safe-area-inset-bottom, 0px); + min-height: max(env(safe-area-inset-bottom, 0px), 20px); + } +} + +/* Extra white bleed behind the bar for any remaining viewport gap on mobile */ +.qr-mobile-bottom-bleed { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 48; + pointer-events: none; + background-color: #fff; + height: calc(4rem + env(safe-area-inset-bottom, 0px) + 24px); + min-height: calc(4rem + 24px); +} + +html.qr-mobile-page { + background-color: #fff; +} + +html.qr-mobile-page body { + background-color: #fff; +} + +/* Payment QR landing: lock scroll and keep the amount sheet flush to the screen bottom */ +@media (max-width: 767px) { + html.mini-payment-page, + html.mini-payment-page body { + overflow: hidden; + width: 100%; + height: 100%; + } + + html.mini-payment-page body { + position: fixed; + inset: 0; + } + + .mini-payment-sheet { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 20; + } + + .mini-payment-bottom-bleed { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 19; + pointer-events: none; + background-color: #fff; + height: calc(12rem + env(safe-area-inset-bottom, 0px)); + min-height: 8rem; + } +} + +.mobile-stats-card { + width: calc(66.666667% - 0.5rem); +} + +@layer components { + .btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: 9999px; + background-image: linear-gradient(to right, rgb(79 70 229), rgb(124 58 237)); + padding: 0.5rem 1rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 600; + color: #fff; + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + transition: filter 0.15s ease; + } + + .btn-primary:hover { + filter: brightness(0.92); + } + + .btn-primary:disabled { + opacity: 0.6; + pointer-events: none; + } + + .btn-primary-sm { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + line-height: 1rem; + } + + .btn-primary-lg { + padding: 0.625rem 1.25rem; + } + + .btn-fab { + display: inline-flex; + height: 2.75rem; + width: 2.75rem; + align-items: center; + justify-content: center; + border-radius: 9999px; + background-image: linear-gradient(to right, rgb(79 70 229), rgb(124 58 237)); + color: #fff; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + transition: filter 0.15s ease; + } + + .btn-fab:hover { + filter: brightness(0.92); + } +} + + +@media (min-width: 640px) { + .mobile-stats-card { + width: auto; + } +} + +/* Ladill app launcher — cap at 5 rows (15 apps), always-visible scroll indicator */ +.ladill-launcher-apps-wrap { + position: relative; +} + +.ladill-launcher-apps { + max-height: calc(5 * 5.25rem + 4 * 0.25rem); + overflow-y: scroll; + overscroll-behavior: contain; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: rgb(148 163 184) rgb(241 245 249); + margin-right: -0.25rem; + padding-right: 0.25rem; +} + +.ladill-launcher-apps::-webkit-scrollbar { + -webkit-appearance: none; + width: 8px; +} + +.ladill-launcher-apps::-webkit-scrollbar-track { + background: rgb(241 245 249); + border-radius: 9999px; +} + +.ladill-launcher-apps::-webkit-scrollbar-thumb { + background-color: rgb(148 163 184); + border-radius: 9999px; + min-height: 2.5rem; + border: 2px solid rgb(241 245 249); + background-clip: padding-box; +} + +.ladill-launcher-apps::-webkit-scrollbar-thumb:hover { + background-color: rgb(100 116 139); +} + +.ladill-launcher-scroll-more { + position: absolute; + left: 50%; + bottom: 0.125rem; + z-index: 1; + display: flex; + height: 1.25rem; + width: 1.25rem; + -webkit-transform: translateX(-50%); + transform: translateX(-50%); + align-items: center; + justify-content: center; + border-radius: 9999px; + background: rgb(255 255 255); + color: rgb(100 116 139); + box-shadow: 0 1px 2px rgb(15 23 42 / 0.08); + pointer-events: none; +} diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..f9cf3da --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1,172 @@ +import Alpine from 'alpinejs'; +import collapse from '@alpinejs/collapse'; + +Alpine.plugin(collapse); + +// In-app notification bell + dropdown. +Alpine.data('notificationDropdown', (config = {}) => ({ + open: false, + loading: false, + notifications: [], + unreadCount: 0, + unreadUrl: config.unreadUrl || '/notifications/unread', + markReadUrl: config.markReadUrl || '/notifications/__ID__/read', + markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read', + indexUrl: config.indexUrl || '/notifications', + csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '', + + init() { + this.fetchUnread(); + setInterval(() => this.fetchUnread(), 60000); + }, + async fetchUnread() { + try { + const res = await fetch(this.unreadUrl, { + headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }); + const data = await res.json(); + this.notifications = data.notifications || []; + this.unreadCount = data.unread_count || 0; + } catch (e) { + console.error('Failed to fetch notifications', e); + } + }, + toggle() { + this.open = !this.open; + if (this.open) { + this.loading = true; + this.fetchUnread().finally(() => { this.loading = false; }); + } + }, + async markRead(id) { + const url = this.markReadUrl.replace('__ID__', id); + try { + await fetch(url, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken, 'X-Requested-With': 'XMLHttpRequest' }, + }); + this.notifications = this.notifications.filter(n => n.id !== id); + this.unreadCount = Math.max(0, this.unreadCount - 1); + } catch (e) { + console.error('Failed to mark notification as read', e); + } + }, + async markAllRead() { + try { + await fetch(this.markAllReadUrl, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken, 'X-Requested-With': 'XMLHttpRequest' }, + }); + this.notifications = []; + this.unreadCount = 0; + } catch (e) { + console.error('Failed to mark all notifications as read', e); + } + }, + getIconBg(icon) { + const map = { success: 'bg-green-50', task: 'bg-amber-50' }; + return map[icon] || 'bg-slate-100'; + }, + getIconColor(icon) { + const map = { success: 'text-green-600', task: 'text-amber-600' }; + return map[icon] || 'text-slate-500'; + }, +})); + +// Afia — in-app AI assistant slide-over. Opened via $dispatch('afia-open'). +Alpine.data('afia', (config = {}) => ({ + open: false, + input: '', + loading: false, + messages: [ + { role: 'assistant', text: config.greeting || "Hi, I'm Afia 👋 How can I help?" }, + ], + suggestions: config.suggestions || [], + init() { + window.addEventListener('afia-open', () => { + this.open = true; + this.$nextTick(() => this.$refs.input && this.$refs.input.focus()); + }); + }, + close() { this.open = false; }, + useSuggestion(s) { this.input = s; this.send(); }, + scrollDown() { + this.$nextTick(() => { const el = this.$refs.scroll; if (el) el.scrollTop = el.scrollHeight; }); + }, + async send() { + const text = this.input.trim(); + if (!text || this.loading) return; + const history = this.messages.map((m) => ({ role: m.role, text: m.text })); + this.messages.push({ role: 'user', text }); + this.input = ''; + this.loading = true; + this.scrollDown(); + try { + const res = await fetch(config.chatUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': config.csrf, 'Accept': 'application/json' }, + body: JSON.stringify({ message: text, history }), + }); + const data = await res.json(); + if (! res.ok) { + this.messages.push({ role: 'assistant', text: data.message || data.reply || 'Sorry, I could not respond.' }); + } else { + this.messages.push({ role: 'assistant', text: data.reply || data.message || 'Sorry, I could not respond.' }); + } + } catch (e) { + this.messages.push({ role: 'assistant', text: 'Network error — please try again.' }); + } + this.loading = false; + this.scrollDown(); + }, +})); + +// Wallet balance peek for the avatar dropdown. +Alpine.data('walletWidget', (config = {}) => ({ + display: '…', + async load() { + try { + const res = await fetch(config.url, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } }); + const data = await res.json(); + this.display = data.available ? data.formatted : 'View wallet'; + } catch (e) { + this.display = 'View wallet'; + } + }, +})); + +// Deal line-items editor — products/services as quote lines; deal value = subtotal. +Alpine.data('dealForm', (initialLines = [], products = []) => ({ + lines: Array.isArray(initialLines) ? initialLines : [], + products: Array.isArray(products) ? products : [], + selectedProduct: '', + addLine() { + this.lines.push({ product_id: '', description: '', quantity: 1, unit_price: '' }); + }, + removeLine(index) { + this.lines.splice(index, 1); + }, + addProductLine() { + const p = this.products.find((x) => String(x.id) === String(this.selectedProduct)); + if (!p) return; + this.lines.push({ + product_id: p.id, + description: p.name || '', + quantity: 1, + unit_price: (Number(p.unit_price_minor || 0) / 100).toFixed(2), + }); + this.selectedProduct = ''; + }, + lineTotal(line) { + return (parseFloat(line.quantity) || 0) * (parseFloat(line.unit_price) || 0); + }, + get subtotal() { + return this.lines.reduce((sum, line) => sum + this.lineTotal(line), 0); + }, + money(value) { + return (Number(value) || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + }, +})); + +window.Alpine = Alpine; +Alpine.start(); diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js new file mode 100644 index 0000000..ba26479 --- /dev/null +++ b/resources/js/meet-room.js @@ -0,0 +1,354 @@ +import Alpine from 'alpinejs'; +import { Room, RoomEvent, Track } from 'livekit-client'; + +function meetRoom() { + return { + room: null, + isMuted: false, + isVideoOff: false, + isScreenSharing: false, + isRecording: false, + isLocked: false, + isHost: false, + watermark: false, + liveCaptions: false, + captionText: '', + displayName: '', + canPublish: true, + isWebinar: false, + qaItems: [], + activePolls: [], + breakoutBroadcast: '', + featuresOpen: false, + whiteboardOpen: false, + qaInput: '', + chatOpen: false, + chatInput: '', + connectionStatus: 'Connecting…', + participants: [], + floatingReactions: [], + pollTimer: null, + config: {}, + + init() { + const el = document.getElementById('meet-config'); + if (!el) return; + + this.config = { + token: el.dataset.token, + url: el.dataset.url, + configured: el.dataset.configured === '1', + pollUrl: el.dataset.pollUrl, + chatUrl: el.dataset.chatUrl, + reactionUrl: el.dataset.reactionUrl, + raiseHandUrl: el.dataset.raiseHandUrl, + recordingStartUrl: el.dataset.recordingStartUrl, + recordingStopUrl: el.dataset.recordingStopUrl, + lockUrl: el.dataset.lockUrl, + unlockUrl: el.dataset.unlockUrl, + captionUrl: el.dataset.captionUrl, + qaUrl: el.dataset.qaUrl, + pollsCreateUrl: el.dataset.pollsCreateUrl, + breakoutsCreateUrl: el.dataset.breakoutsCreateUrl, + breakoutsCloseUrl: el.dataset.breakoutsCloseUrl, + breakoutsBroadcastUrl: el.dataset.breakoutsBroadcastUrl, + filesUploadUrl: el.dataset.filesUploadUrl, + whiteboardUrl: el.dataset.whiteboardUrl, + whiteboardSaveUrl: el.dataset.whiteboardSaveUrl, + csrf: el.dataset.csrf, + }; + + this.isHost = el.dataset.isHost === '1'; + this.canPublish = el.dataset.canPublish !== '0'; + this.isWebinar = el.dataset.isWebinar === '1'; + this.isRecording = el.dataset.recordingActive === '1'; + this.isLocked = el.dataset.locked === '1'; + this.watermark = el.dataset.watermark === '1'; + this.liveCaptions = el.dataset.liveCaptions === '1'; + this.displayName = el.dataset.displayName || ''; + + if (this.config.configured && this.config.token) { + this.connectLiveKit(); + } else { + this.connectionStatus = 'Video unavailable — configure LiveKit'; + } + + this.pollTimer = setInterval(() => this.poll(), 3000); + }, + + async connectLiveKit() { + try { + this.room = new Room({ adaptiveStream: true, dynacast: true }); + this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => { + this.attachTrack(track, participant); + }); + this.room.on(RoomEvent.TrackUnsubscribed, (track) => { + track.detach(); + }); + this.room.on(RoomEvent.ParticipantConnected, () => this.syncParticipants()); + this.room.on(RoomEvent.ParticipantDisconnected, () => this.syncParticipants()); + this.room.on(RoomEvent.Disconnected, () => { + this.connectionStatus = 'Disconnected'; + }); + + await this.room.connect(this.config.url, this.config.token); + if (this.canPublish) { + await this.room.localParticipant.enableCameraAndMicrophone(); + this.isMuted = false; + this.isVideoOff = false; + } else { + this.connectionStatus = 'View-only audience mode'; + this.isMuted = true; + this.isVideoOff = true; + } + this.connectionStatus = 'Connected'; + this.syncParticipants(); + this.setupCaptionPipeline(); + this.room.localParticipant.videoTrackPublications.forEach((pub) => { + if (pub.track) this.attachTrack(pub.track, this.room.localParticipant); + }); + } catch (e) { + console.error(e); + this.connectionStatus = 'Connection failed'; + } + }, + + setupCaptionPipeline() { + if (!this.liveCaptions || !this.config.captionUrl) return; + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SpeechRecognition) return; + + const recognition = new SpeechRecognition(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.onresult = (event) => { + const text = event.results[event.results.length - 1][0].transcript.trim(); + if (!text) return; + this.captionText = text; + fetch(this.config.captionUrl, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ text }), + }).catch(() => {}); + }; + recognition.onerror = () => {}; + try { + recognition.start(); + } catch (e) { + // browser may block without gesture + } + }, + + async toggleRecording() { + if (!this.isHost) return; + const url = this.isRecording ? this.config.recordingStopUrl : this.config.recordingStartUrl; + const res = await fetch(url, { method: 'POST', headers: this.headers() }); + if (res.ok) { + this.isRecording = !this.isRecording; + } + }, + + async toggleLock() { + if (!this.isHost) return; + const url = this.isLocked ? this.config.unlockUrl : this.config.lockUrl; + const res = await fetch(url, { method: 'POST', headers: this.headers() }); + if (res.ok) { + this.isLocked = !this.isLocked; + } + }, + + async submitQuestion() { + const question = this.qaInput.trim(); + if (!question || !this.config.qaUrl) return; + this.qaInput = ''; + await fetch(this.config.qaUrl, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ question }), + }); + }, + + async createBreakouts() { + if (!this.isHost || !this.config.breakoutsCreateUrl) return; + const count = parseInt(prompt('Number of breakout rooms?', '4') || '0', 10); + if (!count) return; + await fetch(this.config.breakoutsCreateUrl, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ count }), + }); + }, + + async closeBreakouts() { + if (!this.isHost || !this.config.breakoutsCloseUrl) return; + await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() }); + }, + + async uploadFile(event) { + const file = event.target.files?.[0]; + if (!file || !this.config.filesUploadUrl) return; + const form = new FormData(); + form.append('file', file); + await fetch(this.config.filesUploadUrl, { + method: 'POST', + headers: { 'X-CSRF-TOKEN': this.config.csrf, Accept: 'application/json' }, + body: form, + }); + }, + + attachTrack(track, participant) { + const grid = document.getElementById('video-grid'); + if (!grid || track.kind !== Track.Kind.Video) return; + + const isScreen = track.source === Track.Source.ScreenShare; + const container = isScreen ? document.getElementById('screen-share') : grid; + + if (isScreen) { + container.classList.remove('hidden'); + container.innerHTML = ''; + } + + const el = track.attach(); + el.className = isScreen + ? 'h-full w-full rounded-xl object-contain' + : 'aspect-video w-full rounded-xl bg-slate-800 object-cover'; + el.dataset.participant = participant.identity; + + if (!isScreen) { + const wrapper = document.createElement('div'); + wrapper.className = 'relative overflow-hidden rounded-xl bg-slate-800'; + const label = document.createElement('span'); + label.className = 'absolute bottom-2 left-2 rounded bg-black/60 px-2 py-0.5 text-xs'; + label.textContent = participant.name || participant.identity; + wrapper.appendChild(el); + wrapper.appendChild(label); + container.appendChild(wrapper); + } else { + container.appendChild(el); + } + }, + + syncParticipants() { + if (!this.room) return; + const all = [this.room.localParticipant, ...this.room.remoteParticipants.values()]; + this.participants = all.map((p) => ({ identity: p.identity, name: p.name })); + }, + + async toggleMute() { + if (!this.room) return; + this.isMuted = !this.isMuted; + await this.room.localParticipant.setMicrophoneEnabled(!this.isMuted); + }, + + async toggleVideo() { + if (!this.room) return; + this.isVideoOff = !this.isVideoOff; + await this.room.localParticipant.setCameraEnabled(!this.isVideoOff); + }, + + async toggleScreenShare() { + if (!this.room) return; + this.isScreenSharing = !this.isScreenSharing; + await this.room.localParticipant.setScreenShareEnabled(this.isScreenSharing); + if (!this.isScreenSharing) { + const screen = document.getElementById('screen-share'); + if (screen) { + screen.classList.add('hidden'); + screen.innerHTML = ''; + } + } + }, + + async raiseHand() { + await fetch(this.config.raiseHandUrl, { + method: 'POST', + headers: this.headers(), + }); + }, + + async sendChat() { + const body = this.chatInput.trim(); + if (!body) return; + this.chatInput = ''; + const res = await fetch(this.config.chatUrl, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ body }), + }); + const data = await res.json(); + if (data.message) this.appendChatMessage(data.message); + }, + + async sendReaction(emoji) { + await fetch(this.config.reactionUrl, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ emoji }), + }); + this.showFloatingReaction(emoji); + }, + + async poll() { + try { + const res = await fetch(this.config.pollUrl, { headers: { Accept: 'application/json' } }); + const data = await res.json(); + this.updateChatFromPoll(data.messages || []); + this.qaItems = data.qa || []; + this.activePolls = data.polls || []; + if (data.broadcasts?.length) { + this.breakoutBroadcast = data.broadcasts[0].body; + } + } catch (e) { + // ignore poll errors + } + }, + + updateChatFromPoll(messages) { + const container = document.getElementById('chat-messages'); + if (!container) return; + const existing = new Set([...container.querySelectorAll('[data-uuid]')].map((el) => el.dataset.uuid)); + messages.forEach((m) => { + if (!existing.has(m.uuid)) this.appendChatMessage(m); + }); + }, + + appendChatMessage(message) { + const container = document.getElementById('chat-messages'); + if (!container) return; + const div = document.createElement('div'); + div.className = 'text-sm'; + div.dataset.uuid = message.uuid; + div.innerHTML = `${this.escape(message.sender_name)} +

${this.escape(message.body)}

`; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + }, + + showFloatingReaction(emoji) { + const id = Date.now(); + this.floatingReactions.push({ id, emoji }); + setTimeout(() => { + this.floatingReactions = this.floatingReactions.filter((r) => r.id !== id); + }, 2000); + }, + + headers() { + return { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': this.config.csrf, + 'X-Requested-With': 'XMLHttpRequest', + }; + }, + + escape(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, + }; +} + +window.meetRoom = meetRoom; +Alpine.data('meetRoom', meetRoom); +Alpine.start(); diff --git a/resources/views/auth/signed-out.blade.php b/resources/views/auth/signed-out.blade.php new file mode 100644 index 0000000..35a188e --- /dev/null +++ b/resources/views/auth/signed-out.blade.php @@ -0,0 +1,52 @@ +@php + $signedOut = (array) config('signed_out'); + $logo = (string) ($signedOut['logo'] ?? 'images/logo/ladillcare-logo.svg'); + $logoPath = public_path($logo); +@endphp + + + + + + Signed out — {{ $signedOut['title'] ?? config('app.name') }} + @include('partials.favicon') + + + @vite(['resources/css/app.css']) + + + +
+
+ +
+ {{ $signedOut['title'] ?? config('app.name') }} + +
+ +
+ +

You’re signed out

+

+ {{ $signedOut['description'] ?? ('Your '.($signedOut['title'] ?? config('app.name')).' session has ended. Sign in again to continue.') }} +

+ + + Sign in again + + + + Go to ladill.com + +
+
+ + diff --git a/resources/views/auth/sso-error.blade.php b/resources/views/auth/sso-error.blade.php new file mode 100644 index 0000000..9d89915 --- /dev/null +++ b/resources/views/auth/sso-error.blade.php @@ -0,0 +1,30 @@ +@php + $signedOut = (array) config('signed_out'); + $logo = (string) ($signedOut['logo'] ?? 'images/logo/ladillcare-logo.svg'); + $title = (string) ($signedOut['title'] ?? config('app.name')); + $logoPath = public_path($logo); +@endphp + + + + + + Sign-in problem · {{ $title }} + @include('partials.favicon') + @vite(['resources/css/app.css']) + + +
+ {{ $title }} +

We couldn't sign you in

+

+ Sign-in didn't complete after a few tries. This is usually a temporary + issue with the Ladill account service. Please wait a moment and try again. +

+ @if ($reason !== '') +

Reference: {{ $reason }}

+ @endif + Try again +
+ + diff --git a/resources/views/auth/sso-logout-bridge.blade.php b/resources/views/auth/sso-logout-bridge.blade.php new file mode 100644 index 0000000..2540367 --- /dev/null +++ b/resources/views/auth/sso-logout-bridge.blade.php @@ -0,0 +1,26 @@ + + + + + + Signing out… + @include('partials.favicon') + + + + +

Signing you out of Ladill…

+ + + + diff --git a/resources/views/components/app-layout.blade.php b/resources/views/components/app-layout.blade.php new file mode 100644 index 0000000..89f3fe0 --- /dev/null +++ b/resources/views/components/app-layout.blade.php @@ -0,0 +1,60 @@ +@props(['title' => 'Ladill Meet', 'heading' => null]) + + + + + + + {{ $title }} · Ladill Meet + @include('partials.favicon') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + @include('partials.boot-splash') +
+
+ +
+ @include('partials.topbar', ['heading' => $heading ?? $title]) +
+ @include('partials.flash') + {{ $slot }} +
+
+
+ @auth + @php + $navUser = auth()->user(); + $navInitials = collect(explode(' ', trim((string) $navUser?->name))) + ->filter()->take(2) + ->map(fn ($part) => strtoupper(substr($part, 0, 1))) + ->implode(''); + $navAvatarUrl = $navUser && method_exists($navUser, 'avatarUrl') + ? $navUser->avatarUrl() + : ($navUser?->avatar_url ?? null); + @endphp + @include('partials.mobile-bottom-nav', [ + 'homeUrl' => route('meet.dashboard'), + 'homeActive' => request()->routeIs('meet.dashboard'), + 'searchUrl' => route('meet.rooms.index'), + 'searchActive' => request()->routeIs('meet.rooms.*'), + 'notificationsUrl' => route('notifications.index'), + 'notificationsActive' => request()->routeIs('notifications.*'), + 'unreadUrl' => route('notifications.unread'), + 'profileActive' => false, + 'profileName' => $navUser?->name ?? '', + 'profileSubtitle' => $navUser?->email ?? '', + 'profileMenuItems' => \App\Support\UserProfileMenu::items($navUser), + 'avatarUrl' => $navAvatarUrl, + 'initials' => $navInitials !== '' ? $navInitials : 'U', + ]) + @endauth + @include('partials.wallet-topup-modal', ['openOnLoad' => (bool) session('topup_required')]) + + diff --git a/resources/views/components/badge.blade.php b/resources/views/components/badge.blade.php new file mode 100644 index 0000000..a35a20f --- /dev/null +++ b/resources/views/components/badge.blade.php @@ -0,0 +1,15 @@ +@props(['color' => 'slate']) +@php + $map = [ + 'slate' => 'bg-slate-100 text-slate-700', + 'indigo' => 'bg-indigo-100 text-indigo-700', + 'green' => 'bg-green-100 text-green-700', + 'red' => 'bg-red-100 text-red-700', + 'amber' => 'bg-amber-100 text-amber-700', + 'blue' => 'bg-blue-100 text-blue-700', + ]; + $classes = $map[$color] ?? $map['slate']; +@endphp +merge(['class' => "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium $classes"]) }}> + {{ $slot }} + diff --git a/resources/views/components/btn/create.blade.php b/resources/views/components/btn/create.blade.php new file mode 100644 index 0000000..f51caf4 --- /dev/null +++ b/resources/views/components/btn/create.blade.php @@ -0,0 +1,33 @@ +@props([ + 'href' => null, + 'type' => 'button', + 'label' => null, +]) + +@php + $tag = $href ? 'a' : 'button'; + $ariaLabel = $label ?? trim(preg_replace('/\s+/', ' ', strip_tags((string) $slot))); +@endphp + +<{{ $tag }} + @if ($href) href="{{ $href }}" @endif + @if ($tag === 'button') type="{{ $type }}" @endif + aria-label="{{ $ariaLabel }}" + title="{{ $ariaLabel }}" + {{ $attributes->class(['btn-fab h-10 w-10 lg:hidden']) }} +> + + + +<{{ $tag }} + @if ($href) href="{{ $href }}" @endif + @if ($tag === 'button') type="{{ $type }}" @endif + {{ $attributes->class(['btn-primary hidden lg:inline-flex']) }} +> + + {{ $slot }} + diff --git a/resources/views/components/field.blade.php b/resources/views/components/field.blade.php new file mode 100644 index 0000000..5b84fec --- /dev/null +++ b/resources/views/components/field.blade.php @@ -0,0 +1,37 @@ +@props([ + 'name', + 'label' => null, + 'type' => 'text', + 'value' => null, + 'options' => [], + 'placeholder' => '', + 'required' => false, + 'rows' => 4, +]) +@php + $label = $label ?? \Illuminate\Support\Str::headline($name); + $current = old($name, $value); + $base = 'mt-1 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500'; +@endphp +
only('class') }}> + + + @if ($type === 'textarea') + + @elseif ($type === 'select') + + @else + + @endif + + @error($name)

{{ $message }}

@enderror +
diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php new file mode 100644 index 0000000..a823707 --- /dev/null +++ b/resources/views/components/modal.blade.php @@ -0,0 +1,88 @@ +@props([ + 'name', + 'show' => false, + 'maxWidth' => '2xl' +]) + +@php +$maxWidth = [ + 'sm' => 'sm:max-w-sm', + 'md' => 'sm:max-w-md', + 'lg' => 'sm:max-w-lg', + 'xl' => 'sm:max-w-xl', + '2xl' => 'sm:max-w-2xl', +][$maxWidth]; +@endphp + +
+ {{-- Backdrop click to close --}} +
+ + {{-- Panel: bottom-sheet on mobile, centered card on sm+ --}} +
+ {{-- Drag handle (mobile only) --}} +
+
+
+ + {{-- Close button (desktop only) --}} + + + {{ $slot }} +
+
diff --git a/resources/views/email/contact-message.blade.php b/resources/views/email/contact-message.blade.php new file mode 100644 index 0000000..3cb5cc9 --- /dev/null +++ b/resources/views/email/contact-message.blade.php @@ -0,0 +1,18 @@ + + + + + + + +
+
+
{{ $bodyText }}
+ @if (! empty($fromName)) +

— {{ $fromName }}

+ @endif +
+

Sent via Ladill Frontdesk

+
+ + diff --git a/resources/views/meet/admin/members/create.blade.php b/resources/views/meet/admin/members/create.blade.php new file mode 100644 index 0000000..1bd031b --- /dev/null +++ b/resources/views/meet/admin/members/create.blade.php @@ -0,0 +1,38 @@ + +
+

Add team member

+ +
+ @csrf + +
+ + +

The member's Ladill public_id (UUID).

+
+ +
+ + +
+ +
+ + +
+ + +
+
+
diff --git a/resources/views/meet/admin/members/index.blade.php b/resources/views/meet/admin/members/index.blade.php new file mode 100644 index 0000000..fb8654c --- /dev/null +++ b/resources/views/meet/admin/members/index.blade.php @@ -0,0 +1,31 @@ + +
+

Team members

+ Add member +
+ +
+ + + + + + @foreach ($members as $member) + + + + + + + @endforeach + +
User IDRoleBranch
{{ $member->user_ref }}{{ $roles[$member->role] ?? $member->role }}{{ $member->branch?->name ?? 'All branches' }} + @if ($member->user_ref !== auth()->user()->public_id) +
+ @csrf @method('DELETE') + +
+ @endif +
+
+
diff --git a/resources/views/meet/admin/usage.blade.php b/resources/views/meet/admin/usage.blade.php new file mode 100644 index 0000000..ab34d6e --- /dev/null +++ b/resources/views/meet/admin/usage.blade.php @@ -0,0 +1,24 @@ + +
+

Usage & quotas

+

{{ $organization->name }} · {{ ucfirst($tier) }} plan

+ +
+ @foreach ($quotas as $metric => $limit) + @php + $used = (int) ($summary[$metric] ?? 0); + $pct = $limit > 0 ? min(100, round(($used / $limit) * 100)) : 0; + @endphp +
+
+

{{ str_replace('_', ' ', ucfirst($metric)) }}

+ {{ number_format($used) }} / {{ number_format($limit) }} +
+
+
+
+
+ @endforeach +
+
+
diff --git a/resources/views/meet/channels/create.blade.php b/resources/views/meet/channels/create.blade.php new file mode 100644 index 0000000..94d1617 --- /dev/null +++ b/resources/views/meet/channels/create.blade.php @@ -0,0 +1,24 @@ + +
+

Create channel

+
+ @csrf +
+ + +
+
+ + +
+
+ + +
+ +
+
+
diff --git a/resources/views/meet/channels/index.blade.php b/resources/views/meet/channels/index.blade.php new file mode 100644 index 0000000..b00ade9 --- /dev/null +++ b/resources/views/meet/channels/index.blade.php @@ -0,0 +1,27 @@ + +
+
+
+

Team channels

+

Persistent chat for {{ $organization->name }}

+
+ New channel +
+ + +
+
diff --git a/resources/views/meet/channels/show.blade.php b/resources/views/meet/channels/show.blade.php new file mode 100644 index 0000000..e007b57 --- /dev/null +++ b/resources/views/meet/channels/show.blade.php @@ -0,0 +1,38 @@ + +
+
+

# {{ $channel->name }}

+ @if ($channel->description) +

{{ $channel->description }}

+ @endif +
+ +
+ @foreach ($messages as $message) +
+

{{ $message->sender_name }}

+

{{ $message->body }}

+ @if ($message->replies->isNotEmpty()) +
+ @foreach ($message->replies as $reply) +
+

{{ $reply->sender_name }}

+

{{ $reply->body }}

+
+ @endforeach +
+ @endif +
+ @endforeach + {{ $messages->links() }} +
+ +
+ @csrf +
+ + +
+
+
+
diff --git a/resources/views/meet/contacts/groups.blade.php b/resources/views/meet/contacts/groups.blade.php new file mode 100644 index 0000000..3779d6c --- /dev/null +++ b/resources/views/meet/contacts/groups.blade.php @@ -0,0 +1,30 @@ + +
+

Contact groups

+

Reusable invite lists for {{ $organization->name }}

+ +
+ @csrf +
+ + +
+
+ + +
+ +
+ +
    + @forelse ($groups as $group) +
  • +

    {{ $group->name }}

    +

    {{ $group->members_count }} contacts

    +
  • + @empty +
  • No contact groups yet.
  • + @endforelse +
+
+
diff --git a/resources/views/meet/dashboard.blade.php b/resources/views/meet/dashboard.blade.php new file mode 100644 index 0000000..e69a223 --- /dev/null +++ b/resources/views/meet/dashboard.blade.php @@ -0,0 +1,110 @@ + +
+
+

Meetings

+

{{ $organization->name }}

+
+ +
+ +
+
+

Live now

+

{{ $stats['live_count'] }}

+
+
+

Upcoming

+

{{ $stats['upcoming_count'] }}

+
+
+

Team

+

{{ $stats['team_members'] }}

+
+
+

Recordings

+

{{ $stats['recordings_count'] }}

+
+
+ +
+
+

Last 30 days

+ Full report +
+
+
+

Sessions

+

{{ $analytics['sessions_held'] }}

+
+
+

Meeting minutes

+

{{ number_format($analytics['total_minutes']) }}

+
+
+

Peak participants

+

{{ $analytics['peak_participants'] }}

+
+
+

Completed

+

{{ $analytics['meetings_completed'] }}

+
+
+
+ + @if ($recentRecordings->isNotEmpty()) +
+
+

Recent recordings

+ View all +
+ +
+ @endif + + @if ($active->isNotEmpty()) +
+

Live meetings

+
+ @foreach ($active as $room) + @include('meet.partials.room-card', ['room' => $room, 'live' => true]) + @endforeach +
+
+ @endif + +
+
+

Upcoming

+ View all +
+
+ @forelse ($upcoming as $room) + @include('meet.partials.room-card', ['room' => $room]) + @empty +

No upcoming meetings.

+ @endforelse +
+
+ +
+

Recent

+
+ @forelse ($past as $room) + @include('meet.partials.room-card', ['room' => $room]) + @empty +

No past meetings yet.

+ @endforelse +
+
+
diff --git a/resources/views/meet/invitations/index.blade.php b/resources/views/meet/invitations/index.blade.php new file mode 100644 index 0000000..6fe1246 --- /dev/null +++ b/resources/views/meet/invitations/index.blade.php @@ -0,0 +1,59 @@ + +
+
+

Invitations

+

{{ $room->title }}

+
+ ← Back to meeting +
+ +
+
+ @csrf +

Invite people

+
+ + +
+ @if ($groups->isNotEmpty()) +
+ + +
+ @endif + +
+ +
+ @csrf +

Bulk import (CSV)

+

Columns: email, name, role

+ + +
+
+ +
+
    + @forelse ($room->invitations as $invitation) +
  • +
    +

    {{ $invitation->display_name ?? $invitation->email }}

    +

    {{ $invitation->email }} · {{ ucfirst($invitation->status) }}

    +
    +
    +
    @csrf
    +
    @csrf @method('DELETE')
    +
    +
  • + @empty +
  • No invitations yet.
  • + @endforelse +
+
+
diff --git a/resources/views/meet/invitations/rsvp-done.blade.php b/resources/views/meet/invitations/rsvp-done.blade.php new file mode 100644 index 0000000..c23b69d --- /dev/null +++ b/resources/views/meet/invitations/rsvp-done.blade.php @@ -0,0 +1,19 @@ + + + + + + RSVP recorded + @include('partials.favicon') + @vite(['resources/css/app.css']) + + +
+

Thank you

+

Your response ({{ $invitation->status }}) was recorded for {{ $room->title }}.

+ @if ($invitation->status === 'accepted') + Join link + @endif +
+ + diff --git a/resources/views/meet/invitations/rsvp.blade.php b/resources/views/meet/invitations/rsvp.blade.php new file mode 100644 index 0000000..853fe04 --- /dev/null +++ b/resources/views/meet/invitations/rsvp.blade.php @@ -0,0 +1,24 @@ + + + + + + RSVP · {{ $room->title }} + @include('partials.favicon') + @vite(['resources/css/app.css']) + + +
+

{{ $room->title }}

+

You're invited as {{ $invitation->email }}

+ @if ($room->scheduled_at) +

{{ $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') }}

+ @endif +
+ @csrf + + +
+
+ + diff --git a/resources/views/meet/join/cancelled.blade.php b/resources/views/meet/join/cancelled.blade.php new file mode 100644 index 0000000..aa08e37 --- /dev/null +++ b/resources/views/meet/join/cancelled.blade.php @@ -0,0 +1,16 @@ + + + + + + Meeting cancelled · Ladill Meet + @include('partials.favicon') + @vite(['resources/css/app.css']) + + +
+

Meeting cancelled

+

{{ $room->title }} is no longer available.

+
+ + diff --git a/resources/views/meet/join/denied.blade.php b/resources/views/meet/join/denied.blade.php new file mode 100644 index 0000000..4a3714d --- /dev/null +++ b/resources/views/meet/join/denied.blade.php @@ -0,0 +1,17 @@ + + + + + + Access denied · Ladill Meet + @include('partials.favicon') + @vite(['resources/css/app.css']) + + +
+

Unable to join

+

{{ $reason ?? 'You do not have access to this meeting.' }}

+ Sign in +
+ + diff --git a/resources/views/meet/join/passcode.blade.php b/resources/views/meet/join/passcode.blade.php new file mode 100644 index 0000000..0cbf896 --- /dev/null +++ b/resources/views/meet/join/passcode.blade.php @@ -0,0 +1,27 @@ + + + + + + + Join · {{ $room->title }} · Ladill Meet + @include('partials.favicon') + @vite(['resources/css/app.css']) + + +
+

{{ $room->title }}

+

Enter the meeting passcode to continue.

+ +
+ @csrf + + @error('passcode') +

{{ $message }}

+ @enderror + +
+
+ + diff --git a/resources/views/meet/join/show.blade.php b/resources/views/meet/join/show.blade.php new file mode 100644 index 0000000..7709969 --- /dev/null +++ b/resources/views/meet/join/show.blade.php @@ -0,0 +1,40 @@ + + + + + + + Join · {{ $room->title }} · Ladill Meet + @include('partials.favicon') + @vite(['resources/css/app.css']) + + +
+

{{ $room->title }}

+

Ready to join?

+ +
+ @csrf + @guest +
+ + +
+ @if ($isWebinar ?? false) +
+ + +
+ @endif + @endguest + +
+ + @auth +

Joining as {{ auth()->user()->name }}

+ @endauth +
+ + diff --git a/resources/views/meet/messages/index.blade.php b/resources/views/meet/messages/index.blade.php new file mode 100644 index 0000000..9ced818 --- /dev/null +++ b/resources/views/meet/messages/index.blade.php @@ -0,0 +1,34 @@ + +
+

Direct messages

+

Private conversations in {{ $organization->name }}

+ +
+ @csrf + + +
+ + +
+
diff --git a/resources/views/meet/messages/show.blade.php b/resources/views/meet/messages/show.blade.php new file mode 100644 index 0000000..bc8085a --- /dev/null +++ b/resources/views/meet/messages/show.blade.php @@ -0,0 +1,21 @@ + +
+
+ @foreach ($messages as $message) +
+

{{ $message->sender_name }}

+

{{ $message->body }}

+
+ @endforeach + {{ $messages->links() }} +
+ +
+ @csrf +
+ + +
+
+
+
diff --git a/resources/views/meet/onboarding/show.blade.php b/resources/views/meet/onboarding/show.blade.php new file mode 100644 index 0000000..1b3c341 --- /dev/null +++ b/resources/views/meet/onboarding/show.blade.php @@ -0,0 +1,62 @@ + +
+

Welcome to Ladill Meet

+

Set up your organization to start hosting video meetings.

+ +
+ @csrf + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
diff --git a/resources/views/meet/partials/room-card.blade.php b/resources/views/meet/partials/room-card.blade.php new file mode 100644 index 0000000..c1dd98e --- /dev/null +++ b/resources/views/meet/partials/room-card.blade.php @@ -0,0 +1,38 @@ +@php + $statusColors = [ + 'live' => 'bg-emerald-50 text-emerald-700', + 'scheduled' => 'bg-sky-50 text-sky-700', + 'ended' => 'bg-slate-100 text-slate-600', + 'cancelled' => 'bg-red-50 text-red-700', + ]; +@endphp +
+
+
+

{{ $room->title }}

+ + {{ config('meet.room_statuses')[$room->status] ?? $room->status }} + +
+

+ @if ($room->scheduled_at) + {{ $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') }} + @else + Instant meeting + @endif +

+
+
+ @if (($live ?? false) || $room->status === 'live') + @if ($room->activeSession()) + Join + @endif + @elseif ($room->status === 'scheduled') +
+ @csrf + +
+ @endif + Details +
+
diff --git a/resources/views/meet/recordings/index.blade.php b/resources/views/meet/recordings/index.blade.php new file mode 100644 index 0000000..ec0c47b --- /dev/null +++ b/resources/views/meet/recordings/index.blade.php @@ -0,0 +1,29 @@ + +
+

Recordings

+
+ +
+ @forelse ($recordings as $recording) +
+
+

{{ $recording->session->room->title }}

+

+ {{ config('meet.recording_statuses')[$recording->status] ?? $recording->status }} + · {{ $recording->created_at->format('M j, Y g:i A') }} +

+
+
+ View + @if ($recording->isReady()) + Download + @endif +
+
+ @empty +

No recordings yet.

+ @endforelse +
+ +
{{ $recordings->links() }}
+
diff --git a/resources/views/meet/recordings/show.blade.php b/resources/views/meet/recordings/show.blade.php new file mode 100644 index 0000000..72b777b --- /dev/null +++ b/resources/views/meet/recordings/show.blade.php @@ -0,0 +1,27 @@ + +
+

{{ $recording->session->room->title }}

+

{{ config('meet.recording_statuses')[$recording->status] ?? $recording->status }}

+ +
+ @if ($recording->duration_seconds) +

Duration: {{ gmdate('H:i:s', $recording->duration_seconds) }}

+ @endif + @if ($recording->isReady()) + Download MP4 + @else +

Recording is still processing. Refresh shortly.

+ @endif + + @php $summary = $recording->session->aiSummaries()->latest()->first(); @endphp + @if ($summary?->status === 'ready') + View AI summary → + @endif +
+ +
+ @csrf @method('DELETE') + +
+
+
diff --git a/resources/views/meet/reports/index.blade.php b/resources/views/meet/reports/index.blade.php new file mode 100644 index 0000000..dbe52d1 --- /dev/null +++ b/resources/views/meet/reports/index.blade.php @@ -0,0 +1,12 @@ + +

Reports

+

Meeting analytics and exports for {{ $organization->name }}

+
+ @foreach ($reports as $key => $label) + +

{{ $label }}

+

View and export CSV

+
+ @endforeach +
+
diff --git a/resources/views/meet/reports/show.blade.php b/resources/views/meet/reports/show.blade.php new file mode 100644 index 0000000..12a1c46 --- /dev/null +++ b/resources/views/meet/reports/show.blade.php @@ -0,0 +1,90 @@ + +
+
+

{{ $label }}

+

{{ $from }} — {{ $to }}

+
+ @if ($canExport) + Export CSV + @endif +
+ +
+ + + @if ($branches->isNotEmpty()) + + @endif + +
+ +
+ @if ($type === 'meetings') +
+
Total
{{ $data['total'] }}
+
+
+ + + + @foreach ($data['rows'] as $row) + + + + + + + @endforeach + +
TitleStatusPeakRSVP
{{ $row['title'] }}{{ $row['status'] }}{{ $row['peak_participants'] }}{{ $row['accepted'] }}/{{ $row['invitations'] }}
+
+ @elseif ($type === 'users') +
+
Unique participants
{{ $data['unique_participants'] }}
+
Unique hosts
{{ $data['unique_hosts'] }}
+
+
    + @foreach ($data['top_users'] as $user) +
  • {{ $user['display_name'] }}{{ $user['minutes'] }} min · {{ $user['meetings_joined'] }} meetings
  • + @endforeach +
+ @elseif ($type === 'invitations') +
+
Sent
{{ $data['total'] }}
+
Accepted
{{ $data['accepted'] }}
+
Declined
{{ $data['declined'] }}
+
Accept rate
{{ $data['acceptance_rate'] }}%
+
+ @elseif ($type === 'branches') +
+ + + + @foreach ($data as $row) + + + + + + + + @endforeach + +
BranchScheduledSessionsMinutesRecordings
{{ $row['branch_name'] }}{{ $row['meetings_scheduled'] }}{{ $row['sessions_held'] }}{{ $row['total_minutes'] }}{{ $row['recordings_count'] }}
+
+ @else +
+ @foreach ($data as $key => $value) + @if (! is_array($value)) +
{{ str_replace('_', ' ', $key) }}
{{ is_numeric($value) && $key === 'recording_storage_bytes' ? number_format($value / 1024 / 1024, 1).' MB' : $value }}
+ @endif + @endforeach +
+ @endif +
+
diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php new file mode 100644 index 0000000..0bad45a --- /dev/null +++ b/resources/views/meet/room/show.blade.php @@ -0,0 +1,181 @@ + + + + + + + {{ $room->title }} · Ladill Meet + @include('partials.favicon') + @vite(['resources/css/app.css', 'resources/js/meet-room.js']) + + + + + @if (! $mediaConfigured) +
+ LiveKit is not configured. Set LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET in .env for video. +
+ @endif + +
+
+
+

{{ $room->title }}

+

+
+
+ REC + Locked + +
+
+ +
+
+
+ + +
+ +
+

+
+ +
+ +
+
+ + +
+ +
+
+ + + + + + + + + + + @if ($participant->isHost()) + + + @endif +
+ @csrf + +
+ @if ($participant->isHost()) +
+ @csrf + +
+ @endif +
+
+ +
+
+ @if ($isWebinar ?? false) +
+

Q&A

+
    + +
+
+ + +
+
+ @endif + @if ($participant->isHost()) +
+ + +
+ @endif +
+

+
+
+ + diff --git a/resources/views/meet/rooms/create.blade.php b/resources/views/meet/rooms/create.blade.php new file mode 100644 index 0000000..eb70a0f --- /dev/null +++ b/resources/views/meet/rooms/create.blade.php @@ -0,0 +1,101 @@ + +
+

Schedule a meeting

+ +
+ @csrf + + @if ($templates->isNotEmpty()) +
+ + +
+ @endif + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ Recurrence + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ +
+ Options + + + + + + +
+ + +
+
+
diff --git a/resources/views/meet/rooms/index.blade.php b/resources/views/meet/rooms/index.blade.php new file mode 100644 index 0000000..7732d02 --- /dev/null +++ b/resources/views/meet/rooms/index.blade.php @@ -0,0 +1,16 @@ + +
+

All meetings

+ Schedule meeting +
+ +
+ @forelse ($rooms as $room) + @include('meet.partials.room-card', ['room' => $room, 'live' => $room->status === 'live']) + @empty +

No meetings yet.

+ @endforelse +
+ +
{{ $rooms->links() }}
+
diff --git a/resources/views/meet/rooms/show.blade.php b/resources/views/meet/rooms/show.blade.php new file mode 100644 index 0000000..29fe463 --- /dev/null +++ b/resources/views/meet/rooms/show.blade.php @@ -0,0 +1,123 @@ + +
+
+
+

{{ $room->title }}

+

{{ config('meet.room_statuses')[$room->status] ?? $room->status }}

+
+ @if ($room->status === 'scheduled') +
+ @csrf + +
+ @endif +
+ +
+

Join link

+
+ + +
+ +
+ Download iCal + QR code + @if ($room->sessions->isNotEmpty()) + Attendance CSV + @endif + Manage invitations + @if ($room->isWebinar()) + Registrations + Registration page + @endif +
+ + @if ($room->passcode) +

Passcode: {{ $room->passcode }}

+ @endif + + @if ($room->scheduled_at) +

+ Scheduled: {{ $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') }} +

+ @endif + + @if ($room->recurrence_rule) +

+ Repeats {{ config('meet.recurrence_rules')[$room->recurrence_rule] ?? $room->recurrence_rule }} + @if ($room->recurrence_until) + until {{ $room->recurrence_until->format('M j, Y') }} + @endif +

+ @endif +
+ + @php + $recordings = $room->sessions->flatMap->recordings->sortByDesc('created_at'); + $summaries = $room->sessions->flatMap->aiSummaries->where('status', 'ready')->sortByDesc('created_at'); + @endphp + + @if ($recordings->isNotEmpty()) +
+

Recordings

+ +
+ @endif + + @if ($summaries->isNotEmpty()) +
+

AI summaries

+ +
+ @endif + + @if ($room->sessionFiles->isNotEmpty()) +
+

Shared files

+
    + @foreach ($room->sessionFiles as $file) +
  • + {{ $file->original_name }} + {{ number_format($file->file_size / 1024, 1) }} KB +
  • + @endforeach +
+
+ @endif + + @if ($room->sessions->isNotEmpty()) +
+

Sessions

+
    + @foreach ($room->sessions as $session) +
  • + {{ $session->started_at?->format('M j, Y g:i A') ?? 'Pending' }} + {{ $session->peak_participants }} participants · {{ config('meet.session_statuses')[$session->status] ?? $session->status }} +
  • + @endforeach +
+
+ @endif + + @if ($room->status === 'scheduled') +
+ @csrf + +
+ @endif +
+
diff --git a/resources/views/meet/settings/calendar.blade.php b/resources/views/meet/settings/calendar.blade.php new file mode 100644 index 0000000..101becd --- /dev/null +++ b/resources/views/meet/settings/calendar.blade.php @@ -0,0 +1,48 @@ + +
+

Calendar integration

+

Sync scheduled meetings to your calendar

+ +
+
+
+

Ladill Mail

+

+ @if ($mailConnected) + Connected — events sync to your mailbox calendar + @elseif (! $mailConfigured) + Not configured on this server + @else + Not connected + @endif +

+
+ @if ($mailConfigured && ! $mailConnected) +
+ @csrf + +
+ @endif +
+
+
+

Google Calendar

+

{{ $googleConnected ? 'Connected' : 'Not connected' }}

+
+ @if ($googleAuthUrl && ! $googleConnected) + Connect + @endif +
+
+
+

Microsoft 365

+

{{ $microsoftConnected ? 'Connected' : 'Not connected' }}

+
+ @if ($microsoftAuthUrl && ! $microsoftConnected) + Connect + @endif +
+

Apple Calendar: use iCal download on each meeting detail page.

+
+
+
diff --git a/resources/views/meet/settings/security.blade.php b/resources/views/meet/settings/security.blade.php new file mode 100644 index 0000000..6d5a0ac --- /dev/null +++ b/resources/views/meet/settings/security.blade.php @@ -0,0 +1,23 @@ + +
+

Meeting security

+

Organization-wide defaults for {{ $organization->name }}

+ +
+ @csrf @method('PUT') + + + + +
+ + +
+
+ + +
+ +
+
+
diff --git a/resources/views/meet/settings/webhooks.blade.php b/resources/views/meet/settings/webhooks.blade.php new file mode 100644 index 0000000..8cb6a64 --- /dev/null +++ b/resources/views/meet/settings/webhooks.blade.php @@ -0,0 +1,39 @@ + +
+

Webhooks

+

Notify sibling Ladill apps when meetings change

+ +
+ @csrf +
+ + +
+
+ + +
+
+ Events + @foreach ($events as $event) + + @endforeach +
+ +
+ +
    + @forelse ($endpoints as $endpoint) +
  • +
    +

    {{ $endpoint->url }}

    +

    {{ implode(', ', $endpoint->events ?? []) }}

    +
    +
    @csrf @method('DELETE')
    +
  • + @empty +
  • No webhook endpoints configured.
  • + @endforelse +
+
+
diff --git a/resources/views/meet/summaries/show.blade.php b/resources/views/meet/summaries/show.blade.php new file mode 100644 index 0000000..546a0bb --- /dev/null +++ b/resources/views/meet/summaries/show.blade.php @@ -0,0 +1,41 @@ + +
+

{{ $summary->session->room->title }}

+

AI meeting summary

+ +
+

Summary

+

{{ $summary->summary }}

+ + @if ($summary->decisions) +

Decisions

+
    + @foreach ($summary->decisions as $decision) +
  • {{ is_string($decision) ? $decision : json_encode($decision) }}
  • + @endforeach +
+ @endif + + @if ($summary->actionItems->isNotEmpty()) +

Action items

+
    + @foreach ($summary->actionItems as $item) +
  • + {{ $item->description }} + @if ($item->assignee_name) + — {{ $item->assignee_name }} + @endif +
  • + @endforeach +
+ @endif +
+ + @if ($summary->transcript) +
+ Full transcript +
{{ $summary->transcript->content }}
+
+ @endif +
+
diff --git a/resources/views/meet/templates/create.blade.php b/resources/views/meet/templates/create.blade.php new file mode 100644 index 0000000..75f8979 --- /dev/null +++ b/resources/views/meet/templates/create.blade.php @@ -0,0 +1,21 @@ + +
+

New meeting template

+
+ @csrf +
+ + +
+
+ + +
+ + + + + +
+
+
diff --git a/resources/views/meet/templates/index.blade.php b/resources/views/meet/templates/index.blade.php new file mode 100644 index 0000000..fa890e0 --- /dev/null +++ b/resources/views/meet/templates/index.blade.php @@ -0,0 +1,17 @@ + +
+

Meeting templates

+ New template +
+ +
+ @forelse ($templates as $template) +
+

{{ $template->name }}

+

{{ $template->duration_minutes }} min @if($template->is_default) · Default @endif

+
+ @empty +

No templates yet.

+ @endforelse +
+
diff --git a/resources/views/meet/webinar/register-done.blade.php b/resources/views/meet/webinar/register-done.blade.php new file mode 100644 index 0000000..1af2740 --- /dev/null +++ b/resources/views/meet/webinar/register-done.blade.php @@ -0,0 +1,15 @@ + +
+

Registration received

+

+ @if ($registration->isApproved()) + You're approved. Use your join link when the webinar starts. + @else + Your registration is pending host approval. + @endif +

+ @if ($registration->isApproved()) + Join link + @endif +
+
diff --git a/resources/views/meet/webinar/register.blade.php b/resources/views/meet/webinar/register.blade.php new file mode 100644 index 0000000..669e5a7 --- /dev/null +++ b/resources/views/meet/webinar/register.blade.php @@ -0,0 +1,22 @@ + +
+

Register for webinar

+

{{ $room->title }}

+ @if ($room->scheduled_at) +

{{ $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') }}

+ @endif + +
+ @csrf +
+ + +
+
+ + +
+ +
+
+
diff --git a/resources/views/meet/webinar/registrations.blade.php b/resources/views/meet/webinar/registrations.blade.php new file mode 100644 index 0000000..4976e59 --- /dev/null +++ b/resources/views/meet/webinar/registrations.blade.php @@ -0,0 +1,30 @@ + +
+
+
+

Registrations

+

{{ $room->title }}

+
+ Public registration page +
+ +
    + @forelse ($registrations as $registration) +
  • +
    +

    {{ $registration->display_name }}

    +

    {{ $registration->email }} · {{ ucfirst($registration->status) }}

    +
    + @if ($registration->status === 'pending') +
    +
    @csrf
    +
    @csrf
    +
    + @endif +
  • + @empty +
  • No registrations yet.
  • + @endforelse +
+
+
diff --git a/resources/views/notifications/index.blade.php b/resources/views/notifications/index.blade.php new file mode 100644 index 0000000..737a023 --- /dev/null +++ b/resources/views/notifications/index.blade.php @@ -0,0 +1,24 @@ + +
+

Notifications

+ + + +
{{ $notifications->links() }}
+
+
diff --git a/resources/views/partials/afia-button.blade.php b/resources/views/partials/afia-button.blade.php new file mode 100644 index 0000000..35c0366 --- /dev/null +++ b/resources/views/partials/afia-button.blade.php @@ -0,0 +1,15 @@ +@props(['compact' => false]) + + diff --git a/resources/views/partials/boot-splash.blade.php b/resources/views/partials/boot-splash.blade.php new file mode 100644 index 0000000..5d86769 --- /dev/null +++ b/resources/views/partials/boot-splash.blade.php @@ -0,0 +1,43 @@ +@php + // Branded boot splash (Ladill Mail style). Self-icon from this app's subdomain. + $sub = strtolower((string) ((explode('.', (string) (parse_url((string) config('app.url'), PHP_URL_HOST) ?: '')))[0] ?? '')); + $icon = $sub !== '' && is_file(public_path("images/launcher-icons/{$sub}.svg")) ? "images/launcher-icons/{$sub}.svg" : null; + $label = (string) config('app.name', 'Ladill'); +@endphp +
+
+ @if ($icon) + + @endif +
+
Loading {{ $label }}…
+
+
+ + diff --git a/resources/views/partials/favicon.blade.php b/resources/views/partials/favicon.blade.php new file mode 100644 index 0000000..db5adb3 --- /dev/null +++ b/resources/views/partials/favicon.blade.php @@ -0,0 +1,3 @@ +@php $faviconVer = @filemtime(public_path('favicon.ico')) ?: '1'; @endphp + + diff --git a/resources/views/partials/flash.blade.php b/resources/views/partials/flash.blade.php new file mode 100644 index 0000000..657ef02 --- /dev/null +++ b/resources/views/partials/flash.blade.php @@ -0,0 +1,34 @@ +@if (session('success')) +
+
+

{{ session('success') }}

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

{{ session('error') }}

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

{{ session('warning') }}

+
+
+@endif + +@if ($errors->any()) +
+

Please fix the following:

+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+@endif diff --git a/resources/views/partials/launcher.blade.php b/resources/views/partials/launcher.blade.php new file mode 100644 index 0000000..de21e62 --- /dev/null +++ b/resources/views/partials/launcher.blade.php @@ -0,0 +1,84 @@ +@php + // Shared Ladill app launcher — IDENTICAL across every app/service. + // Driven by config/ladill_launcher.php (fully-extracted apps only) + icons + // from public/images/launcher-icons/. Omits the current app (matched by + // APP_URL host). To replicate elsewhere, copy this file + the config + + // the launcher-icons folder verbatim. + $sidebar = (bool) ($sidebar ?? false); + $selfHost = parse_url((string) config('app.url'), PHP_URL_HOST); + $launcherApps = array_values(array_filter( + config('ladill_launcher.apps', []), + fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost + )); + $launcherOverflows = count($launcherApps) > 15; +@endphp +@if (! empty($launcherApps)) +
+ +
! $sidebar, + 'bottom-full left-0 mb-2' => $sidebar, + ])> +

All apps

+
$launcherOverflows])> +
+
+ @foreach ($launcherApps as $app) + + + + + {{ $app['name'] }} + + @endforeach +
+
+ @if ($launcherOverflows) + + @endif +
+
+
+@endif diff --git a/resources/views/partials/mobile-bottom-nav.blade.php b/resources/views/partials/mobile-bottom-nav.blade.php new file mode 100644 index 0000000..5fbdf50 --- /dev/null +++ b/resources/views/partials/mobile-bottom-nav.blade.php @@ -0,0 +1,130 @@ +@php + $showSearch = isset($searchUrl) && $searchUrl !== null && $searchUrl !== '#'; + $gridCols = match (true) { + ! empty($centerCompose) => $showSearch ? 'grid-cols-5' : 'grid-cols-4', + default => $showSearch ? 'grid-cols-4' : 'grid-cols-3', + }; + $avatarUrl = $avatarUrl ?? null; + $initials = $initials ?? 'U'; + $notificationsUrl = $notificationsUrl ?? '#'; + $unreadUrl = $unreadUrl ?? null; + $profileUrl = $profileUrl ?? '#'; + $profileName = trim((string) ($profileName ?? '')); + $profileSubtitle = trim((string) ($profileSubtitle ?? '')); + $profileMenuItems = $profileMenuItems ?? []; + if ($profileMenuItems === [] && $profileUrl !== '#') { + $profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]]; + } + $navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600'; +@endphp +
+ + + {{-- Profile menu bottom sheet (matches desktop avatar dropdown) --}} +
+
+ +
+
+ +
+ + @if ($profileName !== '' || $profileSubtitle !== '') +
+ @if ($avatarUrl) + + @else + {{ $initials }} + @endif +
+ @if ($profileName !== '') +

{{ $profileName }}

+ @endif + @if ($profileSubtitle !== '') +

{{ $profileSubtitle }}

+ @endif +
+
+ @endif + + @include('partials.user-profile-menu', [ + 'items' => $profileMenuItems, + 'variant' => 'sheet', + 'onNavigate' => 'profileOpen = false', + ]) +
+
+
diff --git a/resources/views/partials/mobile-header-btn.blade.php b/resources/views/partials/mobile-header-btn.blade.php new file mode 100644 index 0000000..97d79c4 --- /dev/null +++ b/resources/views/partials/mobile-header-btn.blade.php @@ -0,0 +1,53 @@ +@php + $variant = $variant ?? 'primary'; + $type = $type ?? 'submit'; + $tag = ! empty($href) ? 'a' : 'button'; + $ariaLabel = $ariaLabel ?? $label ?? ''; + $desktopLabel = $desktopLabel ?? $label ?? ''; + $showDesktopIcon = $showDesktopIcon ?? ($variant === 'primary'); + + $mobileClass = match ($variant) { + 'primary' => 'btn-fab h-10 w-10 lg:hidden', + 'outline' => 'inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 lg:hidden', + 'dark' => 'inline-flex h-10 w-10 items-center justify-center rounded-full bg-gray-900 text-white shadow-sm transition hover:bg-gray-800 lg:hidden', + 'indigo' => 'inline-flex h-10 w-10 items-center justify-center rounded-full bg-indigo-600 text-white shadow-sm transition hover:bg-indigo-700 lg:hidden', + default => 'btn-fab h-10 w-10 lg:hidden', + }; + + $desktopClass = match ($variant) { + 'primary' => 'btn-primary hidden items-center lg:inline-flex', + 'outline' => 'hidden items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 lg:inline-flex', + 'dark' => 'hidden items-center gap-1.5 rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-medium text-white transition hover:bg-gray-800 lg:inline-flex', + 'indigo' => 'hidden items-center justify-center rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-700 lg:inline-flex', + default => 'btn-primary hidden items-center lg:inline-flex', + }; + + $extraClass = $class ?? ''; +@endphp + +<{{ $tag }} + @if ($tag === 'a') href="{{ $href }}" @endif + @if ($tag === 'button') type="{{ $type }}" @endif + aria-label="{{ $ariaLabel }}" + title="{{ $ariaLabel }}" + @if (! empty($attributes)) {!! $attributes !!} @endif + class="{{ trim($mobileClass.' '.$extraClass) }}" +> + + + +<{{ $tag }} + @if ($tag === 'a') href="{{ $href }}" @endif + @if ($tag === 'button') type="{{ $type }}" @endif + @if (! empty($attributes)) {!! $attributes !!} @endif + class="{{ trim($desktopClass.' '.$extraClass) }}" +> + @if ($showDesktopIcon) + + @endif + {{ $desktopLabel }} + diff --git a/resources/views/partials/mobile-icon-link.blade.php b/resources/views/partials/mobile-icon-link.blade.php new file mode 100644 index 0000000..b1a96c4 --- /dev/null +++ b/resources/views/partials/mobile-icon-link.blade.php @@ -0,0 +1,27 @@ +@php + $icon = $icon ?? 'arrow'; + $desktopClass = $desktopClass ?? 'text-xs font-medium text-indigo-600 hover:text-indigo-700'; + $label = $label ?? ''; +@endphp + + + + @if ($icon === 'shield') + + @elseif ($icon === 'calendar') + + @else + + @endif + + + diff --git a/resources/views/partials/mobile-topbar-title.blade.php b/resources/views/partials/mobile-topbar-title.blade.php new file mode 100644 index 0000000..422816d --- /dev/null +++ b/resources/views/partials/mobile-topbar-title.blade.php @@ -0,0 +1,6 @@ +@php + $title = $heading ?? $mobileTopbarTitle ?? 'Ladill Meet'; +@endphp +
+

{{ $title }}

+
diff --git a/resources/views/partials/notification-dropdown.blade.php b/resources/views/partials/notification-dropdown.blade.php new file mode 100644 index 0000000..be1a23b --- /dev/null +++ b/resources/views/partials/notification-dropdown.blade.php @@ -0,0 +1,97 @@ +{{-- In-app notification bell + dropdown (scoped to this app). --}} + diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php new file mode 100644 index 0000000..30407fd --- /dev/null +++ b/resources/views/partials/sidebar.blade.php @@ -0,0 +1,72 @@ +
+
+ + Ladill Meet + +
+ @php + $member = auth()->user() + ? app(\App\Services\Meet\OrganizationResolver::class)->memberFor(auth()->user()) + : null; + $permissions = app(\App\Services\Meet\MeetPermissions::class); + + $nav = [ + ['name' => 'Dashboard', 'route' => route('meet.dashboard'), 'active' => request()->routeIs('meet.dashboard'), + 'icon' => ''], + ['name' => 'Meetings', 'route' => route('meet.rooms.index'), 'active' => request()->routeIs('meet.rooms.*'), + 'icon' => ''], + ['name' => 'Recordings', 'route' => route('meet.recordings.index'), 'active' => request()->routeIs('meet.recordings.*'), + 'icon' => ''], + ['name' => 'Reports', 'route' => route('meet.reports.index'), 'active' => request()->routeIs('meet.reports.*'), + 'icon' => ''], + ['name' => 'Channels', 'route' => route('meet.channels.index'), 'active' => request()->routeIs('meet.channels.*'), + 'icon' => ''], + ['name' => 'Messages', 'route' => route('meet.messages.index'), 'active' => request()->routeIs('meet.messages.*'), + 'icon' => ''], + ]; + + if ($permissions->isAdmin($member)) { + $nav[] = ['name' => 'Templates', 'route' => route('meet.templates.index'), 'active' => request()->routeIs('meet.templates.*'), + 'icon' => '']; + } + + $adminNav = []; + if ($permissions->isAdmin($member)) { + $adminNav[] = ['name' => 'Usage', 'route' => route('meet.admin.usage'), 'active' => request()->routeIs('meet.admin.*'), + 'icon' => '']; + $adminNav[] = ['name' => 'Security', 'route' => route('meet.settings.security'), 'active' => request()->routeIs('meet.settings.security*'), + 'icon' => '']; + $adminNav[] = ['name' => 'Calendar', 'route' => route('meet.settings.calendar'), 'active' => request()->routeIs('meet.settings.calendar*'), + 'icon' => '']; + $adminNav[] = ['name' => 'Webhooks', 'route' => route('meet.settings.webhooks'), 'active' => request()->routeIs('meet.settings.webhooks*'), + 'icon' => '']; + $adminNav[] = ['name' => 'Contacts', 'route' => route('meet.contacts.groups'), 'active' => request()->routeIs('meet.contacts.*'), + 'icon' => '']; + $adminNav[] = ['name' => 'Team', 'route' => route('meet.members.index'), 'active' => request()->routeIs('meet.members.*'), + 'icon' => '']; + } + + $settingsActive = false; + @endphp + + +
diff --git a/resources/views/partials/topbar-account-switcher.blade.php b/resources/views/partials/topbar-account-switcher.blade.php new file mode 100644 index 0000000..dfcaf02 --- /dev/null +++ b/resources/views/partials/topbar-account-switcher.blade.php @@ -0,0 +1,24 @@ +{{-- Account switcher (desktop) — only when the user belongs to more than one account. --}} +@if (isset($accessibleAccounts) && $accessibleAccounts->count() > 1) + +@endif diff --git a/resources/views/partials/topbar-desktop-widgets.blade.php b/resources/views/partials/topbar-desktop-widgets.blade.php new file mode 100644 index 0000000..5e00826 --- /dev/null +++ b/resources/views/partials/topbar-desktop-widgets.blade.php @@ -0,0 +1,45 @@ +{{-- + Top-right header widgets: + Mobile — Afia + launcher only (profile/notifications live in bottom nav). + Desktop — Afia → notifications → launcher → divider → avatar dropdown. +--}} +@php + $topbarUser = $user ?? auth()->user(); + $initials = collect(explode(' ', trim((string) $topbarUser?->name))) + ->filter()->take(2)->map(fn ($p) => strtoupper(substr($p, 0, 1)))->implode(''); + $avatarUrl = $topbarUser && method_exists($topbarUser, 'avatarUrl') + ? $topbarUser->avatarUrl() + : ($topbarUser?->avatar_url ?? null); + $showUserHeader = $showUser ?? true; +@endphp + +@includeIf('partials.afia-button', ['compact' => true]) + +@includeIf('partials.notification-dropdown') + +@include('partials.launcher') + +@includeIf('partials.topbar-widgets-mid') + + + + diff --git a/resources/views/partials/topbar.blade.php b/resources/views/partials/topbar.blade.php new file mode 100644 index 0000000..a9cb1f1 --- /dev/null +++ b/resources/views/partials/topbar.blade.php @@ -0,0 +1,34 @@ +@php + $user = auth()->user(); + $initials = collect(explode(' ', trim((string) $user?->name))) + ->filter()->take(2)->map(fn ($p) => strtoupper(substr($p, 0, 1)))->implode(''); +@endphp +
+
+ + + @include('partials.mobile-topbar-title', ['heading' => $heading ?? null]) + + @if (auth()->check() && app(\App\Services\Meet\MeetPermissions::class)->can( + app(\App\Services\Meet\OrganizationResolver::class)->memberFor(auth()->user()), 'meetings.view')) + + @endif +
+ +
+ @includeIf('partials.topbar-widgets-prepend') + @include('partials.topbar-desktop-widgets', ['user' => $user, 'showUser' => true]) + @includeIf('partials.topbar-widgets-append') +
+
diff --git a/resources/views/partials/user-profile-menu.blade.php b/resources/views/partials/user-profile-menu.blade.php new file mode 100644 index 0000000..6ddfea8 --- /dev/null +++ b/resources/views/partials/user-profile-menu.blade.php @@ -0,0 +1,73 @@ +@props([ + 'items' => [], + 'user' => null, + 'showUser' => false, + 'variant' => 'dropdown', + 'onNavigate' => null, +]) + +@php + $linkClass = match ($variant) { + 'dark' => 'block rounded-md px-3 py-2 text-sm text-slate-200 hover:bg-slate-800', + 'sheet' => 'block rounded-xl px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100', + default => 'block rounded-lg px-3 py-2 text-sm text-slate-700 hover:bg-slate-100', + }; + + $logoutClass = match ($variant) { + 'dark' => 'w-full rounded-md px-3 py-2 text-left text-sm text-rose-300 hover:bg-rose-950/40', + 'sheet' => 'w-full rounded-xl px-4 py-3 text-left text-sm font-medium text-rose-600 transition hover:bg-rose-50', + default => 'w-full rounded-lg px-3 py-2 text-left text-sm text-rose-600 hover:bg-rose-50', + }; + + $logoutWrapperClass = match ($variant) { + 'sheet' => 'border-t border-slate-100 pt-2', + 'dark' => '', + default => '', + }; + + $dividerClass = match ($variant) { + 'dark' => 'my-1 border-t border-slate-800', + default => 'my-1 border-t border-slate-100', + }; + + $containerClass = match ($variant) { + 'dark' => 'mt-2 rounded-lg border border-slate-800 bg-slate-900 p-1', + 'sheet' => 'space-y-1 p-2 pb-4', + default => 'p-1', + }; +@endphp + +
merge(['class' => $containerClass]) }}> + @if ($showUser && $user) +
+

{{ $user->name ?? 'Your account' }}

+

{{ $user->email }}

+
+
+ @endif + + @foreach ($items as $item) + @if (($item['type'] ?? 'link') === 'link') + + {{ $item['label'] }} + + @elseif (($item['type'] ?? '') === 'wallet') + @includeIf('partials.wallet-widget', [ + 'onNavigate' => $onNavigate, + 'class' => $variant === 'sheet' ? 'mx-2' : 'mx-1', + ]) + @elseif (($item['type'] ?? '') === 'logout') + @if ($variant !== 'sheet') +
+ @endif +
+ @csrf + +
+ @endif + @endforeach +
diff --git a/resources/views/partials/wallet-topup-modal.blade.php b/resources/views/partials/wallet-topup-modal.blade.php new file mode 100644 index 0000000..b460469 --- /dev/null +++ b/resources/views/partials/wallet-topup-modal.blade.php @@ -0,0 +1,24 @@ +@props(['openOnLoad' => false, 'suggested' => 10, 'min' => 1, 'returnUrl' => null]) + + +
+

Top up your wallet

+

Add funds to your Ladill wallet to continue.

+
+ +
+ @csrf + + + + +

You'll be taken to Paystack to pay securely; funds land in your Ladill wallet.

+ +
+ + +
+
+
diff --git a/resources/views/partials/wallet-widget.blade.php b/resources/views/partials/wallet-widget.blade.php new file mode 100644 index 0000000..095b2f4 --- /dev/null +++ b/resources/views/partials/wallet-widget.blade.php @@ -0,0 +1,30 @@ +{{-- Wallet balance peek (links to the account wallet on account.ladill.com). --}} +@php + $onNavigate = $onNavigate ?? null; + $class = trim((string) ($class ?? 'mx-1')); + $balanceRoute = (string) config('billing.wallet_balance_route', 'wallet.balance'); + $balanceUrl = \Illuminate\Support\Facades\Route::has($balanceRoute) ? route($balanceRoute) : null; + $walletUrl = \Illuminate\Support\Facades\Route::has('user.wallet.index') + ? route('user.wallet.index') + : (function_exists('ladill_account_url') ? ladill_account_url('/wallet') : '#'); +@endphp +@if ($balanceUrl) + + + + + + + + + Wallet balance + + + + + +@endif diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..3b4472d --- /dev/null +++ b/routes/api.php @@ -0,0 +1,23 @@ + response()->json(['status' => 'ok', 'app' => 'meet'])); + +Route::post('/service-events', ServiceEventController::class)->name('api.service-events'); + +Route::middleware(['auth.service:meet'])->prefix('service/v1')->group(function () { + Route::post('/rooms', [ServiceRoomController::class, 'store'])->name('api.service.rooms.store'); + Route::get('/rooms/{room}', [ServiceRoomController::class, 'show'])->name('api.service.rooms.show'); + Route::post('/rooms/{room}/start', [ServiceRoomController::class, 'start'])->name('api.service.rooms.start'); +}); + +Route::middleware(['auth:sanctum', 'meet.setup'])->prefix('v1')->group(function () { + Route::get('/rooms', [RoomController::class, 'index'])->name('api.rooms.index'); + Route::post('/rooms', [RoomController::class, 'store'])->name('api.rooms.store'); + Route::get('/rooms/{room}', [RoomController::class, 'show'])->name('api.rooms.show'); + Route::post('/rooms/{room}/start', [RoomController::class, 'start'])->name('api.rooms.start'); +}); diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..0cc9cfd --- /dev/null +++ b/routes/console.php @@ -0,0 +1,5 @@ +everyFiveMinutes(); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..317bcfb --- /dev/null +++ b/routes/web.php @@ -0,0 +1,175 @@ + auth()->check() + ? redirect()->route('meet.dashboard') + : redirect()->route('sso.connect'))->name('meet.root'); + +Route::get('/login', [SsoLoginController::class, 'connect'])->name('login'); +Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect'); +Route::get('/sso/callback', [SsoLoginController::class, 'callback'])->name('sso.callback'); +Route::get('/sso/error', [SsoLoginController::class, 'failed'])->name('sso.failed'); +Route::post('/logout', [SsoLoginController::class, 'logout'])->name('logout'); +Route::get('/sso/logout-bridge', [SsoLoginController::class, 'logoutBridge'])->name('sso.logout-bridge'); +Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannelLogout'])->name('sso.logout-frontchannel'); +Route::get('/sso/platform-signed-out', [SsoLoginController::class, 'platformSignedOut'])->name('sso.platform-signed-out'); +Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('meet.dashboard') : view('auth.signed-out'))->name('meet.signed-out'); + +Route::get('/r/{room}', [JoinController::class, 'show'])->name('meet.join'); +Route::post('/r/{room}/passcode', [JoinController::class, 'verifyPasscode'])->name('meet.join.passcode'); +Route::post('/r/{room}/enter', [JoinController::class, 'enter'])->name('meet.join.enter'); + +Route::match(['get', 'post'], '/invite/{invitation}/rsvp', [InvitationController::class, 'rsvp'])->name('meet.invitations.rsvp'); + +Route::get('/w/{room}/register', [WebinarRegistrationController::class, 'show'])->name('meet.webinar.register'); +Route::post('/w/{room}/register', [WebinarRegistrationController::class, 'store'])->name('meet.webinar.register.store'); + +Route::middleware(['auth', 'platform.session'])->group(function () { + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread'); + Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read'); + Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read'); + + Route::get('/onboarding', [OnboardingController::class, 'show'])->name('meet.onboarding.show'); + Route::post('/onboarding', [OnboardingController::class, 'store'])->name('meet.onboarding.store'); + + Route::middleware(['meet.setup'])->group(function () { + Route::get('/dashboard', [DashboardController::class, 'index'])->name('meet.dashboard'); + + Route::get('/meetings/instant', [RoomController::class, 'instant'])->name('meet.instant'); + Route::get('/meetings/personal', [RoomController::class, 'personal'])->name('meet.personal'); + Route::get('/meetings', [RoomController::class, 'index'])->name('meet.rooms.index'); + Route::get('/meetings/create', [RoomController::class, 'create'])->name('meet.rooms.create'); + Route::post('/meetings', [RoomController::class, 'store'])->name('meet.rooms.store'); + Route::get('/meetings/{room}', [RoomController::class, 'show'])->name('meet.rooms.show'); + Route::post('/meetings/{room}/start', [RoomController::class, 'start'])->name('meet.rooms.start'); + Route::post('/meetings/{room}/cancel', [RoomController::class, 'cancel'])->name('meet.rooms.cancel'); + Route::get('/meetings/{room}/ical', [RoomController::class, 'ical'])->name('meet.rooms.ical'); + Route::get('/meetings/{room}/qr', [RoomController::class, 'qr'])->name('meet.rooms.qr'); + Route::get('/meetings/{room}/attendance', [RoomController::class, 'attendance'])->name('meet.rooms.attendance'); + Route::get('/meetings/{room}/webinar/registrations', [WebinarRegistrationController::class, 'manage'])->name('meet.webinar.registrations'); + Route::post('/meetings/{room}/webinar/registrations/{registration}/approve', [WebinarRegistrationController::class, 'approve'])->name('meet.webinar.registrations.approve'); + Route::post('/meetings/{room}/webinar/registrations/{registration}/reject', [WebinarRegistrationController::class, 'reject'])->name('meet.webinar.registrations.reject'); + Route::get('/meetings/{room}/invitations', [InvitationController::class, 'index'])->name('meet.invitations.index'); + Route::post('/meetings/{room}/invitations', [InvitationController::class, 'store'])->name('meet.invitations.store'); + Route::post('/meetings/{room}/invitations/csv', [InvitationController::class, 'bulkCsv'])->name('meet.invitations.bulk'); + Route::post('/invitations/{invitation}/resend', [InvitationController::class, 'resend'])->name('meet.invitations.resend'); + Route::delete('/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('meet.invitations.destroy'); + Route::get('/contacts/search', [InvitationController::class, 'searchContacts'])->name('meet.contacts.search'); + + Route::get('/reports', [ReportController::class, 'index'])->name('meet.reports.index'); + Route::get('/reports/{type}', [ReportController::class, 'show'])->name('meet.reports.show'); + Route::get('/reports/{type}/export', [ReportController::class, 'export'])->name('meet.reports.export'); + + Route::get('/contacts/groups', [ContactGroupController::class, 'index'])->name('meet.contacts.groups'); + Route::post('/contacts/groups', [ContactGroupController::class, 'store'])->name('meet.contacts.groups.store'); + + Route::get('/channels', [ChannelController::class, 'index'])->name('meet.channels.index'); + Route::get('/channels/create', [ChannelController::class, 'create'])->name('meet.channels.create'); + Route::post('/channels', [ChannelController::class, 'store'])->name('meet.channels.store'); + Route::get('/channels/{channel}', [ChannelController::class, 'show'])->name('meet.channels.show'); + Route::post('/channels/{channel}/messages', [ChannelController::class, 'postMessage'])->name('meet.channels.messages.store'); + Route::post('/channels/{channel}/messages/{message}/react', [ChannelController::class, 'react'])->name('meet.channels.messages.react'); + Route::get('/workspace/search', [ChannelController::class, 'search'])->name('meet.workspace.search'); + + Route::get('/messages', [DirectMessageController::class, 'index'])->name('meet.messages.index'); + Route::post('/messages/start', [DirectMessageController::class, 'start'])->name('meet.messages.start'); + Route::get('/messages/{conversation}', [DirectMessageController::class, 'show'])->name('meet.messages.show'); + Route::post('/messages/{conversation}', [DirectMessageController::class, 'postMessage'])->name('meet.messages.store'); + + Route::get('/admin/usage', [AdminController::class, 'usage'])->name('meet.admin.usage'); + + Route::get('/settings/calendar', [CalendarController::class, 'settings'])->name('meet.settings.calendar'); + Route::post('/settings/calendar/mail', [CalendarController::class, 'connectMail'])->name('meet.settings.calendar.mail'); + Route::get('/settings/calendar/connect/{provider}', [CalendarController::class, 'connect'])->name('meet.settings.calendar.connect'); + Route::get('/settings/calendar/callback', [CalendarController::class, 'callback'])->name('meet.settings.calendar.callback'); + Route::get('/calendar/availability', [CalendarController::class, 'availability'])->name('meet.calendar.availability'); + + Route::get('/settings/webhooks', [WebhookSettingsController::class, 'index'])->name('meet.settings.webhooks'); + Route::post('/settings/webhooks', [WebhookSettingsController::class, 'store'])->name('meet.settings.webhooks.store'); + Route::delete('/settings/webhooks/{webhook}', [WebhookSettingsController::class, 'destroy'])->name('meet.settings.webhooks.destroy'); + + Route::get('/recordings', [RecordingController::class, 'index'])->name('meet.recordings.index'); + Route::get('/recordings/{recording}', [RecordingController::class, 'show'])->name('meet.recordings.show'); + Route::get('/recordings/{recording}/download', [RecordingController::class, 'download'])->name('meet.recordings.download'); + Route::delete('/recordings/{recording}', [RecordingController::class, 'destroy'])->name('meet.recordings.destroy'); + + Route::get('/summaries/{summary}', [SummaryController::class, 'show'])->name('meet.summaries.show'); + + Route::get('/templates', [TemplateController::class, 'index'])->name('meet.templates.index'); + Route::get('/templates/create', [TemplateController::class, 'create'])->name('meet.templates.create'); + Route::post('/templates', [TemplateController::class, 'store'])->name('meet.templates.store'); + + Route::get('/settings/security', [SettingsController::class, 'security'])->name('meet.settings.security'); + Route::put('/settings/security', [SettingsController::class, 'updateSecurity'])->name('meet.settings.security.update'); + + Route::get('/room/{session}', [MeetingRoomController::class, 'show'])->name('meet.room'); + Route::post('/room/{session}/end', [MeetingRoomController::class, 'end'])->name('meet.room.end'); + Route::post('/room/{session}/leave', [MeetingRoomController::class, 'leave'])->name('meet.room.leave'); + Route::post('/room/{session}/raise-hand', [MeetingRoomController::class, 'raiseHand'])->name('meet.room.raise-hand'); + Route::post('/room/{session}/chat', [MeetingRoomController::class, 'sendMessage'])->name('meet.room.chat'); + Route::post('/room/{session}/reaction', [MeetingRoomController::class, 'sendReaction'])->name('meet.room.reaction'); + Route::get('/room/{session}/poll', [MeetingRoomController::class, 'poll'])->name('meet.room.poll'); + Route::post('/room/{session}/recording/start', [MeetingRoomController::class, 'startRecording'])->name('meet.room.recording.start'); + Route::post('/room/{session}/recording/stop', [MeetingRoomController::class, 'stopRecording'])->name('meet.room.recording.stop'); + Route::post('/room/{session}/lock', [MeetingRoomController::class, 'lock'])->name('meet.room.lock'); + Route::post('/room/{session}/unlock', [MeetingRoomController::class, 'unlock'])->name('meet.room.unlock'); + Route::post('/room/{session}/caption', [MeetingRoomController::class, 'caption'])->name('meet.room.caption'); + + Route::post('/room/{session}/qa', [MeetingFeaturesController::class, 'submitQuestion'])->name('meet.room.qa.submit'); + Route::post('/room/{session}/qa/{question}/approve', [MeetingFeaturesController::class, 'approveQuestion'])->name('meet.room.qa.approve'); + Route::post('/room/{session}/qa/{question}/answer', [MeetingFeaturesController::class, 'answerQuestion'])->name('meet.room.qa.answer'); + Route::post('/room/{session}/qa/{question}/upvote', [MeetingFeaturesController::class, 'upvoteQuestion'])->name('meet.room.qa.upvote'); + Route::post('/room/{session}/polls', [MeetingFeaturesController::class, 'createPoll'])->name('meet.room.polls.create'); + Route::post('/room/{session}/polls/{poll}/vote', [MeetingFeaturesController::class, 'votePoll'])->name('meet.room.polls.vote'); + Route::post('/room/{session}/polls/{poll}/close', [MeetingFeaturesController::class, 'closePoll'])->name('meet.room.polls.close'); + Route::post('/room/{session}/breakouts', [MeetingFeaturesController::class, 'createBreakouts'])->name('meet.room.breakouts.create'); + Route::post('/room/{session}/breakouts/{breakout}/join', [MeetingFeaturesController::class, 'moveToBreakout'])->name('meet.room.breakouts.join'); + Route::post('/room/{session}/breakouts/return', [MeetingFeaturesController::class, 'returnFromBreakout'])->name('meet.room.breakouts.return'); + Route::post('/room/{session}/breakouts/broadcast', [MeetingFeaturesController::class, 'broadcastBreakout'])->name('meet.room.breakouts.broadcast'); + Route::post('/room/{session}/breakouts/close', [MeetingFeaturesController::class, 'closeBreakouts'])->name('meet.room.breakouts.close'); + Route::post('/room/{session}/files', [MeetingFeaturesController::class, 'uploadFile'])->name('meet.room.files.upload'); + Route::get('/room/{session}/files/{file}', [MeetingFeaturesController::class, 'downloadFile'])->name('meet.room.files.download'); + Route::get('/room/{session}/whiteboard', [MeetingFeaturesController::class, 'showWhiteboard'])->name('meet.room.whiteboard.show'); + Route::post('/room/{session}/whiteboard', [MeetingFeaturesController::class, 'saveWhiteboard'])->name('meet.room.whiteboard.save'); + Route::post('/room/{session}/whiteboard/export', [MeetingFeaturesController::class, 'exportWhiteboard'])->name('meet.room.whiteboard.export'); + + Route::post('/room/{session}/live-stream', [MeetingFeaturesController::class, 'configureLiveStream'])->name('meet.room.livestream.configure'); + Route::post('/room/{session}/live-stream/start', [MeetingFeaturesController::class, 'startLiveStream'])->name('meet.room.livestream.start'); + Route::post('/room/{session}/live-stream/stop', [MeetingFeaturesController::class, 'stopLiveStream'])->name('meet.room.livestream.stop'); + Route::post('/room/{session}/town-hall/green-room', [MeetingFeaturesController::class, 'startGreenRoom'])->name('meet.room.townhall.green'); + Route::post('/room/{session}/town-hall/go-live', [MeetingFeaturesController::class, 'goLiveTownHall'])->name('meet.room.townhall.live'); + Route::post('/room/{session}/town-hall/handoff', [MeetingFeaturesController::class, 'handoffCoHost'])->name('meet.room.townhall.handoff'); + + Route::get('/members', [MemberController::class, 'index'])->name('meet.members.index'); + Route::get('/members/create', [MemberController::class, 'create'])->name('meet.members.create'); + Route::post('/members', [MemberController::class, 'store'])->name('meet.members.store'); + Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('meet.members.destroy'); + + Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('meet.wallet'); + Route::get('/team', fn () => redirect()->away(ladill_account_url('/account/team')))->name('meet.team'); + }); +}); diff --git a/tests/Feature/MeetWebTest.php b/tests/Feature/MeetWebTest.php new file mode 100644 index 0000000..3c36b9f --- /dev/null +++ b/tests/Feature/MeetWebTest.php @@ -0,0 +1,343 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'test-user-001', + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Test Org', + 'slug' => 'test-org', + 'timezone' => 'UTC', + 'settings' => ['onboarded' => true], + ]); + + Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'owner', + ]); + } + + public function test_health_endpoint(): void + { + $this->getJson('/api/health') + ->assertOk() + ->assertJson(['status' => 'ok', 'app' => 'meet']); + } + + public function test_guest_redirected_from_dashboard(): void + { + $this->get('/dashboard')->assertRedirect(); + } + + public function test_onboarded_user_can_view_dashboard(): void + { + $this->actingAs($this->user) + ->get('/dashboard') + ->assertOk(); + } + + public function test_user_can_create_instant_meeting(): void + { + $this->actingAs($this->user) + ->get('/meetings/instant') + ->assertRedirect(); + + $this->assertDatabaseHas('meet_rooms', [ + 'host_user_ref' => $this->user->public_id, + 'type' => 'instant', + ]); + } + + public function test_room_has_join_url(): void + { + $room = Room::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'host_user_ref' => $this->user->public_id, + 'title' => 'Test Meeting', + 'type' => 'instant', + 'status' => 'scheduled', + 'scheduled_at' => now(), + 'timezone' => 'UTC', + 'settings' => config('meet.default_settings'), + ]); + + $this->assertStringContainsString($room->uuid, $room->joinUrl()); + } + + public function test_user_can_view_recordings_index(): void + { + $room = $this->createRoom(); + $session = Session::create([ + 'owner_ref' => $this->user->public_id, + 'room_id' => $room->id, + 'media_room_name' => 'room-'.$room->uuid, + 'status' => 'ended', + 'started_at' => now()->subHour(), + 'ended_at' => now(), + ]); + Recording::create([ + 'owner_ref' => $this->user->public_id, + 'session_id' => $session->id, + 'status' => 'ready', + 'storage_path' => 'recordings/test.mp4', + ]); + + $this->actingAs($this->user) + ->get('/recordings') + ->assertOk() + ->assertSee('Recordings'); + } + + public function test_admin_can_create_template(): void + { + $this->actingAs($this->user) + ->post('/templates', [ + 'name' => 'Weekly standup', + 'duration_minutes' => 30, + 'waiting_room' => '1', + ]) + ->assertRedirect(route('meet.templates.index')); + + $this->assertDatabaseHas('meet_templates', [ + 'name' => 'Weekly standup', + 'organization_id' => $this->organization->id, + ]); + } + + public function test_admin_can_update_security_policy(): void + { + $this->actingAs($this->user) + ->put('/settings/security', [ + 'org_only' => '1', + 'authenticated_only' => '1', + 'allowed_domains' => 'example.com, ladill.com', + 'recording_host_only' => '1', + 'session_idle_minutes' => 90, + ]) + ->assertRedirect(route('meet.settings.security')); + + $this->organization->refresh(); + $this->assertTrue($this->organization->securitySetting('org_only')); + $this->assertContains('example.com', $this->organization->securitySetting('allowed_domains')); + } + + public function test_user_can_access_personal_room(): void + { + $this->actingAs($this->user) + ->get('/meetings/personal') + ->assertRedirect(); + + $this->assertDatabaseHas('meet_rooms', [ + 'host_user_ref' => $this->user->public_id, + 'type' => 'personal', + ]); + } + + public function test_admin_can_view_reports(): void + { + $this->actingAs($this->user) + ->get('/reports') + ->assertOk() + ->assertSee('Reports'); + } + + public function test_service_api_can_create_room(): void + { + config(['meet.service_api_keys.care' => 'test-care-key']); + + $this->postJson('/api/service/v1/rooms', [ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'host_user_ref' => $this->user->public_id, + 'title' => 'Care consultation', + 'source' => ['app' => 'care', 'entity_type' => 'appointment', 'entity_id' => 'appt-123'], + ], ['Authorization' => 'Bearer test-care-key']) + ->assertCreated() + ->assertJsonPath('room.title', 'Care consultation'); + + $this->assertDatabaseHas('meet_rooms', [ + 'title' => 'Care consultation', + 'host_user_ref' => $this->user->public_id, + ]); + } + + public function test_invitation_rsvp_accepts(): void + { + $room = $this->createRoom(); + $invitation = \App\Models\Invitation::create([ + 'owner_ref' => $this->user->public_id, + 'room_id' => $room->id, + 'email' => 'guest@example.com', + 'status' => 'pending', + 'rsvp_token' => 'test-rsvp-token', + ]); + + $this->post('/invite/'.$invitation->uuid.'/rsvp?token=test-rsvp-token', [ + 'status' => 'accepted', + ])->assertOk(); + + $this->assertDatabaseHas('meet_invitations', [ + 'id' => $invitation->id, + 'status' => 'accepted', + ]); + } + + public function test_admin_can_add_webhook_endpoint(): void + { + $this->actingAs($this->user) + ->post('/settings/webhooks', [ + 'url' => 'https://care.example.com/webhooks/meet', + 'secret' => 'whsec_test', + 'events' => ['meeting.started', 'meeting.ended'], + ]) + ->assertRedirect(route('meet.settings.webhooks')); + + $this->assertDatabaseHas('meet_webhook_endpoints', [ + 'organization_id' => $this->organization->id, + 'url' => 'https://care.example.com/webhooks/meet', + ]); + } + + public function test_webinar_registration_flow(): void + { + $room = $this->createRoom([ + 'type' => 'webinar', + 'settings' => array_merge(config('meet.default_settings'), ['webinar_auto_approve' => true]), + ]); + + $this->post('/w/'.$room->uuid.'/register', [ + 'email' => 'attendee@example.com', + 'display_name' => 'Attendee One', + ])->assertOk(); + + $this->assertDatabaseHas('meet_webinar_registrations', [ + 'room_id' => $room->id, + 'email' => 'attendee@example.com', + 'status' => 'approved', + ]); + } + + public function test_host_can_create_webinar_room(): void + { + $this->actingAs($this->user) + ->post('/meetings', [ + 'title' => 'Product launch', + 'type' => 'webinar', + 'scheduled_at' => now()->addDay()->format('Y-m-d\TH:i'), + 'duration_minutes' => 60, + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('meet_rooms', [ + 'title' => 'Product launch', + 'type' => 'webinar', + ]); + } + + public function test_user_can_create_channel_and_post_message(): void + { + $this->actingAs($this->user) + ->post('/channels', [ + 'name' => 'General', + 'visibility' => 'public', + ]) + ->assertRedirect(); + + $channel = \App\Models\Channel::first(); + $this->assertNotNull($channel); + + $this->actingAs($this->user) + ->post('/channels/'.$channel->uuid.'/messages', ['body' => 'Hello team']) + ->assertRedirect(); + + $this->assertDatabaseHas('meet_channel_messages', [ + 'channel_id' => $channel->id, + 'body' => 'Hello team', + ]); + } + + public function test_admin_can_view_usage_dashboard(): void + { + $this->actingAs($this->user) + ->get('/admin/usage') + ->assertOk() + ->assertSee('Usage'); + } + + public function test_mail_calendar_can_be_connected(): void + { + config(['meet.calendar.mail.api_key' => 'test-mail-key']); + + $this->actingAs($this->user) + ->post('/settings/calendar/mail') + ->assertRedirect(route('meet.settings.calendar')); + + $this->assertDatabaseHas('meet_calendar_connections', [ + 'user_ref' => $this->user->public_id, + 'provider' => 'mail', + ]); + } + + public function test_host_can_create_town_hall_room(): void + { + $this->actingAs($this->user) + ->post('/meetings', [ + 'title' => 'All hands', + 'type' => 'town_hall', + 'scheduled_at' => now()->addDay()->format('Y-m-d\TH:i'), + 'duration_minutes' => 90, + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('meet_rooms', [ + 'title' => 'All hands', + 'type' => 'town_hall', + ]); + } + + protected function createRoom(array $overrides = []): Room + { + return Room::create(array_merge([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'host_user_ref' => $this->user->public_id, + 'title' => 'Test Meeting', + 'type' => 'scheduled', + 'status' => 'scheduled', + 'scheduled_at' => now()->addDay(), + 'timezone' => 'UTC', + 'settings' => config('meet.default_settings'), + ], $overrides)); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..eece6d4 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,14 @@ +withoutVite(); + } +} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php new file mode 100644 index 0000000..5773b0c --- /dev/null +++ b/tests/Unit/ExampleTest.php @@ -0,0 +1,16 @@ +assertTrue(true); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..50add8d --- /dev/null +++ b/vite.config.js @@ -0,0 +1,18 @@ +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', 'resources/js/meet-room.js'], + refresh: true, + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +});