From b33b217bdb0ab70e35f520b154770228e7339713 Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 21 Jul 2026 23:39:19 +0200 Subject: [PATCH] v1.0.0 --- .gitignore | 3 + Dockerfile | 27 + README.md | 116 + install.md | 356 + src/.editorconfig | 18 + src/.env.example | 68 + src/.gitattributes | 11 + src/.gitignore | 31 + src/.npmrc | 2 + src/README.md | 58 + src/app/Console/Commands/CheckSlaBreaches.php | 42 + .../Controllers/Api/CategoryController.php | 37 + .../Controllers/Api/PriorityController.php | 34 + .../Http/Controllers/Api/StatusController.php | 34 + .../Http/Controllers/Api/TeamController.php | 34 + .../Http/Controllers/Api/TicketController.php | 215 + .../Api/TicketMessageController.php | 93 + .../Http/Controllers/Api/UserController.php | 82 + src/app/Http/Controllers/Controller.php | 23 + src/app/Http/Middleware/EnsureRole.php | 21 + .../Api/StoreTicketMessageRequest.php | 35 + .../Http/Requests/Api/StoreTicketRequest.php | 38 + .../Http/Requests/Api/UpdateTicketRequest.php | 27 + src/app/Http/Resources/CategoryResource.php | 29 + .../Http/Resources/CustomFieldResource.php | 33 + src/app/Http/Resources/PriorityResource.php | 29 + src/app/Http/Resources/StatusResource.php | 31 + .../Http/Resources/SubcategoryResource.php | 31 + src/app/Http/Resources/TeamResource.php | 25 + .../Resources/TicketAttachmentResource.php | 32 + .../Http/Resources/TicketMessageResource.php | 41 + src/app/Http/Resources/TicketResource.php | 86 + src/app/Http/Resources/UserResource.php | 37 + src/app/Ldap/Handlers/AssignDefaultRole.php | 21 + .../Ldap/Handlers/SyncUserFieldsFromLdap.php | 31 + src/app/Ldap/LldapUser.php | 33 + src/app/Livewire/Admin/ApiKeys.php | 120 + src/app/Livewire/Admin/Panel.php | 1402 +++ src/app/Livewire/Auth/Login.php | 70 + src/app/Livewire/Client/Dashboard.php | 44 + src/app/Livewire/Client/NewTicket.php | 123 + src/app/Livewire/Client/TicketShow.php | 138 + src/app/Livewire/Landing.php | 159 + src/app/Livewire/Operator/NewTicket.php | 138 + src/app/Livewire/Operator/Queue.php | 320 + src/app/Livewire/Operator/Stats.php | 394 + src/app/Livewire/Operator/TicketShow.php | 503 + src/app/Models/ApiClient.php | 42 + src/app/Models/Category.php | 16 + src/app/Models/CustomField.php | 38 + src/app/Models/EmailTemplate.php | 26 + src/app/Models/NotificationSetting.php | 21 + src/app/Models/Priority.php | 32 + src/app/Models/ReplyQuickAction.php | 15 + src/app/Models/ResponseTemplate.php | 12 + src/app/Models/Role.php | 16 + src/app/Models/Setting.php | 16 + src/app/Models/SlaRule.php | 16 + src/app/Models/Status.php | 46 + src/app/Models/Subcategory.php | 40 + src/app/Models/Team.php | 27 + src/app/Models/Ticket.php | 337 + src/app/Models/TicketAttachment.php | 21 + src/app/Models/TicketHistory.php | 25 + src/app/Models/TicketMessage.php | 89 + src/app/Models/TicketMessageAuthor.php | 33 + src/app/Models/User.php | 223 + src/app/Models/UserField.php | 30 + src/app/Models/UserFieldValue.php | 21 + src/app/Notifications/TicketNotification.php | 50 + src/app/Providers/AppServiceProvider.php | 169 + src/app/Services/LdapUserProvisioner.php | 127 + src/app/Services/TicketService.php | 288 + src/app/Support/Rel.php | 38 + src/app/Support/Settings.php | 297 + src/artisan | 18 + src/bootstrap/app.php | 46 + src/bootstrap/cache/.gitignore | 2 + src/bootstrap/providers.php | 7 + src/composer.json | 97 + src/composer.lock | 10447 ++++++++++++++++ src/config/app.php | 138 + src/config/auth.php | 142 + src/config/cache.php | 136 + src/config/database.php | 184 + src/config/filesystems.php | 80 + src/config/l5-swagger.php | 284 + src/config/ldap.php | 81 + src/config/livewire.php | 282 + src/config/logging.php | 132 + src/config/mail.php | 118 + src/config/queue.php | 129 + src/config/sanctum.php | 87 + src/config/services.php | 38 + src/config/session.php | 233 + src/database/.gitignore | 1 + src/database/factories/ApiClientFactory.php | 22 + src/database/factories/UserFactory.php | 46 + .../0001_01_01_000000_create_users_table.php | 45 + .../0001_01_01_000001_create_cache_table.php | 29 + .../0001_01_01_000002_create_jobs_table.php | 53 + .../2024_01_01_000010_create_roles_table.php | 30 + .../2024_01_01_000020_create_teams_table.php | 29 + ...4_01_01_000030_create_categories_table.php | 40 + ...1_01_000040_create_custom_fields_table.php | 34 + ..._01_01_000050_create_user_fields_table.php | 38 + ..._create_statuses_and_priorities_tables.php | 46 + ...0_create_templates_and_settings_tables.php | 41 + ...080_create_notification_settings_table.php | 26 + ...00090_create_reply_quick_actions_table.php | 28 + ..._01_01_000100_create_api_clients_table.php | 25 + ...2024_01_01_000110_create_tickets_table.php | 39 + ...01_000120_create_ticket_messages_table.php | 57 + ...30_create_personal_access_tokens_table.php | 27 + src/database/seeders/DatabaseSeeder.php | 445 + src/package-lock.json | 1614 +++ src/package.json | 16 + src/phpunit.xml | 36 + src/public/.htaccess | 25 + src/public/branding/default-mark.svg | 6 + src/public/favicon.ico | 0 src/public/index.php | 20 + src/public/pwa-icons/icon-192-maskable.png | Bin 0 -> 2714 bytes src/public/pwa-icons/icon-192.png | Bin 0 -> 4475 bytes src/public/pwa-icons/icon-512-maskable.png | Bin 0 -> 7928 bytes src/public/pwa-icons/icon-512.png | Bin 0 -> 12308 bytes src/public/robots.txt | 2 + src/public/sw.js | 48 + src/resources/css/app.css | 450 + src/resources/js/app.js | 1 + .../components/custom-field-input.blade.php | 34 + .../views/components/login-notice.blade.php | 17 + .../components/message-attachment.blade.php | 9 + .../views/components/multiselect.blade.php | 40 + .../views/components/profile-menu.blade.php | 53 + .../views/components/quill-editor.blade.php | 74 + .../views/components/theme-toggle.blade.php | 35 + .../views/components/topbar.blade.php | 22 + .../emails/ticket-notification.blade.php | 11 + src/resources/views/layouts/app.blade.php | 82 + .../views/livewire/admin/api-keys.blade.php | 114 + .../views/livewire/admin/panel.blade.php | 1004 ++ .../views/livewire/auth/login.blade.php | 27 + .../views/livewire/client/dashboard.blade.php | 34 + .../livewire/client/new-ticket.blade.php | 96 + .../livewire/client/ticket-show.blade.php | 137 + .../views/livewire/landing.blade.php | 128 + .../livewire/operator/new-ticket.blade.php | 102 + .../views/livewire/operator/queue.blade.php | 167 + .../views/livewire/operator/stats.blade.php | 217 + .../livewire/operator/ticket-show.blade.php | 401 + .../views/vendor/l5-swagger/.gitkeep | 0 .../views/vendor/l5-swagger/index.blade.php | 174 + src/resources/views/welcome.blade.php | 223 + src/routes/api.php | 36 + src/routes/console.php | 11 + src/routes/web.php | 75 + src/storage/api-docs/api-docs.json | 1191 ++ src/storage/app/.gitignore | 4 + src/storage/app/private/.gitignore | 2 + src/storage/app/public/.gitignore | 2 + src/storage/framework/.gitignore | 9 + src/storage/framework/cache/.gitignore | 3 + src/storage/framework/cache/data/.gitignore | 2 + src/storage/framework/sessions/.gitignore | 2 + src/storage/framework/testing/.gitignore | 2 + src/storage/framework/views/.gitignore | 2 + src/storage/logs/.gitignore | 2 + src/tests/Feature/AdminBrandingTest.php | 131 + .../Feature/AdminCategoryManagementTest.php | 47 + .../AdminStatusPriorityReorderTest.php | 58 + src/tests/Feature/AdminTeamManagementTest.php | 113 + src/tests/Feature/AdminUserManagementTest.php | 60 + .../Feature/ApiAbilityEnforcementTest.php | 43 + src/tests/Feature/ApiKeyManagementTest.php | 79 + src/tests/Feature/AttachmentLimitsTest.php | 123 + src/tests/Feature/DictionariesApiTest.php | 61 + src/tests/Feature/EmailLayoutTest.php | 94 + .../Feature/EmailNotificationsAdminTest.php | 80 + src/tests/Feature/ExampleTest.php | 7 + .../ExtendedNotificationTriggersTest.php | 123 + .../LdapAccountCreationTogglesTest.php | 106 + src/tests/Feature/LdapLoginTest.php | 65 + ...estrictedGuestTicketsAndLocalLoginTest.php | 173 + src/tests/Feature/MailSmtpConfigTest.php | 111 + src/tests/Feature/MessageAttachmentsTest.php | 145 + src/tests/Feature/OperatorNewTicketTest.php | 44 + .../Feature/OperatorQueueBulkActionsTest.php | 75 + .../Feature/OperatorQueueClosedTabTest.php | 64 + .../OperatorQueueSearchSortColumnsTest.php | 72 + src/tests/Feature/OperatorTeamScopingTest.php | 137 + .../Feature/OperatorTicketDetailsEditTest.php | 47 + ...rityStatusReplyQuickActionKeyOrderTest.php | 103 + src/tests/Feature/ReplyQuickActionsTest.php | 111 + .../Feature/ResponseTemplatesAdminTest.php | 66 + src/tests/Feature/RoleMiddlewareTest.php | 46 + .../RolesFieldValuesAndMessageAuthorTest.php | 147 + .../Feature/SessionLifetimeConfigTest.php | 26 + .../Feature/SlaBreachNotificationTest.php | 95 + src/tests/Feature/SmokeTest.php | 34 + .../Feature/StatusStageRestructureTest.php | 68 + .../SubcategoryDefaultPriorityTest.php | 66 + src/tests/Feature/TicketApiTest.php | 180 + .../TicketBodyAndAttachmentDisplayTest.php | 71 + src/tests/Feature/TicketBusinessRulesTest.php | 130 + src/tests/Feature/TicketMessageApiTest.php | 84 + src/tests/Feature/TicketTimeTrackingTest.php | 202 + src/tests/Feature/TimezoneConfigTest.php | 48 + src/tests/Feature/UserFieldsTest.php | 185 + src/tests/Feature/UsersApiTest.php | 66 + src/tests/Pest.php | 50 + src/tests/TestCase.php | 18 + src/tests/Unit/ExampleTest.php | 5 + src/vite.config.js | 24 + wiki/admin/README.md | 115 + wiki/client/README.md | 55 + wiki/operator/README.md | 93 + 217 files changed, 32076 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 install.md create mode 100644 src/.editorconfig create mode 100644 src/.env.example create mode 100644 src/.gitattributes create mode 100644 src/.gitignore create mode 100644 src/.npmrc create mode 100644 src/README.md create mode 100644 src/app/Console/Commands/CheckSlaBreaches.php create mode 100644 src/app/Http/Controllers/Api/CategoryController.php create mode 100644 src/app/Http/Controllers/Api/PriorityController.php create mode 100644 src/app/Http/Controllers/Api/StatusController.php create mode 100644 src/app/Http/Controllers/Api/TeamController.php create mode 100644 src/app/Http/Controllers/Api/TicketController.php create mode 100644 src/app/Http/Controllers/Api/TicketMessageController.php create mode 100644 src/app/Http/Controllers/Api/UserController.php create mode 100644 src/app/Http/Controllers/Controller.php create mode 100644 src/app/Http/Middleware/EnsureRole.php create mode 100644 src/app/Http/Requests/Api/StoreTicketMessageRequest.php create mode 100644 src/app/Http/Requests/Api/StoreTicketRequest.php create mode 100644 src/app/Http/Requests/Api/UpdateTicketRequest.php create mode 100644 src/app/Http/Resources/CategoryResource.php create mode 100644 src/app/Http/Resources/CustomFieldResource.php create mode 100644 src/app/Http/Resources/PriorityResource.php create mode 100644 src/app/Http/Resources/StatusResource.php create mode 100644 src/app/Http/Resources/SubcategoryResource.php create mode 100644 src/app/Http/Resources/TeamResource.php create mode 100644 src/app/Http/Resources/TicketAttachmentResource.php create mode 100644 src/app/Http/Resources/TicketMessageResource.php create mode 100644 src/app/Http/Resources/TicketResource.php create mode 100644 src/app/Http/Resources/UserResource.php create mode 100644 src/app/Ldap/Handlers/AssignDefaultRole.php create mode 100644 src/app/Ldap/Handlers/SyncUserFieldsFromLdap.php create mode 100644 src/app/Ldap/LldapUser.php create mode 100644 src/app/Livewire/Admin/ApiKeys.php create mode 100644 src/app/Livewire/Admin/Panel.php create mode 100644 src/app/Livewire/Auth/Login.php create mode 100644 src/app/Livewire/Client/Dashboard.php create mode 100644 src/app/Livewire/Client/NewTicket.php create mode 100644 src/app/Livewire/Client/TicketShow.php create mode 100644 src/app/Livewire/Landing.php create mode 100644 src/app/Livewire/Operator/NewTicket.php create mode 100644 src/app/Livewire/Operator/Queue.php create mode 100644 src/app/Livewire/Operator/Stats.php create mode 100644 src/app/Livewire/Operator/TicketShow.php create mode 100644 src/app/Models/ApiClient.php create mode 100644 src/app/Models/Category.php create mode 100644 src/app/Models/CustomField.php create mode 100644 src/app/Models/EmailTemplate.php create mode 100644 src/app/Models/NotificationSetting.php create mode 100644 src/app/Models/Priority.php create mode 100644 src/app/Models/ReplyQuickAction.php create mode 100644 src/app/Models/ResponseTemplate.php create mode 100644 src/app/Models/Role.php create mode 100644 src/app/Models/Setting.php create mode 100644 src/app/Models/SlaRule.php create mode 100644 src/app/Models/Status.php create mode 100644 src/app/Models/Subcategory.php create mode 100644 src/app/Models/Team.php create mode 100644 src/app/Models/Ticket.php create mode 100644 src/app/Models/TicketAttachment.php create mode 100644 src/app/Models/TicketHistory.php create mode 100644 src/app/Models/TicketMessage.php create mode 100644 src/app/Models/TicketMessageAuthor.php create mode 100644 src/app/Models/User.php create mode 100644 src/app/Models/UserField.php create mode 100644 src/app/Models/UserFieldValue.php create mode 100644 src/app/Notifications/TicketNotification.php create mode 100644 src/app/Providers/AppServiceProvider.php create mode 100644 src/app/Services/LdapUserProvisioner.php create mode 100644 src/app/Services/TicketService.php create mode 100644 src/app/Support/Rel.php create mode 100644 src/app/Support/Settings.php create mode 100644 src/artisan create mode 100644 src/bootstrap/app.php create mode 100644 src/bootstrap/cache/.gitignore create mode 100644 src/bootstrap/providers.php create mode 100644 src/composer.json create mode 100644 src/composer.lock create mode 100644 src/config/app.php create mode 100644 src/config/auth.php create mode 100644 src/config/cache.php create mode 100644 src/config/database.php create mode 100644 src/config/filesystems.php create mode 100644 src/config/l5-swagger.php create mode 100644 src/config/ldap.php create mode 100644 src/config/livewire.php create mode 100644 src/config/logging.php create mode 100644 src/config/mail.php create mode 100644 src/config/queue.php create mode 100644 src/config/sanctum.php create mode 100644 src/config/services.php create mode 100644 src/config/session.php create mode 100644 src/database/.gitignore create mode 100644 src/database/factories/ApiClientFactory.php create mode 100644 src/database/factories/UserFactory.php create mode 100644 src/database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 src/database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 src/database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 src/database/migrations/2024_01_01_000010_create_roles_table.php create mode 100644 src/database/migrations/2024_01_01_000020_create_teams_table.php create mode 100644 src/database/migrations/2024_01_01_000030_create_categories_table.php create mode 100644 src/database/migrations/2024_01_01_000040_create_custom_fields_table.php create mode 100644 src/database/migrations/2024_01_01_000050_create_user_fields_table.php create mode 100644 src/database/migrations/2024_01_01_000060_create_statuses_and_priorities_tables.php create mode 100644 src/database/migrations/2024_01_01_000070_create_templates_and_settings_tables.php create mode 100644 src/database/migrations/2024_01_01_000080_create_notification_settings_table.php create mode 100644 src/database/migrations/2024_01_01_000090_create_reply_quick_actions_table.php create mode 100644 src/database/migrations/2024_01_01_000100_create_api_clients_table.php create mode 100644 src/database/migrations/2024_01_01_000110_create_tickets_table.php create mode 100644 src/database/migrations/2024_01_01_000120_create_ticket_messages_table.php create mode 100644 src/database/migrations/2024_01_01_000130_create_personal_access_tokens_table.php create mode 100644 src/database/seeders/DatabaseSeeder.php create mode 100644 src/package-lock.json create mode 100644 src/package.json create mode 100644 src/phpunit.xml create mode 100644 src/public/.htaccess create mode 100644 src/public/branding/default-mark.svg create mode 100644 src/public/favicon.ico create mode 100644 src/public/index.php create mode 100644 src/public/pwa-icons/icon-192-maskable.png create mode 100644 src/public/pwa-icons/icon-192.png create mode 100644 src/public/pwa-icons/icon-512-maskable.png create mode 100644 src/public/pwa-icons/icon-512.png create mode 100644 src/public/robots.txt create mode 100644 src/public/sw.js create mode 100644 src/resources/css/app.css create mode 100644 src/resources/js/app.js create mode 100644 src/resources/views/components/custom-field-input.blade.php create mode 100644 src/resources/views/components/login-notice.blade.php create mode 100644 src/resources/views/components/message-attachment.blade.php create mode 100644 src/resources/views/components/multiselect.blade.php create mode 100644 src/resources/views/components/profile-menu.blade.php create mode 100644 src/resources/views/components/quill-editor.blade.php create mode 100644 src/resources/views/components/theme-toggle.blade.php create mode 100644 src/resources/views/components/topbar.blade.php create mode 100644 src/resources/views/emails/ticket-notification.blade.php create mode 100644 src/resources/views/layouts/app.blade.php create mode 100644 src/resources/views/livewire/admin/api-keys.blade.php create mode 100644 src/resources/views/livewire/admin/panel.blade.php create mode 100644 src/resources/views/livewire/auth/login.blade.php create mode 100644 src/resources/views/livewire/client/dashboard.blade.php create mode 100644 src/resources/views/livewire/client/new-ticket.blade.php create mode 100644 src/resources/views/livewire/client/ticket-show.blade.php create mode 100644 src/resources/views/livewire/landing.blade.php create mode 100644 src/resources/views/livewire/operator/new-ticket.blade.php create mode 100644 src/resources/views/livewire/operator/queue.blade.php create mode 100644 src/resources/views/livewire/operator/stats.blade.php create mode 100644 src/resources/views/livewire/operator/ticket-show.blade.php create mode 100644 src/resources/views/vendor/l5-swagger/.gitkeep create mode 100644 src/resources/views/vendor/l5-swagger/index.blade.php create mode 100644 src/resources/views/welcome.blade.php create mode 100644 src/routes/api.php create mode 100644 src/routes/console.php create mode 100644 src/routes/web.php create mode 100644 src/storage/api-docs/api-docs.json create mode 100644 src/storage/app/.gitignore create mode 100644 src/storage/app/private/.gitignore create mode 100644 src/storage/app/public/.gitignore create mode 100644 src/storage/framework/.gitignore create mode 100644 src/storage/framework/cache/.gitignore create mode 100644 src/storage/framework/cache/data/.gitignore create mode 100644 src/storage/framework/sessions/.gitignore create mode 100644 src/storage/framework/testing/.gitignore create mode 100644 src/storage/framework/views/.gitignore create mode 100644 src/storage/logs/.gitignore create mode 100644 src/tests/Feature/AdminBrandingTest.php create mode 100644 src/tests/Feature/AdminCategoryManagementTest.php create mode 100644 src/tests/Feature/AdminStatusPriorityReorderTest.php create mode 100644 src/tests/Feature/AdminTeamManagementTest.php create mode 100644 src/tests/Feature/AdminUserManagementTest.php create mode 100644 src/tests/Feature/ApiAbilityEnforcementTest.php create mode 100644 src/tests/Feature/ApiKeyManagementTest.php create mode 100644 src/tests/Feature/AttachmentLimitsTest.php create mode 100644 src/tests/Feature/DictionariesApiTest.php create mode 100644 src/tests/Feature/EmailLayoutTest.php create mode 100644 src/tests/Feature/EmailNotificationsAdminTest.php create mode 100644 src/tests/Feature/ExampleTest.php create mode 100644 src/tests/Feature/ExtendedNotificationTriggersTest.php create mode 100644 src/tests/Feature/LdapAccountCreationTogglesTest.php create mode 100644 src/tests/Feature/LdapLoginTest.php create mode 100644 src/tests/Feature/LdapRestrictedGuestTicketsAndLocalLoginTest.php create mode 100644 src/tests/Feature/MailSmtpConfigTest.php create mode 100644 src/tests/Feature/MessageAttachmentsTest.php create mode 100644 src/tests/Feature/OperatorNewTicketTest.php create mode 100644 src/tests/Feature/OperatorQueueBulkActionsTest.php create mode 100644 src/tests/Feature/OperatorQueueClosedTabTest.php create mode 100644 src/tests/Feature/OperatorQueueSearchSortColumnsTest.php create mode 100644 src/tests/Feature/OperatorTeamScopingTest.php create mode 100644 src/tests/Feature/OperatorTicketDetailsEditTest.php create mode 100644 src/tests/Feature/PriorityStatusReplyQuickActionKeyOrderTest.php create mode 100644 src/tests/Feature/ReplyQuickActionsTest.php create mode 100644 src/tests/Feature/ResponseTemplatesAdminTest.php create mode 100644 src/tests/Feature/RoleMiddlewareTest.php create mode 100644 src/tests/Feature/RolesFieldValuesAndMessageAuthorTest.php create mode 100644 src/tests/Feature/SessionLifetimeConfigTest.php create mode 100644 src/tests/Feature/SlaBreachNotificationTest.php create mode 100644 src/tests/Feature/SmokeTest.php create mode 100644 src/tests/Feature/StatusStageRestructureTest.php create mode 100644 src/tests/Feature/SubcategoryDefaultPriorityTest.php create mode 100644 src/tests/Feature/TicketApiTest.php create mode 100644 src/tests/Feature/TicketBodyAndAttachmentDisplayTest.php create mode 100644 src/tests/Feature/TicketBusinessRulesTest.php create mode 100644 src/tests/Feature/TicketMessageApiTest.php create mode 100644 src/tests/Feature/TicketTimeTrackingTest.php create mode 100644 src/tests/Feature/TimezoneConfigTest.php create mode 100644 src/tests/Feature/UserFieldsTest.php create mode 100644 src/tests/Feature/UsersApiTest.php create mode 100644 src/tests/Pest.php create mode 100644 src/tests/TestCase.php create mode 100644 src/tests/Unit/ExampleTest.php create mode 100644 src/vite.config.js create mode 100644 wiki/admin/README.md create mode 100644 wiki/client/README.md create mode 100644 wiki/operator/README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c772cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/mysql/ +.env +compose.yaml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ab12bd2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM php:apache + +# Instalacja niezbędnych pakietów (dodano git, który jest kluczowy dla Composera) +RUN apt-get update && apt-get install -y \ + libcurl4-openssl-dev \ + libonig-dev \ + libzip-dev \ + unzip \ + git \ + libldap2-dev \ + && docker-php-ext-install curl mysqli pdo pdo_mysql ldap zip + +# Kopiowanie Composera z oficjalnego obrazu +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# Włączenie mod_rewrite dla Apache +RUN a2enmod rewrite + +# Ustawienie DocumentRoot na /var/www/html/public +ENV APACHE_DOCUMENT_ROOT /var/www/html/public + +# Zmiana konfiguracji Apache, by używał nowego DocumentRoot +RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/000-default.conf \ + && sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf + +# Uruchomienie Apache +CMD ["apache2-foreground"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..406a625 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Servicedesk + +Helpdesk / ticketing system built with Laravel + Livewire (Polish UI). Clients submit +support tickets, operators triage and resolve them inside team queues, and admins +configure everything else — categories, SLA rules, templates, branding, LDAP/SMTP, +and a REST API for external integrations. + +## Roles & areas + +The app has three areas, gated by role (a user can hold more than one at once): + +| Area | Route prefix | Who | What they do | +|---|---|---|---| +| Client | `/client` | `client` | Submit tickets, track status, reply, see resolution | +| Operator | `/operator` | `operator` | Work the ticket queue, reply/resolve, see team statistics | +| Admin | `/admin` | `admin` | Configure categories, users, teams, SLA, templates, branding, LDAP/SMTP | + +Every account gets the `client` role by default (see `AssignDefaultRole` for LDAP-provisioned +accounts), and always lands on `/client` first after login regardless of what other +roles it also holds — staff switch into Operator/Admin via the role switcher in the +header. See **[wiki/client](wiki/client/README.md)**, **[wiki/operator](wiki/operator/README.md)**, +and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides. + +## Feature overview + +- **Tickets** — number, subject, body, category/subcategory, status, priority, team, + assignee, custom fields (per subcategory), attachments, full message thread + (public replies + internal notes), history log, merge, delete. +- **SLA** — per-priority response/resolution time targets; a scheduled command + (`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify + the assigned operator. +- **Categories & custom fields** — admin-defined categories/subcategories, each with + its own set of custom fields (text/textarea/select/checkbox/date/number) and an + optional default priority. +- **Teams** — subcategories auto-route to a team; operators only see their own + team's queue (plus unrouted tickets and anything assigned to them) unless they're + an admin. +- **Templates** — canned response snippets for the reply box, admin-configurable + "quick actions" (send + transition status in one click), and HTML e-mail + templates for every ticket lifecycle event (created, status/priority/category/ + team changed, closed, operator replied, SLA breached), each independently + enable/disable-able. +- **Operator statistics** (`/operator/stats`) — filterable dashboard (date range, + team, priority, category, assignee) with KPI tiles (volume, SLA breach rate, + average first-response/resolution time) and breakdowns by status, priority, + category, team and operator workload, plus a daily created-vs-closed trend. +- **Branding & config** — company name/logo/favicon/accent color, login notice, + e-mail layout/footer, LDAP connection + user sync, SMTP connection, attachment + limits, session lifetime, timezone — all editable from Admin > Konfiguracja. +- **LDAP auth** — logins bind against an LDAP/LLDAP directory (`config/auth.php`, + `config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back + to e-mail + local password when the LDAP bind doesn't match. +- **REST API** (`/api/v1/...`, Sanctum token auth, ability-scoped: `tickets:read`, + `tickets:write`, `dictionaries:read`, `users:read`) for tickets/messages/users/ + categories/statuses/priorities/teams — issued via admin-managed API clients. + Interactive docs (L5-Swagger) at `/admin/api-docs`. +- **PWA** — installable manifest + icons for the client-facing area. + +## Tech stack + +- **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite + for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for + API docs. +- **Frontend**: Blade + Livewire + a little Alpine.js for local UI state; Tailwind + v4 via Vite for `resources/css/app.css`. No JS charting library — the statistics + dashboard is hand-rolled inline-styled bar/column charts, so it needs no client + build step beyond the CSS bundle. +- **Database**: MariaDB. +- **Deployment**: `compose.yaml` — `servicedesk` (php:apache, source bind-mounted + from `./src`, no image rebuild needed for PHP/Blade/route changes) + `mariadb`, + fronted by Traefik with a private-CA TLS cert. + +See **[install.md](install.md)** for full step-by-step deployment instructions — +both via Docker Compose (this stack) and directly on a server with Apache/Nginx, +including which `.env` values to set (there are two separate `.env` files — +Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed. + +## Local/dev notes + +- The app container mounts `./src` directly — editing PHP/Blade/routes takes effect + immediately, no rebuild or restart needed. +- CSS/JS changes under `resources/` **do** need a Vite build. Neither the host nor + the app container has Node installed; rebuild with a throwaway container instead + of rebuilding the app image: + ```bash + sudo docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build + ``` +- Fresh install / reset: + ```bash + sudo docker exec servicedesk-servicedesk-1 php artisan migrate:fresh --seed + ``` + Seeds real reference data (categories, custom fields, statuses/priorities/SLA, + teams, quick actions, response/e-mail templates, branding/config with example + SMTP+LDAP placeholders) and a single local fallback account, **`admin@example.com` + / `admin`** — replace its password and/or the SMTP/LDAP settings before relying on + this in anything but a lab environment. + +## Project layout + +``` +src/ Laravel application + app/Livewire/ Client/Operator/Admin Livewire components + app/Models/ Eloquent models + app/Services/ TicketService (ticket lifecycle + notifications) + app/Ldap/ LDAP user model + sync handlers + database/migrations/ Schema (one file per table group, final shape) + database/seeders/ DatabaseSeeder — reference data, no ticket data + resources/css/ Tailwind entrypoint (needs `npm run build` after edits) + resources/views/ Blade templates + routes/web.php Client/Operator/Admin routes (role-gated) + routes/api.php REST API (Sanctum, ability-gated) +wiki/ + client/ How-to guide for the Client role + operator/ How-to guide for the Operator role + admin/ How-to guide for the Admin role +``` diff --git a/install.md b/install.md new file mode 100644 index 0000000..67b658c --- /dev/null +++ b/install.md @@ -0,0 +1,356 @@ +# Instalacja / wdrożenie + +Dwa sposoby uruchomienia Servicedesk: przez **Docker Compose** (tak jak działa ten +projekt obecnie) albo **bezpośrednio na serwerze** z Apache/Nginx + PHP-FPM, bez +kontenerów. + +Zanim zaczniesz, ważne rozróżnienie — w projekcie są **dwa różne pliki `.env`**: + +| Plik | Do czego służy | +|---|---| +| `.env` (w katalogu głównym repo, obok `compose.yaml`) | Zmienne dla **Docker Compose** — hasła do MariaDB, nazwa hosta dla Traefika. Czyta go `compose.yaml`, **nie** czyta go Laravel. | +| `src/.env` (w katalogu aplikacji Laravel) | Cała konfiguracja **aplikacji** — połączenie z bazą, adres URL, klucz szyfrowania, LDAP, SMTP, itd. Czyta go wyłącznie Laravel. | + +Oba istnieją niezależnie od siebie (nawet w wersji Docker) — wartości `DB_*` w +`src/.env` muszą **odpowiadać** wartościom `MYSQL_*` z głównego `.env`, ale to dwa +osobne pliki, w dwóch różnych miejscach. + +--- + +## 1. Wdrożenie przez Docker Compose + +### Wymagania + +- Docker + wtyczka `docker compose`. +- Zewnętrzna sieć Docker `traefik_public`, jeśli używasz Traefika tak jak w + `compose.yaml` (`docker network create traefik_public`, jeśli jeszcze nie + istnieje). Bez Traefika trzeba samodzielnie zmapować porty serwisu `servicedesk` + na hosta (`ports: ["8080:80"]`) i obsłużyć TLS inaczej (patrz sekcja 2 niżej, w + razie potrzeby reverse-proxy przed kontenerem). + +### 1.1. Plik `.env` w katalogu głównym (Docker Compose) + +Skopiuj/utwórz `.env` obok `compose.yaml`: + +```env +HOSTNAME=servicedesk.twoja-domena.pl +MYSQL_ROOT_PASSWORD=wygeneruj-silne-haslo +MYSQL_DATABASE=servicedesk +MYSQL_USER=servicedesk +MYSQL_PASSWORD=wygeneruj-inne-silne-haslo +``` + +- `HOSTNAME` — domena, pod którą Traefik wystawi aplikację (trafia do reguły + `Host(...)` w etykietach `servicedesk`). +- `MYSQL_*` — dane bazy dla kontenera `mariadb`; `MYSQL_USER`/`MYSQL_PASSWORD` to + **te same** wartości, które za chwilę wpiszesz do `src/.env` jako `DB_USERNAME`/ + `DB_PASSWORD`. + +### 1.2. Plik `src/.env` (Laravel) + +```bash +cp src/.env.example src/.env +``` + +Kluczowe wartości do zmiany względem `.env.example`: + +```env +APP_NAME=Servicedesk +APP_ENV=production +APP_DEBUG=false +APP_URL=https://servicedesk.twoja-domena.pl # to samo co HOSTNAME wyżej, z https:// +APP_LOCALE=pl +APP_FALLBACK_LOCALE=pl + +AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji +VERSION=1.0.0 # rezerwa na przyszłość, jeszcze nigdzie nie wyświetlane + +DB_CONNECTION=mysql +DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1 +DB_PORT=3306 +DB_DATABASE=servicedesk # = MYSQL_DATABASE z głównego .env +DB_USERNAME=servicedesk # = MYSQL_USER +DB_PASSWORD=wygeneruj-inne-silne-haslo # = MYSQL_PASSWORD + +SESSION_DRIVER=database +QUEUE_CONNECTION=database +``` + +`APP_KEY` wygenerujesz komendą artisan (krok 1.4) — zostaw puste w pliku. + +`LDAP_*` i `MAIL_*` w `src/.env` są tylko **wartościami startowymi/awaryjnymi**. +Docelowo LDAP i SMTP konfiguruje się wygodniej z poziomu **Admin > Konfiguracja** +w samej aplikacji (patrz ramka ostrzegawcza w kroku 1.6) — ale jeśli chcesz mieć +sensowny fallback zanim ktokolwiek się zaloguje do panelu admina, warto je od razu +uzupełnić: + +```env +LDAP_HOST=ldap.twoja-domena.pl +LDAP_USERNAME=cn=admin,dc=twoja-domena,dc=pl +LDAP_PASSWORD=haslo-bind-ldap +LDAP_BASE_DN=dc=twoja-domena,dc=pl +LDAP_PORT=389 + +MAIL_MAILER=smtp +MAIL_HOST=smtp.twoja-domena.pl +MAIL_PORT=587 +MAIL_USERNAME=noreply@twoja-domena.pl +MAIL_PASSWORD=haslo-smtp +MAIL_FROM_ADDRESS=noreply@twoja-domena.pl +MAIL_FROM_NAME="${APP_NAME}" +``` + +### 1.3. Budowa i start kontenerów + +```bash +docker compose up -d --build +``` + +`--build` jest potrzebny **tylko przy pierwszym uruchomieniu** (budowa obrazu z +`Dockerfile`). Później, przy zwykłych zmianach w kodzie PHP/Blade — źródło jest +zamontowane z `./src`, więc `docker compose restart` (albo nic — Laravel odświeża +się natychmiast) wystarczy; `--build`/`docker compose build` uruchamiaj tylko gdy +zmienia się sam `Dockerfile`. + +### 1.4. Instalacja aplikacji wewnątrz kontenera + +```bash +docker compose exec servicedesk composer install --no-dev --optimize-autoloader +docker compose exec servicedesk php artisan key:generate +docker compose exec servicedesk php artisan migrate --seed +docker compose exec servicedesk php artisan storage:link +``` + +`migrate --seed` (bez `--fresh`) na pustej bazie utworzy wszystkie tabele i +wypełni dane referencyjne: kategorie/podkategorie, pola dodatkowe, statusy, +priorytety/SLA, zespoły, szybkie akcje, szablony odpowiedzi/e-mail, branding oraz +**jedno konto lokalne `admin@example.com` / `admin`** — zmień jego hasło od razu +po pierwszym zalogowaniu (Admin > Użytkownicy). + +### 1.5. Zbudowanie zasobów front-endowych (CSS/Tailwind) + +Ani host, ani kontener `servicedesk` nie mają zainstalowanego Node.js — buduj +przez jednorazowy kontener `node:22` zamiast dorzucać Node do obrazu aplikacji: + +```bash +cd /ścieżka/do/repo +docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm ci +docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build +``` + +Powtarzaj drugi krok po każdej zmianie w `resources/css/` lub `resources/js/`. + +### 1.6. Zadanie cykliczne (SLA) i kolejka + +`routes/console.php` planuje `tickets:check-sla-breaches` co 15 minut, ale **obraz +Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego kroku to zadanie +nigdy się nie uruchomi. Najprościej dodać wpis crona **na hoście**: + +```cron +* * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1 +``` + +Powiadomienia e-mail wysyłają się synchronicznie (nie trafiają do kolejki), więc +`php artisan queue:work` nie jest obowiązkowy — `QUEUE_CONNECTION=database` w +`.env` wystarcza jako bezpieczny domyślny driver, gdyby coś w przyszłości zaczęło +kolejkować zadania. + +### ⚠️ Ważne: LDAP/SMTP z panelu Admina nadpisują `.env` w locie + +`AppServiceProvider` na starcie żądania sprawdza tabelę `settings` — jeśli w +Admin > Konfiguracja pole **host LDAP** albo **SMTP włączony + host** jest +ustawione, **te wartości wygrywają z `.env`**, bez potrzeby restartu czy redeployu. + +Po świeżym `migrate --seed` te pola zawierają **przykładowe placeholdery** +(`ldap.example.com`, `smtp.example.com`, `changeme-*-password`) — to znaczy, że +zaraz po instalacji LDAP/SMTP **faktycznie próbują łączyć się z tymi fałszywymi +adresami**, nawet jeśli w `.env` wpisałeś prawdziwe dane! Zanim oddasz system do +użytku: + +1. Zaloguj się lokalnym kontem `admin@example.com` / `admin`. +2. Wejdź w **Admin > Konfiguracja** i wpisz prawdziwe dane LDAP/SMTP (albo wyczyść + pole hosta LDAP, żeby wrócić do wartości z `.env`). +3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz + polegać na logowaniu przez katalog. + +--- + +## 2. Wdrożenie bezpośrednio na serwerze (Apache/Nginx, bez Dockera) + +### Wymagania + +- **PHP 8.3+** z rozszerzeniami: `curl`, `mysqli`, `pdo_mysql`, `ldap`, `zip`, + `mbstring`, `openssl`, `tokenizer`, `xml`, `ctype`, `bcmath`, `fileinfo`. +- **Composer 2**. +- **MariaDB/MySQL** (osobny serwer lub lokalny). +- **Node.js 20+** — tylko do jednorazowej (lub przy update'ach) budowy zasobów + Tailwind/Vite; niepotrzebny w runtime. +- **Apache** z `mod_rewrite` (+ `mod_ssl`) **lub** **Nginx + PHP-FPM**. + +### 2.1. Pobranie kodu i zależności + +```bash +cd /var/www +git clone servicedesk +cd servicedesk/src +composer install --no-dev --optimize-autoloader +``` + +### 2.2. Konfiguracja `.env` + +```bash +cp .env.example .env +``` + +Te same zmienne co w sekcji 1.2, z jedną różnicą — `DB_HOST` wskazuje na +prawdziwy adres serwera bazy, nie na nazwę serwisu Compose: + +```env +APP_NAME=Servicedesk +APP_ENV=production +APP_DEBUG=false +APP_URL=https://servicedesk.twoja-domena.pl +APP_LOCALE=pl +APP_FALLBACK_LOCALE=pl + +AUTHOR_CONTACT=helpdesk@twoja-domena.pl +VERSION=1.0.0 + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB +DB_PORT=3306 +DB_DATABASE=servicedesk +DB_USERNAME=servicedesk +DB_PASSWORD=wygeneruj-silne-haslo + +SESSION_DRIVER=database +QUEUE_CONNECTION=database +``` + +Uzupełnij też `LDAP_*`/`MAIL_*` jak w sekcji 1.2 (to samo ostrzeżenie o +Admin > Konfiguracja nadpisującym te wartości w locie dotyczy tu identycznie). + +```bash +php artisan key:generate +php artisan migrate --seed +php artisan storage:link +``` + +### 2.3. Budowa zasobów + +```bash +npm ci +npm run build +``` + +(Na maszynie bez Node.js zbuduj `public/build/` gdzie indziej i skopiuj cały +katalog — to jedyne, co Vite generuje poza źródłem.) + +### 2.4. Uprawnienia plików + +```bash +chown -R www-data:www-data storage bootstrap/cache +chmod -R 775 storage bootstrap/cache +``` + +(Zmień `www-data` na użytkownika, pod którym faktycznie działa Apache/PHP-FPM w +Twojej dystrybucji, np. `apache` na RHEL/CentOS.) + +### 2.5. Konfiguracja serwera WWW + +**DocumentRoot musi wskazywać na `public/`**, nigdy na katalog główny aplikacji. + +**Apache** (`/etc/apache2/sites-available/servicedesk.conf`): + +```apache + + ServerName servicedesk.twoja-domena.pl + DocumentRoot /var/www/servicedesk/src/public + + + AllowOverride All + Require all granted + + + SSLEngine on + SSLCertificateFile /etc/ssl/certs/servicedesk.crt + SSLCertificateKeyFile /etc/ssl/private/servicedesk.key + + ErrorLog ${APACHE_LOG_DIR}/servicedesk-error.log + CustomLog ${APACHE_LOG_DIR}/servicedesk-access.log combined + +``` + +```bash +a2enmod rewrite ssl +a2ensite servicedesk +systemctl reload apache2 +``` + +> Jeśli kiedykolwiek dodasz własny katalog `public/icons/` — nie rób tego. Stockowy +> moduł `mod_alias` Apache definiuje `Alias /icons/ "/usr/share/apache2/icons/"` +> (FancyIndexing), który po cichu przechwytuje `/icons/*` przed dotarciem do +> Laravela. Ten projekt celowo używa `public/pwa-icons/` z tego właśnie powodu. + +**Nginx** (+ PHP-FPM): + +```nginx +server { + listen 443 ssl http2; + server_name servicedesk.twoja-domena.pl; + root /var/www/servicedesk/src/public; + + ssl_certificate /etc/ssl/certs/servicedesk.crt; + ssl_certificate_key /etc/ssl/private/servicedesk.key; + + index index.php; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass unix:/run/php/php8.3-fpm.sock; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} +``` + +### 2.6. Zadanie cykliczne (SLA) i kolejka + +Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`): + +```cron +* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1 +``` + +Jak w wersji Docker — kolejka (`php artisan queue:work`) nie jest obowiązkowa, +skoro powiadomienia wysyłają się synchronicznie; zostaw `QUEUE_CONNECTION=database` +jako bezpieczny domyślny driver na przyszłość. + +### 2.7. Pierwsze logowanie i dalsza konfiguracja + +Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień +hasło, uzupełnij prawdziwe LDAP/SMTP w Admin > Konfiguracja (placeholdery z seeda +inaczej realnie próbują łączyć się z fałszywymi adresami), przetestuj oba +połączenia przyciskiem „Testuj połączenie”. + +### 2.8. Aktualizacje (bez przestoju) + +```bash +cd /var/www/servicedesk/src +git pull +composer install --no-dev --optimize-autoloader +php artisan migrate --force +npm ci && npm run build +php artisan config:clear # tylko jeśli wcześniej użyto config:cache +``` + +Nie ma potrzeby restartu Apache/PHP-FPM dla samych zmian w PHP/Blade — restart +przyda się tylko po zmianie w `.env` lub konfiguracji samego serwera WWW. diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..6df8428 --- /dev/null +++ b/src/.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,docker-compose}.{yml,yaml}] +indent_size = 4 diff --git a/src/.env.example b/src/.env.example new file mode 100644 index 0000000..d1416d8 --- /dev/null +++ b/src/.env.example @@ -0,0 +1,68 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +AUTHOR_CONTACT=helpdesk@kzbikowski.pl +VERSION=1.0.0 + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/src/.gitattributes b/src/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/src/.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/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..3a2abba --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,31 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.codex +/.cursor/ +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/.scribe +/public/build +/public/fonts-manifest.dev.json +/public/hot +/public/storage +/public/vendor/scribe +/resources/views/scribe +/storage/app/private/scribe +/storage/*.key +/storage/pail +/vendor +_ide_helper.php +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/src/.npmrc b/src/.npmrc new file mode 100644 index 0000000..495a6af --- /dev/null +++ b/src/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..5ad1377 --- /dev/null +++ b/src/README.md @@ -0,0 +1,58 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. + +In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. + +## Agentic Development + +Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: + +```bash +composer require laravel/boost --dev + +php artisan boost:install +``` + +Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/src/app/Console/Commands/CheckSlaBreaches.php b/src/app/Console/Commands/CheckSlaBreaches.php new file mode 100644 index 0000000..2928d1a --- /dev/null +++ b/src/app/Console/Commands/CheckSlaBreaches.php @@ -0,0 +1,42 @@ +where('trigger_key', 'sla_breached')->first(); + + if (! $setting || ! $setting->enabled || ! $setting->email_template_id) { + return self::SUCCESS; + } + + $overdue = Ticket::query() + ->whereNotIn('status_key', Status::closedKeys()) + ->whereNotNull('assignee_id') + ->whereNull('sla_notified_at') + ->with(['priority.slaRule', 'assignee']) + ->get() + ->filter(fn (Ticket $ticket) => $ticket->isOverdue()); + + foreach ($overdue as $ticket) { + $tickets->notify($ticket, 'sla_breached'); + $ticket->update(['sla_notified_at' => now()]); + } + + $this->info("Checked SLA breaches: notified {$overdue->count()} ticket(s)."); + + return self::SUCCESS; + } +} diff --git a/src/app/Http/Controllers/Api/CategoryController.php b/src/app/Http/Controllers/Api/CategoryController.php new file mode 100644 index 0000000..be0c88c --- /dev/null +++ b/src/app/Http/Controllers/Api/CategoryController.php @@ -0,0 +1,37 @@ + []]], + tags: ['Dictionaries'], + responses: [ + new OA\Response( + response: 200, + description: 'OK', + content: new OA\JsonContent( + properties: [ + new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/Category')), + ], + ), + ), + new OA\Response(response: 403, description: 'Missing the dictionaries:read ability'), + ], + )] + public function index() + { + return CategoryResource::collection( + Category::query()->with('subcategories.customFields')->get() + ); + } +} diff --git a/src/app/Http/Controllers/Api/PriorityController.php b/src/app/Http/Controllers/Api/PriorityController.php new file mode 100644 index 0000000..f569df3 --- /dev/null +++ b/src/app/Http/Controllers/Api/PriorityController.php @@ -0,0 +1,34 @@ + []]], + tags: ['Dictionaries'], + responses: [ + new OA\Response( + response: 200, + description: 'OK', + content: new OA\JsonContent( + properties: [ + new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/Priority')), + ], + ), + ), + new OA\Response(response: 403, description: 'Missing the dictionaries:read ability'), + ], + )] + public function index() + { + return PriorityResource::collection(Priority::query()->orderBy('sort_order')->get()); + } +} diff --git a/src/app/Http/Controllers/Api/StatusController.php b/src/app/Http/Controllers/Api/StatusController.php new file mode 100644 index 0000000..81ad58f --- /dev/null +++ b/src/app/Http/Controllers/Api/StatusController.php @@ -0,0 +1,34 @@ + []]], + tags: ['Dictionaries'], + responses: [ + new OA\Response( + response: 200, + description: 'OK', + content: new OA\JsonContent( + properties: [ + new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/Status')), + ], + ), + ), + new OA\Response(response: 403, description: 'Missing the dictionaries:read ability'), + ], + )] + public function index() + { + return StatusResource::collection(Status::query()->orderBy('sort_order')->get()); + } +} diff --git a/src/app/Http/Controllers/Api/TeamController.php b/src/app/Http/Controllers/Api/TeamController.php new file mode 100644 index 0000000..ea82ffc --- /dev/null +++ b/src/app/Http/Controllers/Api/TeamController.php @@ -0,0 +1,34 @@ + []]], + tags: ['Dictionaries'], + responses: [ + new OA\Response( + response: 200, + description: 'OK', + content: new OA\JsonContent( + properties: [ + new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/Team')), + ], + ), + ), + new OA\Response(response: 403, description: 'Missing the dictionaries:read ability'), + ], + )] + public function index() + { + return TeamResource::collection(Team::query()->orderBy('name')->get()); + } +} diff --git a/src/app/Http/Controllers/Api/TicketController.php b/src/app/Http/Controllers/Api/TicketController.php new file mode 100644 index 0000000..4517dbc --- /dev/null +++ b/src/app/Http/Controllers/Api/TicketController.php @@ -0,0 +1,215 @@ + []]], + tags: ['Tickets'], + parameters: [ + new OA\Parameter(name: 'status_key', description: 'Filter by status key. Prefix with `!` to exclude, e.g. `!closed`.', in: 'query', schema: new OA\Schema(type: 'string'), example: '!closed'), + new OA\Parameter(name: 'priority_key', description: 'Filter by priority key. Prefix with `!` to exclude.', in: 'query', schema: new OA\Schema(type: 'string'), example: 'high'), + new OA\Parameter(name: 'team_id', description: 'Filter by team id. Prefix with `!` to exclude.', in: 'query', schema: new OA\Schema(type: 'integer'), example: 1), + new OA\Parameter(name: 'assignee_id', description: 'Filter by assignee (operator) user id. Prefix with `!` to exclude.', in: 'query', schema: new OA\Schema(type: 'integer'), example: 3), + new OA\Parameter(name: 'customer_id', description: 'Filter by customer user id. Prefix with `!` to exclude.', in: 'query', schema: new OA\Schema(type: 'integer'), example: 5), + new OA\Parameter(name: 'subcategory_id', description: 'Filter by subcategory id. Prefix with `!` to exclude.', in: 'query', schema: new OA\Schema(type: 'integer'), example: 2), + new OA\Parameter(name: 'per_page', description: 'Results per page (max 100).', in: 'query', schema: new OA\Schema(type: 'integer'), example: 25), + ], + responses: [ + new OA\Response( + response: 200, + description: 'OK', + content: new OA\JsonContent( + properties: [ + new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/Ticket')), + new OA\Property('links', properties: [ + new OA\Property('first', type: 'string', example: '/api/v1/tickets?page=1'), + new OA\Property('last', type: 'string', example: '/api/v1/tickets?page=1'), + ], type: 'object'), + new OA\Property('meta', properties: [ + new OA\Property('current_page', type: 'integer', example: 1), + new OA\Property('per_page', type: 'integer', example: 25), + new OA\Property('total', type: 'integer', example: 1), + ], type: 'object'), + ], + ), + ), + new OA\Response(response: 403, description: 'Missing the tickets:read ability'), + ], + )] + public function index(Request $request) + { + $query = Ticket::query()->with(['subcategory.category', 'subcategory.customFields']); + + foreach (['status_key', 'priority_key', 'team_id', 'assignee_id', 'customer_id', 'subcategory_id'] as $filter) { + if ($request->filled($filter)) { + $value = (string) $request->input($filter); + + if (str_starts_with($value, '!')) { + $query->where($filter, '!=', substr($value, 1)); + } else { + $query->where($filter, $value); + } + } + } + + $perPage = min((int) $request->input('per_page', 25) ?: 25, 100); + + return TicketResource::collection($query->orderByDesc('created_at')->paginate($perPage)); + } + + #[OA\Get( + path: '/tickets/{ticket}', + summary: 'Get a ticket', + security: [['sanctum' => []]], + tags: ['Tickets'], + parameters: [ + new OA\Parameter(name: 'ticket', description: 'Ticket id.', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'OK', + content: new OA\JsonContent(properties: [new OA\Property('data', ref: '#/components/schemas/Ticket')]), + ), + new OA\Response(response: 403, description: 'Missing the tickets:read ability'), + new OA\Response(response: 404, description: 'Ticket not found'), + ], + )] + public function show(Ticket $ticket) + { + return new TicketResource($ticket->load(['subcategory.category', 'subcategory.customFields'])); + } + + #[OA\Post( + path: '/tickets', + summary: 'Create a ticket', + description: 'Behaves like a guest ticket submission: no customer account is required, the given email/name identify the reporter (an existing user is matched/created automatically when LDAP guest auto-provisioning is enabled).', + security: [['sanctum' => []]], + tags: ['Tickets'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'multipart/form-data', + schema: new OA\Schema( + required: ['email', 'name', 'subject', 'body'], + properties: [ + new OA\Property('email', type: 'string', format: 'email', description: 'Reporter email address.', example: 'jan.kowalski@firma.pl'), + new OA\Property('name', type: 'string', description: 'Reporter display name.', example: 'Jan Kowalski'), + new OA\Property('subcategory_id', type: 'integer', nullable: true, description: 'The subcategory to file the ticket under.'), + new OA\Property('subject', type: 'string', description: 'Ticket subject.'), + new OA\Property('body', type: 'string', description: 'Ticket body.'), + new OA\Property('custom_values', type: 'object', description: 'Custom field values keyed by field id.'), + new OA\Property('attachments', type: 'array', items: new OA\Items(type: 'string', format: 'binary'), description: 'Files to attach to the opening message.'), + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Created', + content: new OA\JsonContent(properties: [new OA\Property('data', ref: '#/components/schemas/Ticket')]), + ), + new OA\Response(response: 403, description: 'Missing the tickets:write ability'), + new OA\Response(response: 422, description: 'Validation error'), + ], + )] + public function store(StoreTicketRequest $request) + { + $ticket = $this->tickets->create($request->validated(), customer: null, authorName: $request->validated('name')); + + $apiClient = $request->user(); + $ticket->update(['api_client_id' => $apiClient->id]); + $ticket->messages()->first()?->update(['api_client_id' => $apiClient->id]); + + $this->tickets->attachFiles($ticket, $ticket->messages()->first(), $request->file('attachments', [])); + + return (new TicketResource($ticket->fresh()->load(['subcategory.category', 'subcategory.customFields']))) + ->response() + ->setStatusCode(201); + } + + #[OA\Patch( + path: '/tickets/{ticket}', + summary: 'Update a ticket', + description: 'Every field is optional — only the fields present in the request are changed.', + security: [['sanctum' => []]], + tags: ['Tickets'], + parameters: [ + new OA\Parameter(name: 'ticket', description: 'Ticket id.', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + content: new OA\JsonContent( + properties: [ + new OA\Property('status_key', type: 'string', description: 'New status key.', example: 'in_progress'), + new OA\Property('priority_key', type: 'string', description: 'New priority key.', example: 'high'), + new OA\Property('assignee_id', type: 'integer', nullable: true, description: 'New assignee (operator) user id, or null to unassign.'), + new OA\Property('team_id', type: 'integer', nullable: true, description: 'New team id, or null to clear.'), + new OA\Property('subject', type: 'string', description: 'New subject (details update).'), + new OA\Property('body', type: 'string', description: 'New body (details update).'), + new OA\Property('subcategory_id', type: 'integer', nullable: true, description: 'New subcategory id (details update).'), + new OA\Property('custom_values', type: 'object', description: 'Custom field values (details update).'), + ], + ), + ), + responses: [ + new OA\Response( + response: 200, + description: 'OK', + content: new OA\JsonContent(properties: [new OA\Property('data', ref: '#/components/schemas/Ticket')]), + ), + new OA\Response(response: 403, description: 'Missing the tickets:write ability'), + new OA\Response(response: 404, description: 'Ticket not found'), + new OA\Response(response: 422, description: 'Validation error'), + ], + )] + public function update(UpdateTicketRequest $request, Ticket $ticket) + { + $data = $request->validated(); + + if (array_key_exists('status_key', $data)) { + $this->tickets->setStatus($ticket, $data['status_key']); + } + + if (array_key_exists('priority_key', $data)) { + $this->tickets->setPriority($ticket, $data['priority_key']); + } + + if (array_key_exists('assignee_id', $data)) { + $this->tickets->setAssignee($ticket, $data['assignee_id'] ? User::find($data['assignee_id']) : null); + } + + if (array_key_exists('team_id', $data)) { + $this->tickets->setTeam($ticket, $data['team_id'] ? Team::find($data['team_id']) : null); + } + + if (array_intersect(['subject', 'body', 'subcategory_id', 'custom_values'], array_keys($data))) { + $this->tickets->updateDetails($ticket, [ + 'subject' => $data['subject'] ?? $ticket->subject, + 'body' => $data['body'] ?? $ticket->body, + 'subcategory_id' => array_key_exists('subcategory_id', $data) ? $data['subcategory_id'] : $ticket->subcategory_id, + 'custom_values' => $data['custom_values'] ?? $ticket->custom_fields, + ]); + } + + return new TicketResource($ticket->fresh()->load(['subcategory.category', 'subcategory.customFields'])); + } +} diff --git a/src/app/Http/Controllers/Api/TicketMessageController.php b/src/app/Http/Controllers/Api/TicketMessageController.php new file mode 100644 index 0000000..8f89307 --- /dev/null +++ b/src/app/Http/Controllers/Api/TicketMessageController.php @@ -0,0 +1,93 @@ + []]], + tags: ['Ticket Messages'], + parameters: [ + new OA\Parameter(name: 'ticket', description: 'Ticket id.', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'OK', + content: new OA\JsonContent( + properties: [ + new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/TicketMessage')), + ], + ), + ), + new OA\Response(response: 403, description: 'Missing the tickets:read ability'), + new OA\Response(response: 404, description: 'Ticket not found'), + ], + )] + public function index(Ticket $ticket) + { + return TicketMessageResource::collection($ticket->messages()->with('attachments')->get()); + } + + #[OA\Post( + path: '/tickets/{ticket}/messages', + summary: 'Add a message to a ticket', + security: [['sanctum' => []]], + tags: ['Ticket Messages'], + parameters: [ + new OA\Parameter(name: 'ticket', description: 'Ticket id.', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'multipart/form-data', + schema: new OA\Schema( + required: ['body'], + properties: [ + new OA\Property('body', type: 'string', example: 'Problem nadal występuje.'), + new OA\Property('internal', type: 'boolean', description: 'Whether this is an internal (operator-only) note. Defaults to false.'), + new OA\Property('author_name', type: 'string', nullable: true, description: "Display name for the message author. Defaults to the API key's name."), + new OA\Property('attachments', type: 'array', items: new OA\Items(type: 'string', format: 'binary'), description: 'Files to attach to the message.'), + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 200, + description: 'OK', + content: new OA\JsonContent(properties: [new OA\Property('data', ref: '#/components/schemas/TicketMessage')]), + ), + new OA\Response(response: 403, description: 'Missing the tickets:write ability'), + new OA\Response(response: 404, description: 'Ticket not found'), + new OA\Response(response: 422, description: 'Validation error'), + ], + )] + public function store(StoreTicketMessageRequest $request, Ticket $ticket) + { + $apiClient = $request->user(); + + $message = $this->tickets->apiMessage( + $ticket, + $apiClient, + $request->validated('body'), + (bool) $request->boolean('internal'), + $request->validated('author_name') ?: $apiClient->name, + $request->file('attachments', []), + ); + + return new TicketMessageResource($message->load('attachments')); + } +} diff --git a/src/app/Http/Controllers/Api/UserController.php b/src/app/Http/Controllers/Api/UserController.php new file mode 100644 index 0000000..e377df5 --- /dev/null +++ b/src/app/Http/Controllers/Api/UserController.php @@ -0,0 +1,82 @@ + []]], + tags: ['Users'], + parameters: [ + new OA\Parameter( + name: 'role', + description: 'Filter by role key.', + in: 'query', + schema: new OA\Schema(type: 'string', enum: ['client', 'operator', 'admin']), + example: 'operator', + ), + new OA\Parameter( + name: 'per_page', + description: 'Results per page (max 100).', + in: 'query', + schema: new OA\Schema(type: 'integer'), + example: 25, + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'OK', + content: new OA\JsonContent( + properties: [ + new OA\Property('data', type: 'array', items: new OA\Items(ref: '#/components/schemas/User')), + ], + ), + ), + new OA\Response(response: 403, description: 'Missing the users:read ability'), + ], + )] + public function index(Request $request) + { + $query = User::query()->with('fieldValues'); + + if ($request->filled('role')) { + $query->withRole($request->input('role')); + } + + $perPage = min((int) $request->input('per_page', 25) ?: 25, 100); + + return UserResource::collection($query->orderBy('name')->paginate($perPage)); + } + + #[OA\Get( + path: '/users/{user}', + summary: 'Get a user', + security: [['sanctum' => []]], + tags: ['Users'], + parameters: [ + new OA\Parameter(name: 'user', description: 'User id.', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'OK', + content: new OA\JsonContent(properties: [new OA\Property('data', ref: '#/components/schemas/User')]), + ), + new OA\Response(response: 403, description: 'Missing the users:read ability'), + new OA\Response(response: 404, description: 'User not found'), + ], + )] + public function show(User $user) + { + return new UserResource($user->load('fieldValues')); + } +} diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..da94ef0 --- /dev/null +++ b/src/app/Http/Controllers/Controller.php @@ -0,0 +1,23 @@ +user(); + + if (! $user || ! array_intersect($roles, $user->roles ?? [])) { + abort(403); + } + + return $next($request); + } +} diff --git a/src/app/Http/Requests/Api/StoreTicketMessageRequest.php b/src/app/Http/Requests/Api/StoreTicketMessageRequest.php new file mode 100644 index 0000000..d7605da --- /dev/null +++ b/src/app/Http/Requests/Api/StoreTicketMessageRequest.php @@ -0,0 +1,35 @@ + ['required', 'string'], + 'internal' => ['sometimes', 'boolean'], + 'author_name' => ['sometimes', 'nullable', 'string', 'max:255'], + 'attachments' => ['sometimes', 'array'], + 'attachments.*' => ['file'], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + if ($error = Settings::validateAttachments($this->file('attachments', []))) { + $validator->errors()->add('attachments', $error); + } + }); + } +} diff --git a/src/app/Http/Requests/Api/StoreTicketRequest.php b/src/app/Http/Requests/Api/StoreTicketRequest.php new file mode 100644 index 0000000..904e796 --- /dev/null +++ b/src/app/Http/Requests/Api/StoreTicketRequest.php @@ -0,0 +1,38 @@ + ['required', 'email'], + 'name' => ['required', 'string', 'max:255'], + 'subcategory_id' => ['nullable', 'integer', 'exists:subcategories,id'], + 'subject' => ['required', 'string', 'max:255'], + 'body' => ['required', 'string'], + 'custom_values' => ['sometimes', 'array'], + 'attachments' => ['sometimes', 'array'], + 'attachments.*' => ['file'], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + if ($error = Settings::validateAttachments($this->file('attachments', []))) { + $validator->errors()->add('attachments', $error); + } + }); + } +} diff --git a/src/app/Http/Requests/Api/UpdateTicketRequest.php b/src/app/Http/Requests/Api/UpdateTicketRequest.php new file mode 100644 index 0000000..bfcdb9c --- /dev/null +++ b/src/app/Http/Requests/Api/UpdateTicketRequest.php @@ -0,0 +1,27 @@ + ['sometimes', 'string', 'exists:statuses,key'], + 'priority_key' => ['sometimes', 'string', 'exists:priorities,key'], + 'assignee_id' => ['sometimes', 'nullable', 'integer', 'exists:users,id'], + 'team_id' => ['sometimes', 'nullable', 'integer', 'exists:teams,id'], + 'subject' => ['sometimes', 'string', 'max:255'], + 'body' => ['sometimes', 'string'], + 'subcategory_id' => ['sometimes', 'nullable', 'integer', 'exists:subcategories,id'], + 'custom_values' => ['sometimes', 'array'], + ]; + } +} diff --git a/src/app/Http/Resources/CategoryResource.php b/src/app/Http/Resources/CategoryResource.php new file mode 100644 index 0000000..c9c616a --- /dev/null +++ b/src/app/Http/Resources/CategoryResource.php @@ -0,0 +1,29 @@ + $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'subcategories' => SubcategoryResource::collection($this->whenLoaded('subcategories')), + ]; + } +} diff --git a/src/app/Http/Resources/CustomFieldResource.php b/src/app/Http/Resources/CustomFieldResource.php new file mode 100644 index 0000000..d9be70c --- /dev/null +++ b/src/app/Http/Resources/CustomFieldResource.php @@ -0,0 +1,33 @@ + $this->id, + 'label' => $this->label, + 'type' => $this->type, + 'required' => $this->required, + 'options' => $this->options, + 'sort_order' => $this->sort_order, + ]; + } +} diff --git a/src/app/Http/Resources/PriorityResource.php b/src/app/Http/Resources/PriorityResource.php new file mode 100644 index 0000000..7453500 --- /dev/null +++ b/src/app/Http/Resources/PriorityResource.php @@ -0,0 +1,29 @@ + $this->key, + 'label' => $this->label, + 'color' => $this->color, + 'sort_order' => $this->sort_order, + ]; + } +} diff --git a/src/app/Http/Resources/StatusResource.php b/src/app/Http/Resources/StatusResource.php new file mode 100644 index 0000000..81ec6ce --- /dev/null +++ b/src/app/Http/Resources/StatusResource.php @@ -0,0 +1,31 @@ + $this->key, + 'label' => $this->label, + 'color' => $this->color, + 'stage' => $this->stage, + 'sort_order' => $this->sort_order, + ]; + } +} diff --git a/src/app/Http/Resources/SubcategoryResource.php b/src/app/Http/Resources/SubcategoryResource.php new file mode 100644 index 0000000..96cfb30 --- /dev/null +++ b/src/app/Http/Resources/SubcategoryResource.php @@ -0,0 +1,31 @@ + $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'default_priority_key' => $this->default_priority_key, + 'custom_fields' => CustomFieldResource::collection($this->whenLoaded('customFields')), + ]; + } +} diff --git a/src/app/Http/Resources/TeamResource.php b/src/app/Http/Resources/TeamResource.php new file mode 100644 index 0000000..7a8c270 --- /dev/null +++ b/src/app/Http/Resources/TeamResource.php @@ -0,0 +1,25 @@ + $this->id, + 'name' => $this->name, + ]; + } +} diff --git a/src/app/Http/Resources/TicketAttachmentResource.php b/src/app/Http/Resources/TicketAttachmentResource.php new file mode 100644 index 0000000..ab31cf8 --- /dev/null +++ b/src/app/Http/Resources/TicketAttachmentResource.php @@ -0,0 +1,32 @@ + $this->id, + 'original_name' => $this->original_name, + 'size' => $this->size, + 'mime' => $this->mime, + 'url' => Storage::disk('public')->url($this->path), + ]; + } +} diff --git a/src/app/Http/Resources/TicketMessageResource.php b/src/app/Http/Resources/TicketMessageResource.php new file mode 100644 index 0000000..7d22a7e --- /dev/null +++ b/src/app/Http/Resources/TicketMessageResource.php @@ -0,0 +1,41 @@ + $this->id, + 'ticket_id' => $this->ticket_id, + 'author_name' => $this->author_name, + 'role' => $this->role, + 'internal' => $this->internal, + 'body' => $this->body, + 'edited' => $this->edited, + 'attachments' => TicketAttachmentResource::collection($this->whenLoaded('attachments')), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/src/app/Http/Resources/TicketResource.php b/src/app/Http/Resources/TicketResource.php new file mode 100644 index 0000000..760aa0f --- /dev/null +++ b/src/app/Http/Resources/TicketResource.php @@ -0,0 +1,86 @@ + 'INV-2024-001']), + new OA\Property('created_at', type: 'string', format: 'date-time'), + new OA\Property('updated_at', type: 'string', format: 'date-time'), + ], +)] +class TicketResource extends JsonResource +{ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'number' => $this->number, + 'subject' => $this->subject, + 'body' => $this->body, + 'email' => $this->email, + 'name' => $this->name, + 'customer_id' => $this->customer_id, + 'assignee_id' => $this->assignee_id, + 'team_id' => $this->team_id, + 'status' => [ + 'key' => $this->status_key, + 'label' => $this->statusLabel(), + ], + 'priority' => [ + 'key' => $this->priority_key, + 'label' => $this->priorityLabel(), + ], + 'category' => $this->subcategory ? [ + 'id' => $this->subcategory->category->id, + 'name' => $this->subcategory->category->name, + 'description' => $this->subcategory->category->description, + 'subcategory' => new SubcategoryResource($this->subcategory), + ] : null, + 'custom_fields' => $this->customFieldEntries(), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/src/app/Http/Resources/UserResource.php b/src/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..341e83b --- /dev/null +++ b/src/app/Http/Resources/UserResource.php @@ -0,0 +1,37 @@ + $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'roles' => $this->roles, + 'custom_fields' => $this->customFieldEntries(), + ]; + } +} diff --git a/src/app/Ldap/Handlers/AssignDefaultRole.php b/src/app/Ldap/Handlers/AssignDefaultRole.php new file mode 100644 index 0000000..461e8aa --- /dev/null +++ b/src/app/Ldap/Handlers/AssignDefaultRole.php @@ -0,0 +1,21 @@ + database sync (config/auth.php: sync_attributes). + * Brand new local accounts default to the "client" role; existing accounts + * (including ones an admin already granted extra roles to) are left alone. + */ +class AssignDefaultRole +{ + public function __invoke(LdapModel $ldap, Model $eloquent): void + { + if (empty($eloquent->roles)) { + $eloquent->roles = ['client']; + } + } +} diff --git a/src/app/Ldap/Handlers/SyncUserFieldsFromLdap.php b/src/app/Ldap/Handlers/SyncUserFieldsFromLdap.php new file mode 100644 index 0000000..f23ef96 --- /dev/null +++ b/src/app/Ldap/Handlers/SyncUserFieldsFromLdap.php @@ -0,0 +1,31 @@ + database sync (config/auth.php: sync_attributes). + * Fills the admin-defined "user field" values (Panel > Zespół > Pola + * dodatkowe) from whichever LDAP attribute each field is mapped to, so they + * stay current on every login without a manual sync. + */ +class SyncUserFieldsFromLdap +{ + public function __invoke(LdapModel $ldap, Model $eloquent): void + { + $values = $eloquent->custom_field_values ?? []; + + foreach (UserField::query()->whereNotNull('ldap_attribute')->where('ldap_attribute', '!=', '')->get() as $field) { + $value = $ldap->getFirstAttribute($field->ldap_attribute); + + if ($value !== null) { + $values[$field->id] = $value; + } + } + + $eloquent->custom_field_values = $values; + } +} diff --git a/src/app/Ldap/LldapUser.php b/src/app/Ldap/LldapUser.php new file mode 100644 index 0000000..4a94db6 --- /dev/null +++ b/src/app/Ldap/LldapUser.php @@ -0,0 +1,33 @@ + '', 'description' => '', 'abilities' => []]; + + /** + * Only ever populated right after createToken() — the plaintext secret + * is never stored anywhere (Sanctum only keeps the hash), so this is the + * one and only chance to show it. Cleared on every other action so a + * page refresh or subsequent click never brings it back. + */ + public ?string $newTokenPlaintext = null; + + public ?string $newTokenClientName = null; + + public static function availableAbilities(): array + { + return [ + 'tickets:read' => 'Odczyt zgłoszeń', + 'tickets:write' => 'Zapis zgłoszeń (tworzenie, aktualizacja, wiadomości)', + 'dictionaries:read' => 'Odczyt słowników (kategorie, statusy, priorytety, zespoły)', + 'users:read' => 'Odczyt użytkowników', + ]; + } + + #[Computed] + public function apiClients(): Collection + { + return ApiClient::query() + ->with(['tokens' => fn ($q) => $q->latest()]) + ->orderByDesc('created_at') + ->get(); + } + + public function openForm(): void + { + $this->reset('form'); + $this->newTokenPlaintext = null; + $this->newTokenClientName = null; + $this->resetErrorBag(); + $this->formOpen = true; + } + + public function closeForm(): void + { + $this->formOpen = false; + } + + public function submit(): void + { + $this->validate([ + 'form.name' => ['required', 'string', 'max:255'], + 'form.description' => ['nullable', 'string', 'max:1000'], + 'form.abilities' => ['required', 'array', 'min:1'], + 'form.abilities.*' => ['string', 'in:'.implode(',', array_keys(static::availableAbilities()))], + ]); + + $client = ApiClient::query()->create([ + 'name' => $this->form['name'], + 'description' => $this->form['description'] ?: null, + 'created_by' => Auth::id(), + ]); + + $token = $client->createToken($this->form['name'], $this->form['abilities']); + + $this->newTokenPlaintext = $token->plainTextToken; + $this->newTokenClientName = $client->name; + $this->formOpen = false; + unset($this->apiClients); + } + + public function revoke(int $apiClientId): void + { + $this->newTokenPlaintext = null; + $this->newTokenClientName = null; + + $client = ApiClient::query()->findOrFail($apiClientId); + $client->tokens()->delete(); + $client->update(['revoked_at' => now()]); + + unset($this->apiClients); + } + + public function regenerate(int $apiClientId): void + { + $client = ApiClient::query()->findOrFail($apiClientId); + $abilities = $client->tokens()->latest()->first()?->abilities ?? []; + + $client->tokens()->delete(); + $token = $client->createToken($client->name, $abilities); + $client->update(['revoked_at' => null]); + + $this->newTokenPlaintext = $token->plainTextToken; + $this->newTokenClientName = $client->name; + + unset($this->apiClients); + } + + public function dismissNewToken(): void + { + $this->newTokenPlaintext = null; + $this->newTokenClientName = null; + } + + public function render() + { + return view('livewire.admin.api-keys'); + } +} diff --git a/src/app/Livewire/Admin/Panel.php b/src/app/Livewire/Admin/Panel.php new file mode 100644 index 0000000..1d3869b --- /dev/null +++ b/src/app/Livewire/Admin/Panel.php @@ -0,0 +1,1402 @@ + null, 'categoryId' => null, 'name' => '', 'description' => '', 'fieldIds' => [], 'defaultPriorityKey' => '']; + + // ---- fields ---- + public bool $fieldFormOpen = false; + + public array $fieldForm = ['id' => null, 'label' => '', 'type' => 'text', 'required' => false, 'optionsText' => '']; + + // ---- users ---- + public bool $userFormOpen = false; + + public array $userForm = ['name' => '', 'email' => '', 'password' => '', 'password_confirmation' => '', 'roles' => ['operator'], 'fieldValues' => []]; + + public ?string $ldapSyncResult = null; + + public ?int $viewingUserId = null; + + // ---- user fields ---- + public bool $userFieldFormOpen = false; + + public array $userFieldForm = ['id' => null, 'label' => '', 'description' => '', 'type' => 'text', 'optionsText' => '', 'ldapAttribute' => '']; + + // ---- teams ---- + public bool $teamFormOpen = false; + + public array $teamForm = ['id' => null, 'name' => '', 'memberIds' => [], 'subcategoryIds' => []]; + + // ---- statuses / priorities ---- + public string $newStatusLabel = ''; + + public string $newStatusColor = '#9184d9'; + + public string $newStatusKey = ''; + + public string $newStatusSortOrder = ''; + + public string $newPriorityLabel = ''; + + public string $newPriorityColor = '#9184d9'; + + public string $newPriorityKey = ''; + + public string $newPrioritySortOrder = ''; + + // ---- reply quick actions ---- + public bool $replyQuickActionFormOpen = false; + + public array $replyQuickActionForm = ['id' => null, 'label' => '', 'statusKey' => '']; + + // ---- response templates ---- + public bool $responseTemplateFormOpen = false; + + public array $responseTemplateForm = ['id' => null, 'label' => '', 'body' => '']; + + // ---- templates ---- + public ?int $editingTemplateId = null; + + public string $emailFooterHtml = ''; + + // Bumped on "Resetuj" so the wire:ignore'd Quill editor's wire:key + // changes, forcing Livewire to remount it (and re-read $emailFooterHtml) + // instead of leaving the old, ignored DOM untouched. + public int $emailFooterVersion = 0; + + public string $loginNoticeHtml = ''; + + public string $loginNoticeType = 'info'; + + // Bumped on "Przywróć domyślny" for the same reason as $emailFooterVersion + // above — forces the wire:ignore'd Quill editor to remount and re-read + // $loginNoticeHtml instead of keeping its old, now-stale DOM. + public int $loginNoticeVersion = 0; + + // ---- branding ---- + public $logoUpload = null; + + public $faviconUpload = null; + + public ?string $faviconError = null; + + public string $accentColor = '#7c6fd6'; + + // ---- config ---- + public array $systemConfig = []; + + public array $ldapConfig = []; + + public ?string $ldapTestResult = null; + + public array $mailConfig = []; + + public ?string $mailTestResult = null; + + // ---- generic pending-delete confirm ---- + public ?string $pendingDeleteType = null; + + public $pendingDeleteId = null; + + public string $pendingDeleteMessage = ''; + + public function mount(): void + { + $this->accentColor = Settings::accentColor(); + $this->emailFooterHtml = Settings::get('email_footer'); + $this->loginNoticeHtml = Settings::get('login_notice_html'); + $this->loginNoticeType = Settings::loginNoticeType(); + + $this->systemConfig = [ + 'defaultStatus' => Settings::get('default_status'), + 'autoAssignByCategory' => Settings::bool('auto_assign_by_category'), + 'allowAttachments' => Settings::bool('allow_attachments'), + 'attachmentMaxSizeKb' => Settings::get('attachment_max_size_kb'), + 'attachmentMaxCount' => Settings::get('attachment_max_count'), + 'attachmentMaxTotalSizeKb' => Settings::get('attachment_max_total_size_kb'), + 'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'), + 'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'), + 'timezone' => Settings::timezone(), + ]; + + $this->ldapConfig = [ + 'enabled' => Settings::bool('ldap_enabled'), + 'host' => Settings::get('ldap_host'), + 'port' => Settings::get('ldap_port'), + 'baseDn' => Settings::get('ldap_base_dn'), + 'bindDn' => Settings::get('ldap_bind_dn'), + 'bindPassword' => Settings::get('ldap_bind_password'), + 'useSsl' => Settings::bool('ldap_use_ssl'), + 'userFilter' => Settings::get('ldap_user_filter'), + 'autoProvisionGuests' => Settings::bool('ldap_auto_provision_guests'), + 'restrictUserCreationToLdap' => Settings::bool('restrict_user_creation_to_ldap'), + 'restrictTicketsToLdap' => Settings::bool('restrict_tickets_to_ldap'), + ]; + + $this->mailConfig = [ + 'smtpEnabled' => Settings::bool('mail_smtp_enabled'), + 'smtpHost' => Settings::get('mail_smtp_host'), + 'smtpPort' => Settings::get('mail_smtp_port'), + 'smtpUsername' => Settings::get('mail_smtp_username'), + 'smtpPassword' => Settings::get('mail_smtp_password'), + 'smtpEncryption' => Settings::get('mail_smtp_encryption'), + 'fromAddress' => Settings::get('mail_from_address'), + 'fromName' => Settings::get('mail_from_name'), + ]; + } + + public function setTab(string $tab): void + { + $this->tab = $tab; + } + + // ===================== CATEGORIES ===================== + + #[Computed] + public function categories() + { + return Category::query()->with(['subcategories.customFields'])->get(); + } + + #[Computed] + public function customFields() + { + return CustomField::query()->orderBy('sort_order')->get(); + } + + public function addCategory(): void + { + if (! trim($this->newCategoryName)) { + return; + } + + Category::query()->create(['name' => $this->newCategoryName]); + $this->newCategoryName = ''; + } + + public function startEditCategory(int $id, string $name, ?string $description): void + { + $this->editingCategoryId = $id; + $this->editingCategoryName = $name; + $this->editingCategoryDescription = $description ?? ''; + } + + public function cancelEditCategory(): void + { + $this->editingCategoryId = null; + } + + public function saveEditCategory(): void + { + if (! trim($this->editingCategoryName)) { + return; + } + + Category::query()->find($this->editingCategoryId)?->update([ + 'name' => $this->editingCategoryName, + 'description' => trim($this->editingCategoryDescription) ?: null, + ]); + $this->editingCategoryId = null; + } + + public function addSubcategory(int $categoryId): void + { + $name = trim($this->newSubNames[$categoryId] ?? ''); + + if (! $name) { + return; + } + + Category::query()->find($categoryId)?->subcategories()->create(['name' => $name]); + $this->newSubNames[$categoryId] = ''; + } + + public function openSubcategoryEditForm(int $subcategoryId): void + { + $sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId); + + $this->subcategoryEditForm = [ + 'id' => $sub->id, + 'categoryId' => $sub->category_id, + 'name' => $sub->name, + 'description' => $sub->description ?? '', + 'fieldIds' => $sub->customFields->pluck('id')->all(), + 'defaultPriorityKey' => $sub->default_priority_key ?? '', + ]; + $this->subcategoryEditFormOpen = true; + } + + public function closeSubcategoryEditForm(): void + { + $this->subcategoryEditFormOpen = false; + } + + public function toggleSubcategoryEditField(int $fieldId): void + { + $ids = $this->subcategoryEditForm['fieldIds']; + + $this->subcategoryEditForm['fieldIds'] = in_array($fieldId, $ids, true) + ? array_values(array_diff($ids, [$fieldId])) + : [...$ids, $fieldId]; + } + + public function moveSubcategoryEditFieldUp(int $fieldId): void + { + $ids = $this->subcategoryEditForm['fieldIds']; + $idx = array_search($fieldId, $ids, true); + + if ($idx === false || $idx === 0) { + return; + } + + [$ids[$idx], $ids[$idx - 1]] = [$ids[$idx - 1], $ids[$idx]]; + $this->subcategoryEditForm['fieldIds'] = $ids; + } + + public function moveSubcategoryEditFieldDown(int $fieldId): void + { + $ids = $this->subcategoryEditForm['fieldIds']; + $idx = array_search($fieldId, $ids, true); + + if ($idx === false || $idx >= count($ids) - 1) { + return; + } + + [$ids[$idx], $ids[$idx + 1]] = [$ids[$idx + 1], $ids[$idx]]; + $this->subcategoryEditForm['fieldIds'] = $ids; + } + + public function submitSubcategoryEdit(): void + { + if (! trim($this->subcategoryEditForm['name'])) { + return; + } + + $sub = Subcategory::query()->findOrFail($this->subcategoryEditForm['id']); + $sub->update([ + 'name' => $this->subcategoryEditForm['name'], + 'description' => trim($this->subcategoryEditForm['description']) ?: null, + 'default_priority_key' => $this->subcategoryEditForm['defaultPriorityKey'] ?: null, + ]); + + $pivot = []; + foreach ($this->subcategoryEditForm['fieldIds'] as $position => $fieldId) { + $pivot[$fieldId] = ['position' => $position]; + } + $sub->customFields()->sync($pivot); + + $this->subcategoryEditFormOpen = false; + } + + public function removeCategory(int $id): void + { + $this->requestDelete('category', $id, 'Kategoria oraz jej podkategorie zostaną usunięte.'); + } + + public function removeSubcategory(int $id): void + { + $this->requestDelete('subcategory', $id, 'Podkategoria zostanie usunięta.'); + } + + // ===================== CUSTOM FIELDS ===================== + + public function openFieldForm(): void + { + $this->fieldForm = ['id' => null, 'label' => '', 'type' => 'text', 'required' => false, 'optionsText' => '']; + $this->fieldFormOpen = true; + } + + public function closeFieldForm(): void + { + $this->fieldFormOpen = false; + } + + public function editField(int $id): void + { + $field = CustomField::query()->findOrFail($id); + + $this->fieldForm = [ + 'id' => $field->id, + 'label' => $field->label, + 'type' => $field->type, + 'required' => $field->required, + 'optionsText' => implode(', ', $field->options ?? []), + ]; + $this->fieldFormOpen = true; + } + + public function submitField(): void + { + $this->validate([ + 'fieldForm.label' => 'required|string|max:255', + 'fieldForm.type' => 'required|in:text,textarea,select,checkbox,date,number', + ]); + + $options = $this->fieldForm['type'] === 'select' + ? array_values(array_filter(array_map('trim', explode(',', $this->fieldForm['optionsText'])))) + : null; + + if ($this->fieldForm['id']) { + CustomField::query()->find($this->fieldForm['id'])?->update([ + 'label' => $this->fieldForm['label'], + 'type' => $this->fieldForm['type'], + 'required' => $this->fieldForm['required'], + 'options' => $options, + ]); + } else { + CustomField::query()->create([ + 'label' => $this->fieldForm['label'], + 'type' => $this->fieldForm['type'], + 'required' => $this->fieldForm['required'], + 'options' => $options, + 'sort_order' => (CustomField::query()->max('sort_order') ?? 0) + 1, + ]); + } + + $this->fieldFormOpen = false; + } + + public function removeField(int $id): void + { + $this->requestDelete('field', $id, 'Pole dodatkowe zostanie usunięte ze wszystkich podkategorii.'); + } + + // ===================== USERS ===================== + + #[Computed] + public function users() + { + return User::query()->with(['teams', 'roleAssignments'])->orderBy('name')->get(); + } + + #[Computed] + public function userFields() + { + return UserField::query()->orderBy('sort_order')->get(); + } + + public function openUserForm(): void + { + $this->userForm = ['id' => null, 'name' => '', 'email' => '', 'password' => '', 'password_confirmation' => '', 'roles' => ['operator'], 'fieldValues' => []]; + $this->userFormOpen = true; + } + + public function editUser(int $id): void + { + $user = User::query()->findOrFail($id); + $this->viewingUserId = null; + + $this->userForm = [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'password' => '', + 'password_confirmation' => '', + 'roles' => $user->roles ?? [], + 'fieldValues' => $user->custom_field_values ?? [], + ]; + $this->userFormOpen = true; + } + + public function closeUserForm(): void + { + $this->userFormOpen = false; + } + + #[Computed] + public function viewingUser(): ?User + { + return $this->viewingUserId ? User::query()->with(['teams', 'roleAssignments'])->find($this->viewingUserId) : null; + } + + public function viewUser(int $id): void + { + $this->viewingUserId = $id; + } + + public function closeUserView(): void + { + $this->viewingUserId = null; + } + + public function toggleUserFormRole(string $role): void + { + $roles = $this->userForm['roles']; + + $this->userForm['roles'] = in_array($role, $roles, true) + ? array_values(array_diff($roles, [$role])) + : [...$roles, $role]; + } + + public function submitUser(): void + { + $emailRule = 'required|email|unique:users,email'.($this->userForm['id'] ? ','.$this->userForm['id'] : ''); + + $this->validate([ + 'userForm.name' => 'required|string|max:255', + 'userForm.email' => $emailRule, + 'userForm.password' => 'nullable|string|min:8|confirmed', + 'userForm.roles' => 'required|array|min:1', + 'userForm.roles.*' => 'in:client,operator,admin', + ]); + + if (! $this->userForm['id'] && Settings::bool('restrict_user_creation_to_ldap') + && ! app(LdapUserProvisioner::class)->existsInLdap($this->userForm['email'])) { + $this->addError('userForm.email', 'Ten adres e-mail nie istnieje w LDAP — administrator wyłączył tworzenie kont spoza LDAP.'); + + return; + } + + // A non-empty password turns this into a local account that can log in + // with a username/password even when LDAP doesn't know this e-mail. + $password = trim($this->userForm['password']) !== '' ? $this->userForm['password'] : null; + + if ($this->userForm['id']) { + User::query()->find($this->userForm['id'])?->update([ + 'name' => $this->userForm['name'], + 'email' => $this->userForm['email'], + 'roles' => $this->userForm['roles'], + 'custom_field_values' => $this->userForm['fieldValues'], + ...($password ? ['password' => $password] : []), + ]); + } else { + User::query()->create([ + 'name' => $this->userForm['name'], + 'email' => $this->userForm['email'], + 'roles' => $this->userForm['roles'], + ...($password ? ['password' => $password] : []), + ]); + } + + $this->userFormOpen = false; + unset($this->users); + } + + public function removeUser(int $id): void + { + if ($id === Auth::id()) { + return; // can't delete your own account + } + + $this->requestDelete('user', $id, 'Użytkownik zostanie usunięty. Jego zgłoszenia i wiadomości zostaną zachowane, ale bez przypisanego autora.'); + } + + public function toggleUserRole(int $id, string $role): void + { + $user = User::query()->find($id); + + if (! $user) { + return; + } + + $roles = $user->roles ?? []; + + $roles = in_array($role, $roles, true) + ? array_values(array_diff($roles, [$role])) + : [...$roles, $role]; + + if (empty($roles)) { + return; // a user must keep at least one role + } + + $user->update(['roles' => $roles]); + unset($this->users); + } + + public function syncUsersWithLdap(): void + { + $matched = app(LdapUserProvisioner::class)->syncAll(); + + $this->ldapSyncResult = $matched === 1 + ? 'Zsynchronizowano dane 1 użytkownika z LDAP.' + : "Zsynchronizowano dane {$matched} użytkowników z LDAP."; + + unset($this->users); + } + + // ===================== USER FIELDS ===================== + + public function openUserFieldForm(): void + { + $this->userFieldForm = ['id' => null, 'label' => '', 'description' => '', 'type' => 'text', 'optionsText' => '', 'ldapAttribute' => '']; + $this->userFieldFormOpen = true; + } + + public function editUserField(int $id): void + { + $field = UserField::query()->findOrFail($id); + + $this->userFieldForm = [ + 'id' => $field->id, + 'label' => $field->label, + 'description' => $field->description, + 'type' => $field->type, + 'optionsText' => implode(', ', $field->options ?? []), + 'ldapAttribute' => $field->ldap_attribute, + ]; + $this->userFieldFormOpen = true; + } + + public function closeUserFieldForm(): void + { + $this->userFieldFormOpen = false; + } + + public function submitUserField(): void + { + $this->validate([ + 'userFieldForm.label' => 'required|string|max:255', + 'userFieldForm.type' => 'required|in:text,textarea,select,checkbox,date,number', + ]); + + $options = $this->userFieldForm['type'] === 'select' + ? array_values(array_filter(array_map('trim', explode(',', $this->userFieldForm['optionsText'])))) + : null; + + $data = [ + 'label' => $this->userFieldForm['label'], + 'description' => trim($this->userFieldForm['description']) ?: null, + 'type' => $this->userFieldForm['type'], + 'options' => $options, + 'ldap_attribute' => trim($this->userFieldForm['ldapAttribute']) ?: null, + ]; + + if ($this->userFieldForm['id']) { + UserField::query()->find($this->userFieldForm['id'])?->update($data); + } else { + UserField::query()->create([ + ...$data, + 'sort_order' => (UserField::query()->max('sort_order') ?? 0) + 1, + ]); + } + + $this->userFieldFormOpen = false; + unset($this->userFields); + } + + public function removeUserField(int $id): void + { + $this->requestDelete('user-field', $id, 'Pole dodatkowe użytkownika zostanie usunięte wraz z zapisanymi dla niego wartościami.'); + } + + // ===================== TEAMS ===================== + + #[Computed] + public function teams() + { + return Team::query()->with(['members', 'subcategories.category'])->get(); + } + + #[Computed] + public function operatorsForTeamForm() + { + return User::query()->withRole('operator')->orderBy('name')->get() + ->map(fn (User $u) => ['id' => $u->id, 'label' => $u->name]) + ->values(); + } + + #[Computed] + public function subcategoriesForTeamForm() + { + return Subcategory::query()->with('category')->get() + ->map(fn (Subcategory $s) => ['id' => $s->id, 'label' => $s->category->name.' / '.$s->name]) + ->values(); + } + + public function openTeamForm(): void + { + $this->teamForm = ['id' => null, 'name' => '', 'memberIds' => [], 'subcategoryIds' => []]; + $this->teamFormOpen = true; + } + + public function editTeam(int $id): void + { + $team = Team::query()->with(['members', 'subcategories'])->findOrFail($id); + + $this->teamForm = [ + 'id' => $team->id, + 'name' => $team->name, + 'memberIds' => $team->members->pluck('id')->all(), + 'subcategoryIds' => $team->subcategories->pluck('id')->all(), + ]; + $this->teamFormOpen = true; + } + + public function closeTeamForm(): void + { + $this->teamFormOpen = false; + } + + public function toggleTeamMember(int $userId): void + { + $ids = $this->teamForm['memberIds']; + $this->teamForm['memberIds'] = in_array($userId, $ids, true) + ? array_values(array_diff($ids, [$userId])) + : [...$ids, $userId]; + } + + public function toggleTeamSubcategory(int $subcategoryId): void + { + $ids = $this->teamForm['subcategoryIds']; + $this->teamForm['subcategoryIds'] = in_array($subcategoryId, $ids, true) + ? array_values(array_diff($ids, [$subcategoryId])) + : [...$ids, $subcategoryId]; + } + + public function submitTeam(): void + { + $this->validate(['teamForm.name' => 'required|string|max:255']); + + if ($this->teamForm['id']) { + $team = Team::query()->findOrFail($this->teamForm['id']); + $team->update(['name' => $this->teamForm['name']]); + } else { + $team = Team::query()->create(['name' => $this->teamForm['name']]); + } + + $team->subcategories()->sync($this->teamForm['subcategoryIds']); + $team->members()->sync($this->teamForm['memberIds']); + + $this->teamFormOpen = false; + unset($this->teams, $this->users); + } + + public function removeTeam(int $id): void + { + $this->requestDelete('team', $id, 'Zespół zostanie usunięty. Członkowie zostaną odpięci, a zgłoszenia zachowają swój stan bez przypisanego zespołu.'); + } + + // ===================== REPLY QUICK ACTIONS ===================== + + #[Computed] + public function replyQuickActions() + { + return ReplyQuickAction::query()->orderBy('sort_order')->get(); + } + + public function openReplyQuickActionForm(): void + { + $this->replyQuickActionForm = ['id' => null, 'label' => '', 'statusKey' => '', 'sortOrder' => '']; + $this->replyQuickActionFormOpen = true; + } + + public function editReplyQuickAction(int $id): void + { + $action = ReplyQuickAction::query()->findOrFail($id); + + $this->replyQuickActionForm = [ + 'id' => $action->id, + 'label' => $action->label, + 'statusKey' => $action->status_key ?? '', + 'sortOrder' => (string) $action->sort_order, + ]; + $this->replyQuickActionFormOpen = true; + } + + public function closeReplyQuickActionForm(): void + { + $this->replyQuickActionFormOpen = false; + } + + public function submitReplyQuickAction(): void + { + $this->validate([ + 'replyQuickActionForm.label' => 'required|string|max:255', + 'replyQuickActionForm.sortOrder' => 'nullable|integer', + ]); + + $data = [ + 'label' => $this->replyQuickActionForm['label'], + 'status_key' => $this->replyQuickActionForm['statusKey'] ?: null, + ]; + + if (trim((string) $this->replyQuickActionForm['sortOrder']) !== '') { + $data['sort_order'] = (int) $this->replyQuickActionForm['sortOrder']; + } + + if ($this->replyQuickActionForm['id']) { + ReplyQuickAction::query()->find($this->replyQuickActionForm['id'])?->update($data); + } else { + ReplyQuickAction::query()->create([ + ...$data, + 'sort_order' => $data['sort_order'] ?? (ReplyQuickAction::query()->max('sort_order') ?? 0) + 1, + ]); + } + + $this->replyQuickActionFormOpen = false; + unset($this->replyQuickActions); + } + + public function removeReplyQuickAction(int $id): void + { + $this->requestDelete('reply-quick-action', $id, 'Szybka akcja zostanie usunięta z menu wysyłania odpowiedzi u operatorów.'); + } + + // ===================== RESPONSE TEMPLATES ===================== + + #[Computed] + public function responseTemplates() + { + return ResponseTemplate::query()->orderBy('id')->get(); + } + + public function openResponseTemplateForm(): void + { + $this->responseTemplateForm = ['id' => null, 'label' => '', 'body' => '']; + $this->responseTemplateFormOpen = true; + } + + public function editResponseTemplate(int $id): void + { + $template = ResponseTemplate::query()->findOrFail($id); + + $this->responseTemplateForm = [ + 'id' => $template->id, + 'label' => $template->label, + 'body' => $template->body, + ]; + $this->responseTemplateFormOpen = true; + } + + public function closeResponseTemplateForm(): void + { + $this->responseTemplateFormOpen = false; + } + + public function submitResponseTemplate(): void + { + $this->validate([ + 'responseTemplateForm.label' => 'required|string|max:255', + 'responseTemplateForm.body' => 'required|string', + ]); + + $data = [ + 'label' => $this->responseTemplateForm['label'], + 'body' => $this->responseTemplateForm['body'], + ]; + + if ($this->responseTemplateForm['id']) { + ResponseTemplate::query()->find($this->responseTemplateForm['id'])?->update($data); + } else { + ResponseTemplate::query()->create($data); + } + + $this->responseTemplateFormOpen = false; + unset($this->responseTemplates); + } + + public function removeResponseTemplate(int $id): void + { + $this->requestDelete('response-template', $id, 'Szablon odpowiedzi zostanie usunięty z listy dostępnej operatorom.'); + } + + // ===================== STATUSES / PRIORITIES ===================== + + #[Computed] + public function statuses() + { + return Status::query()->orderBy('sort_order')->get(); + } + + #[Computed] + public function priorities() + { + return Priority::query()->with('slaRule')->orderBy('sort_order')->get(); + } + + public function setStatusLabel(string $key, string $value): void + { + Status::query()->find($key)?->update(['label' => $value]); + } + + public function setStatusColor(string $key, string $value): void + { + Status::query()->find($key)?->update(['color' => $value]); + } + + /** + * A new custom status is always a refinement of "Otwarty" — Nowy/Otwarty/ + * Zamknięty are the 3 fixed stages and stay the only entries in their + * stage, so there's no stage picker here to choose from. + */ + public function addStatus(): void + { + if (! trim($this->newStatusLabel)) { + return; + } + + $key = trim($this->newStatusKey) ?: 'status-'.now()->timestamp; + + if (Status::query()->whereKey($key)->exists()) { + $this->addError('newStatusKey', 'Status o takim kluczu już istnieje.'); + + return; + } + + Status::query()->create([ + 'key' => $key, + 'label' => $this->newStatusLabel, + 'color' => $this->newStatusColor, + 'stage' => 'open', + 'locked' => false, + 'sort_order' => trim($this->newStatusSortOrder) !== '' ? (int) $this->newStatusSortOrder : (Status::query()->max('sort_order') ?? 0) + 1, + ]); + $this->newStatusLabel = ''; + $this->newStatusColor = '#9184d9'; + $this->newStatusKey = ''; + $this->newStatusSortOrder = ''; + } + + public function removeStatus(string $key): void + { + $this->requestDelete('status', $key, 'Status zostanie usunięty. Istniejące zgłoszenia z tym statusem zachowają starą wartość.'); + } + + public function moveStatusUp(string $key): void + { + $this->swapAdjacentSortOrder(Status::query()->orderBy('sort_order')->get(), $key, -1); + } + + public function moveStatusDown(string $key): void + { + $this->swapAdjacentSortOrder(Status::query()->orderBy('sort_order')->get(), $key, 1); + } + + public function setPriorityLabel(string $key, string $value): void + { + Priority::query()->find($key)?->update(['label' => $value]); + } + + public function setPriorityColor(string $key, string $value): void + { + Priority::query()->find($key)?->update(['color' => $value]); + } + + public function addPriority(): void + { + if (! trim($this->newPriorityLabel)) { + return; + } + + $key = trim($this->newPriorityKey) ?: 'priority-'.now()->timestamp; + + if (Priority::query()->whereKey($key)->exists()) { + $this->addError('newPriorityKey', 'Priorytet o takim kluczu już istnieje.'); + + return; + } + + $priority = Priority::query()->create([ + 'key' => $key, + 'label' => $this->newPriorityLabel, + 'color' => $this->newPriorityColor, + 'sort_order' => trim($this->newPrioritySortOrder) !== '' ? (int) $this->newPrioritySortOrder : (Priority::query()->max('sort_order') ?? 0) + 1, + ]); + + SlaRule::query()->create(['priority_key' => $priority->key, 'response_mins' => 60, 'resolution_mins' => 480]); + + $this->newPriorityLabel = ''; + $this->newPriorityColor = '#9184d9'; + $this->newPriorityKey = ''; + $this->newPrioritySortOrder = ''; + } + + public function removePriority(string $key): void + { + $this->requestDelete('priority', $key, 'Priorytet zostanie usunięty. Istniejące zgłoszenia z tym priorytetem zachowają starą wartość.'); + } + + public function movePriorityUp(string $key): void + { + $this->swapAdjacentSortOrder(Priority::query()->orderBy('sort_order')->get(), $key, -1); + } + + public function movePriorityDown(string $key): void + { + $this->swapAdjacentSortOrder(Priority::query()->orderBy('sort_order')->get(), $key, 1); + } + + /** + * Shared by the Statuses/Priorities reorder buttons — both are simple + * key-primary-keyed, sort_order-ordered lists edited the same way: swap + * this row's sort_order with its immediate neighbor in the given direction. + * + * @param \Illuminate\Support\Collection $ordered + */ + protected function swapAdjacentSortOrder($ordered, string $key, int $direction): void + { + $index = $ordered->search(fn ($row) => $row->getKey() === $key); + $swapIndex = $index + $direction; + + if ($index === false || $swapIndex < 0 || $swapIndex >= $ordered->count()) { + return; + } + + $row = $ordered[$index]; + $neighbor = $ordered[$swapIndex]; + + [$rowOrder, $neighborOrder] = [$row->sort_order, $neighbor->sort_order]; + $row->update(['sort_order' => $neighborOrder]); + $neighbor->update(['sort_order' => $rowOrder]); + } + + public function setSlaResponse(string $priorityKey, int $value): void + { + $rule = SlaRule::query()->firstOrNew(['priority_key' => $priorityKey]); + $rule->response_mins = max(0, $value); + $rule->resolution_mins ??= 480; + $rule->save(); + } + + public function setSlaResolution(string $priorityKey, int $value): void + { + $rule = SlaRule::query()->firstOrNew(['priority_key' => $priorityKey]); + $rule->resolution_mins = max(0, $value); + $rule->response_mins ??= 60; + $rule->save(); + } + + // ===================== EMAIL TEMPLATES ===================== + + /** + * Templates are fixed 1:1 to their notification trigger (no add/delete/ + * reassign) — only their subject/body content is editable, opened via + * the "Edytuj szablon" button on the matching row in notificationSettings(). + */ + #[Computed] + public function editingTemplate(): ?EmailTemplate + { + return $this->editingTemplateId ? EmailTemplate::query()->find($this->editingTemplateId) : null; + } + + public function editEmailTemplate(int $id): void + { + $this->editingTemplateId = $id; + } + + public function closeEmailTemplateEditor(): void + { + $this->editingTemplateId = null; + } + + public function setTemplateSubject(string $value): void + { + $this->editingTemplate?->update(['subject' => $value]); + } + + public function setTemplateBody(string $value): void + { + $this->editingTemplate?->update(['body' => $value]); + } + + /** + * The footer appended inside the fixed e-mail box layout (its {stopka} + * slot) — used to live under Konfiguracja -> E-mail, moved here since + * it's part of the same "how does the e-mail look" concern. + */ + public function saveEmailFooter(string $value): void + { + Settings::set('email_footer', $value); + $this->emailFooterHtml = $value; + } + + public function resetEmailFooter(): void + { + $default = Settings::default('email_footer'); + Settings::set('email_footer', $default); + $this->emailFooterHtml = $default; + $this->emailFooterVersion++; + } + + /** + * A fully-wrapped example e-mail rendered with the layout/footer as + * they currently stand — recomputed on every render, so it updates a + * few hundred ms after each edit (as soon as the debounced Quill change + * lands), letting the admin see the actual result without sending a test. + */ + #[Computed] + public function emailPreviewHtml(): string + { + return Settings::renderEmailLayout( + '

Cześć Jan,

' + .'

Twoje zgłoszenie #1234 („Nie działa drukarka w sali 2”) zmieniło status na Otwarte.

' + .'

Zobacz zgłoszenie

' + ); + } + + /** + * The example above, rendered through the exact same Blade view real + * notifications send with — so the admin's preview iframe shows the + * genuine output, not a hand-approximated copy of it. + */ + #[Computed] + public function emailPreviewDocument(): string + { + return view('emails.ticket-notification', ['html' => $this->emailPreviewHtml])->render(); + } + + // ===================== NOTIFICATION SETTINGS ===================== + + #[Computed] + public function notificationSettings() + { + return NotificationSetting::query()->with('emailTemplate')->orderBy('id')->get(); + } + + public function toggleNotificationEnabled(int $id): void + { + $setting = NotificationSetting::query()->find($id); + $setting?->update(['enabled' => ! $setting->enabled]); + } + + // ===================== BRANDING ===================== + + public function setCompanyName(string $value): void + { + Settings::set('company_name', $value); + } + + /** + * The notice shown above the login form — same wire:ignore'd Quill + * pattern as $emailFooterHtml above (see saveEmailFooter). + */ + public function saveLoginNotice(string $value): void + { + Settings::set('login_notice_html', $value); + $this->loginNoticeHtml = $value; + } + + public function setLoginNoticeType(string $type): void + { + if (! array_key_exists($type, Settings::loginNoticeTypes())) { + return; + } + + Settings::set('login_notice_type', $type); + $this->loginNoticeType = $type; + } + + public function resetLoginNotice(): void + { + $default = Settings::default('login_notice_html'); + Settings::set('login_notice_html', $default); + Settings::set('login_notice_type', Settings::default('login_notice_type')); + $this->loginNoticeHtml = $default; + $this->loginNoticeType = Settings::default('login_notice_type'); + $this->loginNoticeVersion++; + } + + public function updatedLogoUpload(): void + { + if (! $this->logoUpload) { + return; + } + + $path = $this->logoUpload->store('branding', 'public'); + Settings::set('company_logo_path', $path); + } + + public function removeLogo(): void + { + $path = Settings::get('company_logo_path'); + + if ($path) { + Storage::disk('public')->delete($path); + } + + Settings::set('company_logo_path', null); + } + + public function updatedFaviconUpload(): void + { + $this->faviconError = null; + + if (! $this->faviconUpload) { + return; + } + + $extension = strtolower($this->faviconUpload->getClientOriginalExtension()); + $contents = file_get_contents($this->faviconUpload->getRealPath()); + + if ($extension !== 'svg' || ! str_contains($contents, 'faviconUpload = null; + $this->faviconError = 'Favicon musi być plikiem w formacie SVG.'; + + return; + } + + $path = 'branding/favicon-'.uniqid('', true).'.svg'; + Storage::disk('public')->put($path, $this->sanitizeSvg($contents)); + + if ($old = Settings::get('favicon_path')) { + Storage::disk('public')->delete($old); + } + + Settings::set('favicon_path', $path); + $this->faviconUpload = null; + } + + public function removeFavicon(): void + { + $path = Settings::get('favicon_path'); + + if ($path) { + Storage::disk('public')->delete($path); + } + + Settings::set('favicon_path', null); + } + + /** + * Strips #is', '', $svg) ?? $svg; + $svg = preg_replace('#\son\w+\s*=\s*("[^"]*"|\'[^\']*\')#i', '', $svg) ?? $svg; + + return $svg; + } + + public function setAccentColor(string $value): void + { + if (! preg_match('/^#[0-9a-fA-F]{6}$/', $value)) { + return; + } + + Settings::set('accent_color', $value); + $this->accentColor = $value; + } + + public function resetAccentColor(): void + { + $this->setAccentColor('#7c6fd6'); + } + + // ===================== SYSTEM CONFIG ===================== + + public function saveSystemConfig(): void + { + Settings::set('default_status', $this->systemConfig['defaultStatus']); + Settings::set('auto_assign_by_category', $this->systemConfig['autoAssignByCategory'] ? '1' : '0'); + Settings::set('allow_attachments', $this->systemConfig['allowAttachments'] ? '1' : '0'); + Settings::set('attachment_max_size_kb', (string) $this->systemConfig['attachmentMaxSizeKb']); + Settings::set('attachment_max_count', (string) $this->systemConfig['attachmentMaxCount']); + Settings::set('attachment_max_total_size_kb', (string) $this->systemConfig['attachmentMaxTotalSizeKb']); + Settings::set('attachment_allowed_types', $this->systemConfig['attachmentAllowedTypes']); + Settings::set('session_lifetime_minutes', (string) $this->systemConfig['sessionLifetimeMinutes']); + + if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) { + Settings::set('timezone', $this->systemConfig['timezone']); + } + } + + // ===================== LDAP CONFIG ===================== + + public function saveLdapConfig(): void + { + Settings::set('ldap_enabled', $this->ldapConfig['enabled'] ? '1' : '0'); + Settings::set('ldap_host', $this->ldapConfig['host']); + Settings::set('ldap_port', (string) $this->ldapConfig['port']); + Settings::set('ldap_base_dn', $this->ldapConfig['baseDn']); + Settings::set('ldap_bind_dn', $this->ldapConfig['bindDn']); + + if ($this->ldapConfig['bindPassword']) { + Settings::set('ldap_bind_password', $this->ldapConfig['bindPassword']); + } + + Settings::set('ldap_use_ssl', $this->ldapConfig['useSsl'] ? '1' : '0'); + Settings::set('ldap_user_filter', $this->ldapConfig['userFilter']); + Settings::set('ldap_auto_provision_guests', $this->ldapConfig['autoProvisionGuests'] ? '1' : '0'); + Settings::set('restrict_user_creation_to_ldap', $this->ldapConfig['restrictUserCreationToLdap'] ? '1' : '0'); + Settings::set('restrict_tickets_to_ldap', $this->ldapConfig['restrictTicketsToLdap'] ? '1' : '0'); + + $this->ldapTestResult = null; + } + + public function testLdapConnection(): void + { + $cfg = $this->ldapConfig; + + if (empty($cfg['host']) || empty($cfg['baseDn']) || empty($cfg['bindDn'])) { + $this->ldapTestResult = 'error'; + + return; + } + + try { + $connection = new Connection([ + 'hosts' => [$cfg['host']], + 'port' => (int) $cfg['port'], + 'base_dn' => $cfg['baseDn'], + 'username' => $cfg['bindDn'], + 'password' => $cfg['bindPassword'] ?: Settings::get('ldap_bind_password'), + 'use_tls' => (bool) $cfg['useSsl'], + 'timeout' => 5, + ]); + + $connection->connect(); + $this->ldapTestResult = 'ok'; + } catch (\Throwable) { + $this->ldapTestResult = 'error'; + } + } + + // ===================== MAIL / SMTP CONFIG ===================== + + public function saveMailConfig(): void + { + Settings::set('mail_smtp_enabled', $this->mailConfig['smtpEnabled'] ? '1' : '0'); + Settings::set('mail_smtp_host', $this->mailConfig['smtpHost']); + Settings::set('mail_smtp_port', (string) $this->mailConfig['smtpPort']); + Settings::set('mail_smtp_username', $this->mailConfig['smtpUsername']); + + if ($this->mailConfig['smtpPassword']) { + Settings::set('mail_smtp_password', $this->mailConfig['smtpPassword']); + } + + Settings::set('mail_smtp_encryption', $this->mailConfig['smtpEncryption']); + Settings::set('mail_from_address', $this->mailConfig['fromAddress']); + Settings::set('mail_from_name', $this->mailConfig['fromName']); + + $this->mailTestResult = null; + } + + /** + * Sends a real test e-mail to the logged-in admin using the form's + * current (unsaved) values, temporarily overriding the mail config the + * same way AppServiceProvider does for real once saved — so this test + * exercises the exact path production notifications will use. + */ + public function testMailConnection(): void + { + $cfg = $this->mailConfig; + + if (empty($cfg['smtpHost']) || empty($cfg['fromAddress'])) { + $this->mailTestResult = 'error'; + + return; + } + + $original = Config::get('mail'); + + try { + Config::set('mail.default', 'smtp'); + Config::set('mail.mailers.smtp.host', $cfg['smtpHost']); + Config::set('mail.mailers.smtp.port', (int) $cfg['smtpPort']); + Config::set('mail.mailers.smtp.username', $cfg['smtpUsername'] ?: null); + Config::set('mail.mailers.smtp.password', $cfg['smtpPassword'] ?: Settings::get('mail_smtp_password')); + Config::set('mail.mailers.smtp.scheme', match ($cfg['smtpEncryption']) { + 'ssl' => 'smtps', + 'tls' => 'smtp', + default => null, + }); + Config::set('mail.from.address', $cfg['fromAddress']); + Config::set('mail.from.name', $cfg['fromName'] ?: Settings::get('company_name')); + + app()->forgetInstance('mail.manager'); + app()->forgetInstance('mailer'); + + Mail::raw('To jest testowa wiadomość wysłana z panelu administratora Servicedesk.', function ($message) { + $message->to(Auth::user()->email)->subject('Test konfiguracji SMTP'); + }); + + $this->mailTestResult = 'ok'; + } catch (\Throwable) { + $this->mailTestResult = 'error'; + } finally { + Config::set('mail', $original); + app()->forgetInstance('mail.manager'); + app()->forgetInstance('mailer'); + } + } + + // ===================== GENERIC DELETE CONFIRM ===================== + + public function requestDelete(string $type, mixed $id, string $message): void + { + $this->pendingDeleteType = $type; + $this->pendingDeleteId = $id; + $this->pendingDeleteMessage = $message; + } + + public function cancelPendingDelete(): void + { + $this->pendingDeleteType = null; + $this->pendingDeleteId = null; + } + + public function confirmPendingDelete(): void + { + match ($this->pendingDeleteType) { + 'category' => Category::query()->find($this->pendingDeleteId)?->delete(), + 'subcategory' => Subcategory::query()->find($this->pendingDeleteId)?->delete(), + 'field' => CustomField::query()->find($this->pendingDeleteId)?->delete(), + 'status' => Status::query()->where('key', $this->pendingDeleteId)->where('locked', false)->delete(), + 'priority' => Priority::query()->find($this->pendingDeleteId)?->delete(), + 'team' => Team::query()->find($this->pendingDeleteId)?->delete(), + 'user' => $this->pendingDeleteId !== Auth::id() + ? User::query()->find($this->pendingDeleteId)?->delete() + : null, + 'user-field' => UserField::query()->find($this->pendingDeleteId)?->delete(), + 'reply-quick-action' => ReplyQuickAction::query()->find($this->pendingDeleteId)?->delete(), + 'response-template' => ResponseTemplate::query()->find($this->pendingDeleteId)?->delete(), + default => null, + }; + + unset($this->categories, $this->customFields, $this->statuses, $this->priorities, $this->teams, $this->users, $this->userFields, $this->replyQuickActions, $this->responseTemplates, $this->notificationSettings); + + $this->cancelPendingDelete(); + } + + public function render() + { + return view('livewire.admin.panel'); + } +} diff --git a/src/app/Livewire/Auth/Login.php b/src/app/Livewire/Auth/Login.php new file mode 100644 index 0000000..eab3300 --- /dev/null +++ b/src/app/Livewire/Auth/Login.php @@ -0,0 +1,70 @@ +error = null; + + $attribute = Settings::ldapUsernameAttribute(); + + // Local accounts (created with a password from the admin panel) don't + // necessarily exist in LDAP under this attribute, so we also let the + // provider fall back to matching by e-mail + local password. + $ok = Auth::attempt([ + $attribute => $this->username, + 'password' => $this->password, + 'fallback' => ['email' => $this->username], + ]); + + if (! $ok) { + $this->error = 'Nieprawidłowa nazwa użytkownika lub hasło.'; + $this->password = ''; + + return; + } + + request()->session()->regenerate(); + + $this->redirect($this->safeRedirectTarget() ?? Auth::user()->defaultArea(), navigate: false); + } + + /** + * Only follow the ?redirect= target when it's a same-app relative path — + * never an absolute/external URL, to avoid it being abused as an open redirect. + */ + protected function safeRedirectTarget(): ?string + { + if (! $this->redirect || ! str_starts_with($this->redirect, '/') || str_starts_with($this->redirect, '//')) { + return null; + } + + return $this->redirect; + } + + public function render() + { + return view('livewire.auth.login'); + } +} diff --git a/src/app/Livewire/Client/Dashboard.php b/src/app/Livewire/Client/Dashboard.php new file mode 100644 index 0000000..6797b55 --- /dev/null +++ b/src/app/Livewire/Client/Dashboard.php @@ -0,0 +1,44 @@ +ticketsAsCustomer() + ->with('subcategory.category') + ->orderByDesc('updated_at') + ->get(); + } + + #[Computed] + public function currentTickets() + { + return $this->tickets->whereNotIn('status_key', Status::closedKeys()); + } + + #[Computed] + public function archiveTickets() + { + return $this->tickets->whereIn('status_key', Status::closedKeys()); + } + + public function setTab(string $tab): void + { + $this->tab = $tab; + } + + public function render() + { + return view('livewire.client.dashboard'); + } +} diff --git a/src/app/Livewire/Client/NewTicket.php b/src/app/Livewire/Client/NewTicket.php new file mode 100644 index 0000000..65ac54b --- /dev/null +++ b/src/app/Livewire/Client/NewTicket.php @@ -0,0 +1,123 @@ +with('subcategories')->get(); + } + + #[Computed] + public function selectedCategory(): ?Category + { + return $this->categoryId ? Category::query()->with('subcategories')->find($this->categoryId) : null; + } + + #[Computed] + public function selectedSubcategory(): ?Subcategory + { + return $this->subcategoryId ? Subcategory::query()->with('customFields')->find($this->subcategoryId) : null; + } + + public function selectCategory(int $id): void + { + $this->categoryId = $id; + $this->subcategoryId = null; + $this->step = 2; + } + + public function selectSubcategory(int $id): void + { + $this->subcategoryId = $id; + $this->step = 3; + } + + public function backToCategory(): void + { + $this->step = 1; + } + + public function backToSubcategory(): void + { + $this->step = 2; + } + + public function updatedAttachments(): void + { + if (! $this->attachments) { + return; + } + + if ($error = Settings::validateAttachments($this->attachments)) { + $this->attachments = []; + $this->addError('attachments', $error); + } + } + + public function removeAttachment(int $index): void + { + unset($this->attachments[$index]); + $this->attachments = array_values($this->attachments); + } + + public function submit(): void + { + $rules = [ + 'subject' => 'required|string|max:255', + 'body' => 'required|string', + ]; + + foreach ($this->selectedSubcategory?->customFields ?? [] as $field) { + $rules["customValues.{$field->id}"] = $field->required ? 'required' : 'nullable'; + } + + $this->validate($rules); + + $user = Auth::user(); + + $ticket = app(TicketService::class)->create([ + 'email' => $user->email, + 'subcategory_id' => $this->subcategoryId, + 'subject' => $this->subject, + 'body' => $this->body, + 'custom_values' => $this->customValues, + ], $user); + + app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments); + + $this->redirect(route('client.ticket', $ticket), navigate: true); + } + + public function render() + { + return view('livewire.client.new-ticket'); + } +} diff --git a/src/app/Livewire/Client/TicketShow.php b/src/app/Livewire/Client/TicketShow.php new file mode 100644 index 0000000..62e1a6d --- /dev/null +++ b/src/app/Livewire/Client/TicketShow.php @@ -0,0 +1,138 @@ +customer_id === Auth::id(), 403); + + $this->ticket = $ticket; + } + + #[Computed] + public function ticketMessages() + { + return $this->ticket->publicMessages()->with(['author', 'authorLink.role', 'attachments'])->get(); + } + + #[Computed] + public function otherTickets() + { + return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get(); + } + + public function updatedAttachments(): void + { + if (! $this->attachments) { + return; + } + + if ($error = Settings::validateAttachments($this->attachments)) { + $this->attachments = []; + $this->addError('attachments', $error); + } + } + + public function removeAttachment(int $index): void + { + unset($this->attachments[$index]); + $this->attachments = array_values($this->attachments); + } + + public function sendReply(): void + { + $this->validate(['reply' => 'required|string']); + + app(TicketService::class)->clientReply($this->ticket, Auth::user(), $this->reply, $this->attachments); + $this->reset(['reply', 'attachments']); + unset($this->ticketMessages); + $this->ticket->refresh(); + } + + public function close(): void + { + app(TicketService::class)->setStatus($this->ticket, 'closed'); + $this->ticket->refresh(); + } + + public function reopen(): void + { + app(TicketService::class)->setStatus($this->ticket, 'open'); + $this->ticket->refresh(); + } + + public function startEdit(int $messageId, string $body): void + { + $message = TicketMessage::query()->findOrFail($messageId); + abort_unless($message->ticket_id === $this->ticket->id && $message->author_id === Auth::id() && $message->role === 'client', 403); + + $this->editingMessageId = $messageId; + $this->editingDraft = $body; + } + + public function cancelEdit(): void + { + $this->editingMessageId = null; + $this->editingDraft = ''; + } + + public function saveEdit(): void + { + $message = TicketMessage::query()->findOrFail($this->editingMessageId); + abort_unless($message->ticket_id === $this->ticket->id && $message->author_id === Auth::id() && $message->role === 'client', 403); + + $message->update(['body' => $this->editingDraft, 'edited' => true]); + $this->cancelEdit(); + unset($this->ticketMessages); + } + + public function requestDelete(int $messageId): void + { + $this->pendingDeleteMessageId = $messageId; + } + + public function cancelDelete(): void + { + $this->pendingDeleteMessageId = null; + } + + public function confirmDelete(): void + { + $message = TicketMessage::query()->findOrFail($this->pendingDeleteMessageId); + abort_unless($message->ticket_id === $this->ticket->id && $message->author_id === Auth::id() && $message->role === 'client', 403); + + $message->delete(); + $this->pendingDeleteMessageId = null; + unset($this->ticketMessages); + } + + public function render() + { + return view('livewire.client.ticket-show'); + } +} diff --git a/src/app/Livewire/Landing.php b/src/app/Livewire/Landing.php new file mode 100644 index 0000000..d24ae74 --- /dev/null +++ b/src/app/Livewire/Landing.php @@ -0,0 +1,159 @@ +with('subcategories')->get(); + } + + #[Computed] + public function selectedCategory(): ?Category + { + return $this->categoryId ? Category::query()->with('subcategories')->find($this->categoryId) : null; + } + + #[Computed] + public function selectedSubcategory(): ?Subcategory + { + return $this->subcategoryId ? Subcategory::query()->with('customFields')->find($this->subcategoryId) : null; + } + + #[Computed] + public function submittedTicket(): ?Ticket + { + return $this->submittedTicketId ? Ticket::query()->with('subcategory.category')->find($this->submittedTicketId) : null; + } + + public function selectCategory(int $id): void + { + $this->categoryId = $id; + $this->subcategoryId = null; + $this->step = 2; + } + + public function selectSubcategory(int $id): void + { + $this->subcategoryId = $id; + $this->step = 3; + } + + public function backToCategory(): void + { + $this->step = 1; + } + + public function backToSubcategory(): void + { + $this->step = 2; + } + + public function updatedAttachments(): void + { + if (! $this->attachments) { + return; + } + + if ($error = Settings::validateAttachments($this->attachments)) { + $this->attachments = []; + $this->addError('attachments', $error); + } + } + + public function removeAttachment(int $index): void + { + unset($this->attachments[$index]); + $this->attachments = array_values($this->attachments); + } + + public function submit(): void + { + $rules = [ + 'email' => 'required|email', + 'subject' => 'required|string|max:255', + 'body' => 'required|string', + ]; + + foreach ($this->selectedSubcategory?->customFields ?? [] as $field) { + $key = "customValues.{$field->id}"; + $rules[$key] = $field->required ? 'required' : 'nullable'; + } + + $this->validate($rules); + + if (Settings::bool('restrict_tickets_to_ldap') && ! $this->emailIsKnown($this->email)) { + $this->addError('email', 'Nie znaleziono użytkownika o podanym adresie e-mail.'); + + return; + } + + $ticket = app(TicketService::class)->create([ + 'email' => $this->email, + 'subcategory_id' => $this->subcategoryId, + 'subject' => $this->subject, + 'body' => $this->body, + 'custom_values' => $this->customValues, + ], null); + + app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments); + + $this->submittedTicketId = $ticket->id; + $this->reset(['step', 'categoryId', 'subcategoryId', 'email', 'subject', 'body', 'customValues', 'attachments']); + $this->step = 1; + } + + public function resetForm(): void + { + $this->submittedTicketId = null; + } + + /** + * Gate for the "restrict guest ticket creation to LDAP" setting — allows + * an already-provisioned local account too, so someone removed from LDAP + * after their account was created doesn't lose the ability to write in. + */ + protected function emailIsKnown(string $email): bool + { + return User::query()->where('email', $email)->exists() + || app(LdapUserProvisioner::class)->existsInLdap($email); + } + + public function render() + { + return view('livewire.landing'); + } +} diff --git a/src/app/Livewire/Operator/NewTicket.php b/src/app/Livewire/Operator/NewTicket.php new file mode 100644 index 0000000..af51fd3 --- /dev/null +++ b/src/app/Livewire/Operator/NewTicket.php @@ -0,0 +1,138 @@ +withRole('client')->orderBy('name')->get(); + } + + #[Computed] + public function categories() + { + return Category::query()->with('subcategories')->get(); + } + + #[Computed] + public function selectedCategory(): ?Category + { + return $this->categoryId ? Category::query()->with('subcategories')->find($this->categoryId) : null; + } + + #[Computed] + public function selectedSubcategory(): ?Subcategory + { + return $this->subcategoryId ? Subcategory::query()->with('customFields')->find($this->subcategoryId) : null; + } + + public function selectCategory(int $id): void + { + $this->categoryId = $id; + $this->subcategoryId = null; + $this->step = 2; + } + + public function selectSubcategory(int $id): void + { + $this->subcategoryId = $id; + $this->step = 3; + } + + public function backToCategory(): void + { + $this->step = 1; + } + + public function backToSubcategory(): void + { + $this->step = 2; + } + + public function updatedAttachments(): void + { + if (! $this->attachments) { + return; + } + + if ($error = Settings::validateAttachments($this->attachments)) { + $this->attachments = []; + $this->addError('attachments', $error); + } + } + + public function removeAttachment(int $index): void + { + unset($this->attachments[$index]); + $this->attachments = array_values($this->attachments); + } + + public function submit(): void + { + $rules = [ + 'customerId' => 'required|exists:users,id', + 'subject' => 'required|string|max:255', + 'body' => 'required|string', + ]; + + foreach ($this->selectedSubcategory?->customFields ?? [] as $field) { + $rules["customValues.{$field->id}"] = $field->required ? 'required' : 'nullable'; + } + + $this->validate($rules); + + $customer = User::query()->findOrFail($this->customerId); + + $ticket = app(TicketService::class)->create([ + 'email' => $customer->email, + 'subcategory_id' => $this->subcategoryId, + 'subject' => $this->subject, + 'body' => $this->body, + 'custom_values' => $this->customValues, + 'assignee_id' => Auth::id(), + ], $customer); + + $ticket->messages()->first()?->update([ + 'body' => $ticket->body.' (zgłoszenie utworzone przez operatora w imieniu klienta)', + ]); + + app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments); + + $this->redirect(route('operator.ticket', $ticket), navigate: true); + } + + public function render() + { + return view('livewire.operator.new-ticket'); + } +} diff --git a/src/app/Livewire/Operator/Queue.php b/src/app/Livewire/Operator/Queue.php new file mode 100644 index 0000000..aeb1f61 --- /dev/null +++ b/src/app/Livewire/Operator/Queue.php @@ -0,0 +1,320 @@ +orderBy('sort_order')->get(); + } + + /** + * The status filter's option list — "Otwarte" excludes closed tickets + * entirely (they live in their own "Zamknięte" tab), so offering it as a + * filter there would only ever produce an empty result. + */ + #[Computed] + public function filterableStatuses() + { + return $this->queue === 'all' + ? $this->statuses->reject(fn (Status $s) => $s->stage === 'closed') + : $this->statuses; + } + + #[Computed] + public function priorities() + { + return Priority::query()->orderBy('sort_order')->get(); + } + + #[Computed] + public function categories() + { + return Category::query()->with('subcategories')->get(); + } + + /** + * A non-admin operator only ever sees the teams they're actually a + * member of — both here (sidebar tabs) and via Ticket::visibleToOperator + * (which scopes the ticket lists/counts to match). + */ + #[Computed] + public function teams() + { + $query = Team::query(); + + if (! Auth::user()->isAdmin()) { + $query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id())); + } + + return $query->get(); + } + + #[Computed] + public function filteredCustomer(): ?User + { + return $this->filterCustomerId ? User::query()->find($this->filterCustomerId) : null; + } + + protected function queueDefs(): array + { + $closedKeys = Status::closedKeys(); + + $defs = [ + 'all' => ['label' => 'Otwarte', 'icon' => 'inbox', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNotIn('status_key', $closedKeys)], + 'mine' => ['label' => 'Moje zgłoszenia', 'icon' => 'assignment_ind', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->where('assignee_id', Auth::id())], + 'unassigned' => ['label' => 'Nieprzypisane', 'icon' => 'person_off', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNull('assignee_id')], + 'closed' => ['label' => 'Zamknięte', 'icon' => 'archive', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereIn('status_key', $closedKeys)], + ]; + + foreach ($this->teams as $team) { + $defs['team:'.$team->id] = [ + 'label' => $team->name, 'icon' => 'groups', 'group' => 'Zespoły', + 'filter' => fn ($q) => $q->where('team_id', $team->id), + ]; + } + + return $defs; + } + + #[Computed] + public function queueGroups(): array + { + $defs = $this->queueDefs(); + $groups = []; + + foreach ($defs as $key => $def) { + $groups[$def['group']] ??= []; + $groups[$def['group']][] = [ + 'key' => $key, + 'label' => $def['label'], + 'icon' => $def['icon'], + 'count' => ($def['filter'])(Ticket::query()->visibleToOperator(Auth::user()))->count(), + 'active' => $this->queue === $key, + ]; + } + + return $groups; + } + + #[Computed] + public function filteredTickets() + { + $defs = $this->queueDefs(); + $query = ($defs[$this->queue]['filter'] ?? fn ($q) => $q)(Ticket::query()->visibleToOperator(Auth::user())); + + if ($this->filterStatus !== 'all') { + $query->where('status_key', $this->filterStatus); + } + if ($this->filterPriority !== 'all') { + $query->where('priority_key', $this->filterPriority); + } + if ($this->filterCategory !== 'all') { + $query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory)); + } + if ($this->filterCustomerId) { + $query->where('customer_id', $this->filterCustomerId); + } + if (trim($this->search) !== '') { + $term = '%'.trim($this->search).'%'; + $query->where(fn ($q) => $q->where('number', 'like', $term) + ->orWhere('subject', 'like', $term) + ->orWhere('name', 'like', $term) + ->orWhere('email', 'like', $term)); + } + + $tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get(); + + return $this->sortTickets($tickets); + } + + /** + * Column headers are clickable rather than relying on a SQL orderBy, + * since two of the sortable columns (kategoria, przypisany) are derived + * from relations/labels rather than a plain ticket column — sorting the + * already-fetched (and typically small) collection in PHP keeps every + * column's sort using the same display value the operator actually sees. + */ + protected function sortTickets($tickets) + { + $desc = $this->sortDir === 'desc'; + + $sorted = match ($this->sortBy) { + 'number' => $tickets->sortBy(fn (Ticket $t) => (int) $t->number, SORT_REGULAR, $desc), + 'subject' => $tickets->sortBy('subject', SORT_NATURAL | SORT_FLAG_CASE, $desc), + 'customer' => $tickets->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE, $desc), + 'category' => $tickets->sortBy(fn (Ticket $t) => $t->categoryLabel(), SORT_NATURAL | SORT_FLAG_CASE, $desc), + 'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc), + 'status' => $tickets->sortBy(fn (Ticket $t) => $t->status?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc), + 'assignee' => $tickets->sortBy(fn (Ticket $t) => $t->assignee?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc), + default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc), + }; + + return $sorted->values(); + } + + /** + * @return array + */ + public function columnDefs(): array + { + return [ + 'number' => 'Numer', + 'subject' => 'Temat', + 'customer' => 'Klient', + 'category' => 'Kategoria', + 'priority' => 'Priorytet', + 'status' => 'Status', + 'sla' => 'SLA', + 'assignee' => 'Przypisany', + ]; + } + + /** + * SLA isn't a stored/stable value (it's computed from "now" at render + * time), so it's shown/hidden like any other column but excluded from + * click-to-sort. + */ + public function sortableColumns(): array + { + return ['number', 'subject', 'customer', 'category', 'priority', 'status', 'assignee']; + } + + public function sortByColumn(string $column): void + { + if (! in_array($column, $this->sortableColumns(), true)) { + return; + } + + if ($this->sortBy === $column) { + $this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortBy = $column; + $this->sortDir = 'asc'; + } + } + + public function toggleColumn(string $column): void + { + if (in_array($column, $this->visibleColumns, true)) { + // Always leave at least one column visible. + if (count($this->visibleColumns) <= 1) { + return; + } + $this->visibleColumns = array_values(array_diff($this->visibleColumns, [$column])); + } else { + $this->visibleColumns[] = $column; + } + } + + public function setQueue(string $key): void + { + $this->queue = $key; + $this->selectedIds = []; + + if ($key === 'all' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') { + $this->filterStatus = 'all'; + } + } + + public function clearCustomerFilter(): void + { + $this->filterCustomerId = null; + } + + public function toggleSelect(int $id): void + { + if (in_array($id, $this->selectedIds, true)) { + $this->selectedIds = array_values(array_diff($this->selectedIds, [$id])); + } else { + $this->selectedIds[] = $id; + } + } + + public function mergeSelected(): void + { + $ids = $this->selectedIdsInScope(); + + if (count($ids) < 2) { + return; + } + + app(TicketService::class)->merge($ids); + $this->selectedIds = []; + } + + public function requestDeleteSelected(): void + { + if (count($this->selectedIds) < 1) { + return; + } + + $this->pendingDeleteSelected = true; + } + + public function cancelDeleteSelected(): void + { + $this->pendingDeleteSelected = false; + } + + public function confirmDeleteSelected(): void + { + Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->delete(); + $this->selectedIds = []; + $this->pendingDeleteSelected = false; + } + + /** + * Guards against a crafted client-side call selecting ticket ids the + * operator wouldn't otherwise see, since selectedIds is just a public + * Livewire property. + */ + protected function selectedIdsInScope(): array + { + return Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->pluck('id')->all(); + } + + public function render() + { + return view('livewire.operator.queue'); + } +} diff --git a/src/app/Livewire/Operator/Stats.php b/src/app/Livewire/Operator/Stats.php new file mode 100644 index 0000000..62b848c --- /dev/null +++ b/src/app/Livewire/Operator/Stats.php @@ -0,0 +1,394 @@ +isAdmin()) { + $query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id())); + } + + return $query->orderBy('name')->get(); + } + + #[Computed] + public function priorities() + { + return Priority::query()->orderBy('sort_order')->get(); + } + + #[Computed] + public function categories() + { + return Category::query()->orderBy('name')->get(); + } + + #[Computed] + public function operators() + { + $query = User::query()->withRole('operator'); + + if (! Auth::user()->isAdmin()) { + $teamIds = $this->teams->pluck('id'); + $query->whereHas('teams', fn ($q) => $q->whereIn('teams.id', $teamIds)); + } + + return $query->orderBy('name')->get(); + } + + /** + * @return array{from: ?Carbon, to: ?Carbon} + */ + protected function bounds(): array + { + $now = now(); + + return match ($this->range) { + 'today' => ['from' => $now->clone()->startOfDay(), 'to' => $now], + '7d' => ['from' => $now->clone()->subDays(6)->startOfDay(), 'to' => $now], + '30d' => ['from' => $now->clone()->subDays(29)->startOfDay(), 'to' => $now], + '90d' => ['from' => $now->clone()->subDays(89)->startOfDay(), 'to' => $now], + 'custom' => [ + 'from' => $this->from ? Carbon::parse($this->from)->startOfDay() : $now->clone()->subDays(29)->startOfDay(), + 'to' => $this->to ? Carbon::parse($this->to)->endOfDay() : $now, + ], + default => ['from' => null, 'to' => null], + }; + } + + /** + * The shared, fully-filtered ticket scope every stat below reads from — + * cloned per terminal call (count/get/pluck) since none of those mutate + * the underlying where clauses, only cloning avoids one query's + * ->select()/->groupBy() leaking into the next. + */ + #[Computed] + public function baseQuery() + { + $query = Ticket::query()->visibleToOperator(Auth::user()); + $bounds = $this->bounds(); + + if ($bounds['from']) { + $query->where('tickets.created_at', '>=', $bounds['from']); + } + if ($bounds['to']) { + $query->where('tickets.created_at', '<=', $bounds['to']); + } + if ($this->filterTeam !== 'all') { + $query->where('tickets.team_id', $this->filterTeam); + } + if ($this->filterPriority !== 'all') { + $query->where('tickets.priority_key', $this->filterPriority); + } + if ($this->filterCategory !== 'all') { + $query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory)); + } + if ($this->filterAssignee === 'unassigned') { + $query->whereNull('tickets.assignee_id'); + } elseif ($this->filterAssignee !== 'all') { + $query->where('tickets.assignee_id', $this->filterAssignee); + } + + return $query; + } + + #[Computed] + public function kpis(): array + { + $total = (clone $this->baseQuery)->count(); + $closedKeys = Status::closedKeys(); + $closed = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->count(); + + return [ + 'total' => $total, + 'open' => $total - $closed, + 'closed' => $closed, + 'closedPct' => $total > 0 ? round($closed / $total * 100, 1) : null, + 'avgFirstResponseHours' => $this->avgFirstResponseHours(), + 'avgResolutionHours' => $this->avgResolutionHours($closedKeys), + 'sla' => $this->slaBreachStats($closedKeys), + ]; + } + + protected function avgFirstResponseHours(): ?float + { + $ids = (clone $this->baseQuery)->pluck('tickets.id'); + + if ($ids->isEmpty()) { + return null; + } + + $firstReplies = DB::table('ticket_messages as tm') + ->join('ticket_message_authors as tma', 'tma.ticket_message_id', '=', 'tm.id') + ->join('roles as r', 'r.id', '=', 'tma.role_id') + ->where('r.key', 'operator') + ->whereIn('tm.ticket_id', $ids) + ->select('tm.ticket_id', DB::raw('MIN(tm.created_at) as first_reply')) + ->groupBy('tm.ticket_id') + ->pluck('first_reply', 'ticket_id'); + + if ($firstReplies->isEmpty()) { + return null; + } + + $createdAts = Ticket::query()->whereIn('id', $firstReplies->keys())->pluck('created_at', 'id'); + + $minutes = $firstReplies->map(fn ($firstReply, $ticketId) => $createdAts[$ticketId]?->diffInMinutes(Carbon::parse($firstReply))) + ->filter(fn ($v) => $v !== null); + + return $minutes->isEmpty() ? null : round($minutes->avg() / 60, 1); + } + + /** + * Approximate: no dedicated "resolved_at" column exists, so a closed + * ticket's updated_at (touched whenever its status changes — see + * TicketService::setStatus()) stands in for when it was closed. + */ + protected function avgResolutionHours(array $closedKeys): ?float + { + $closed = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->get(['created_at', 'updated_at']); + + if ($closed->isEmpty()) { + return null; + } + + return round($closed->avg(fn (Ticket $t) => $t->created_at->diffInMinutes($t->updated_at)) / 60, 1); + } + + /** + * A ticket "breaches SLA" if its resolution deadline (priority's + * sla_rules.resolution_mins, from creation) has passed — either already, + * for a still-open ticket, or before it was closed (updated_at stands in + * for the close time, same approximation as avgResolutionHours()). + * Priorities with resolution_mins = 0 (e.g. "Brak") never breach. + */ + protected function slaBreachStats(array $closedKeys): array + { + $tickets = (clone $this->baseQuery)->get(['status_key', 'priority_key', 'created_at', 'updated_at']); + + if ($tickets->isEmpty()) { + return ['rate' => null, 'breached' => 0, 'total' => 0]; + } + + $rules = SlaRule::query()->pluck('resolution_mins', 'priority_key'); + $now = now(); + + $breached = $tickets->filter(function (Ticket $t) use ($rules, $closedKeys, $now) { + $mins = $rules[$t->priority_key] ?? 0; + + if ($mins <= 0) { + return false; + } + + $deadline = $t->created_at->clone()->addMinutes($mins); + + return in_array($t->status_key, $closedKeys, true) + ? $t->updated_at->isAfter($deadline) + : $now->isAfter($deadline); + })->count(); + + return [ + 'rate' => round($breached / $tickets->count() * 100, 1), + 'breached' => $breached, + 'total' => $tickets->count(), + ]; + } + + /** + * The "closed" stage's color, reused for the trend chart's closed-tickets + * strip so it visually matches the same status everywhere else in the + * app — falls back to the secondary brand hue if no status is closed. + */ + #[Computed] + public function closedColor(): string + { + return Status::query()->where('stage', 'closed')->value('color') ?? 'var(--color-accent-2)'; + } + + #[Computed] + public function byStatus() + { + $counts = (clone $this->baseQuery) + ->select('status_key', DB::raw('count(*) as total')) + ->groupBy('status_key') + ->pluck('total', 'status_key'); + + return Status::query()->orderBy('sort_order')->get()->map(fn (Status $s) => [ + 'label' => $s->label, + 'color' => $s->color, + 'count' => $counts[$s->key] ?? 0, + ])->values(); + } + + #[Computed] + public function byPriority() + { + $counts = (clone $this->baseQuery) + ->select('priority_key', DB::raw('count(*) as total')) + ->groupBy('priority_key') + ->pluck('total', 'priority_key'); + + return $this->priorities->map(fn (Priority $p) => [ + 'label' => $p->label, + 'color' => $p->color, + 'count' => $counts[$p->key] ?? 0, + ])->values(); + } + + #[Computed] + public function byCategory() + { + return (clone $this->baseQuery) + ->whereNotNull('tickets.subcategory_id') + ->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id') + ->join('categories', 'categories.id', '=', 'subcategories.category_id') + ->select('categories.name as label', DB::raw('count(*) as count')) + ->groupBy('categories.id', 'categories.name') + ->orderByDesc('count') + ->get() + ->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]); + } + + #[Computed] + public function byTeam() + { + $counts = (clone $this->baseQuery) + ->select('team_id', DB::raw('count(*) as total')) + ->groupBy('team_id') + ->pluck('total', 'team_id'); + + $rows = $this->teams->map(fn (Team $t) => ['label' => $t->name, 'count' => $counts[$t->id] ?? 0]) + ->sortByDesc('count') + ->values(); + + if ($counts->get(null, 0)) { + $rows->push(['label' => 'Bez zespołu', 'count' => $counts->get(null)]); + } + + return $rows; + } + + #[Computed] + public function byAssignee() + { + $counts = (clone $this->baseQuery) + ->select('assignee_id', DB::raw('count(*) as total')) + ->groupBy('assignee_id') + ->pluck('total', 'assignee_id'); + + $rows = $this->operators->map(fn (User $u) => ['label' => $u->name, 'count' => $counts[$u->id] ?? 0]) + ->filter(fn ($row) => $row['count'] > 0) + ->sortByDesc('count') + ->values(); + + if ($counts->get(null, 0)) { + $rows->push(['label' => 'Nieprzypisane', 'count' => $counts->get(null)]); + } + + return $rows; + } + + /** + * Daily created-vs-closed volume, capped at the most recent 60 days so a + * wide range (or "Cały okres") never renders an unreadably thin column + * per day — the KPI tiles/breakdowns above still reflect the full range. + */ + #[Computed] + public function trend(): array + { + $bounds = $this->bounds(); + $to = ($bounds['to'] ?? now())->clone()->startOfDay(); + $from = $bounds['from']?->clone()->startOfDay(); + + if (! $from) { + $earliest = (clone $this->baseQuery)->min('tickets.created_at'); + $from = $earliest ? Carbon::parse($earliest)->startOfDay() : $to->clone()->subDays(29); + } + + $maxDays = 60; + if ($from->diffInDays($to) + 1 > $maxDays) { + $from = $to->clone()->subDays($maxDays - 1); + } + + $created = (clone $this->baseQuery) + ->selectRaw('DATE(tickets.created_at) as d, count(*) as total') + ->groupBy('d')->pluck('total', 'd'); + + $closedKeys = Status::closedKeys(); + $closed = (clone $this->baseQuery) + ->whereIn('tickets.status_key', $closedKeys) + ->selectRaw('DATE(tickets.updated_at) as d, count(*) as total') + ->groupBy('d')->pluck('total', 'd'); + + $days = []; + $cursor = $from->clone(); + + while ($cursor->lte($to)) { + $key = $cursor->format('Y-m-d'); + $days[] = [ + 'date' => $key, + 'label' => $cursor->translatedFormat('d.m'), + 'created' => (int) ($created[$key] ?? 0), + 'closed' => (int) ($closed[$key] ?? 0), + ]; + $cursor->addDay(); + } + + return $days; + } + + public function setRange(string $range): void + { + $this->range = $range; + } + + public function render() + { + return view('livewire.operator.stats'); + } +} diff --git a/src/app/Livewire/Operator/TicketShow.php b/src/app/Livewire/Operator/TicketShow.php new file mode 100644 index 0000000..7fac847 --- /dev/null +++ b/src/app/Livewire/Operator/TicketShow.php @@ -0,0 +1,503 @@ + '', 'body' => '', 'categoryId' => '', 'subcategoryId' => '', 'customValues' => []]; + + public bool $editingReporter = false; + + public string $editReporterCustomerId = ''; + + public bool $editingTimer = false; + + public string $editTimerHours = '0'; + + public string $editTimerMinutes = '0'; + + public string $editTimerSeconds = '0'; + + public function mount(Ticket $ticket): void + { + abort_unless($ticket->isVisibleToOperator(Auth::user()), 403); + + $this->ticket = $ticket; + + // The timer tracks only actual time spent with the ticket open in + // the browser (see stopTimer() calls wired to leaving the page in + // ticket-show.blade.php) — so every open resumes it, not just the + // very first one. + $this->ticket->resumeTimer(); + } + + // -------- time tracking -------- + + public function stopTimer(): void + { + $this->ticket->stopTimer(); + } + + public function resumeTimer(): void + { + $this->ticket->resumeTimer(); + } + + public function resetTimer(): void + { + $this->ticket->resetTimer(); + } + + public function startEditTimer(): void + { + $seconds = $this->ticket->timerElapsedSeconds(); + $this->editTimerHours = (string) intdiv($seconds, 3600); + $this->editTimerMinutes = (string) intdiv($seconds % 3600, 60); + $this->editTimerSeconds = (string) ($seconds % 60); + $this->editingTimer = true; + } + + public function cancelEditTimer(): void + { + $this->editingTimer = false; + } + + public function saveEditTimer(): void + { + $seconds = ((int) $this->editTimerHours) * 3600 + + ((int) $this->editTimerMinutes) * 60 + + ((int) $this->editTimerSeconds); + + $this->ticket->setTimeSpent($seconds); + $this->editingTimer = false; + } + + public function updatedReplyAttachments(): void + { + if (! $this->replyAttachments) { + return; + } + + if ($error = Settings::validateAttachments($this->replyAttachments)) { + $this->replyAttachments = []; + $this->addError('replyAttachments', $error); + } + } + + public function removeReplyAttachment(int $index): void + { + unset($this->replyAttachments[$index]); + $this->replyAttachments = array_values($this->replyAttachments); + } + + public function updatedNoteAttachments(): void + { + if (! $this->noteAttachments) { + return; + } + + if ($error = Settings::validateAttachments($this->noteAttachments)) { + $this->noteAttachments = []; + $this->addError('noteAttachments', $error); + } + } + + public function removeNoteAttachment(int $index): void + { + unset($this->noteAttachments[$index]); + $this->noteAttachments = array_values($this->noteAttachments); + } + + #[Computed] + public function publicMessages() + { + return $this->ticket->publicMessages()->with(['author', 'authorLink.role', 'attachments'])->get(); + } + + #[Computed] + public function internalMessages() + { + return $this->ticket->internalMessages()->with(['author', 'authorLink.role', 'attachments'])->get(); + } + + #[Computed] + public function statuses() + { + return Status::query()->orderBy('sort_order')->get(); + } + + #[Computed] + public function priorities() + { + return Priority::query()->orderBy('sort_order')->get(); + } + + #[Computed] + public function categories() + { + return Category::query()->with('subcategories')->get(); + } + + /** + * A non-admin operator can only reassign a ticket to one of their own + * teams (mirrors the visibility scoping in Operator\Queue). + */ + #[Computed] + public function teams() + { + $query = Team::query(); + + if (! Auth::user()->isAdmin()) { + $query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id())); + } + + return $query->get(); + } + + #[Computed] + public function operators() + { + return User::query()->withRole('operator')->orderBy('name')->get(); + } + + #[Computed] + public function clients() + { + return User::query()->withRole('client')->orderBy('name')->get(); + } + + #[Computed] + public function responseTemplates() + { + return ResponseTemplate::query()->get(); + } + + #[Computed] + public function replyQuickActions() + { + return ReplyQuickAction::query()->orderBy('sort_order')->get(); + } + + #[Computed] + public function editDetailsSubcategoryOptions() + { + if (! $this->editDetailsForm['categoryId']) { + return collect(); + } + + return Subcategory::query()->where('category_id', $this->editDetailsForm['categoryId'])->get(); + } + + #[Computed] + public function editDetailsSelectedSubcategory(): ?Subcategory + { + return $this->editDetailsForm['subcategoryId'] + ? Subcategory::query()->with('customFields')->find($this->editDetailsForm['subcategoryId']) + : null; + } + + // -------- status / priority / assignee / team -------- + + public function setStatus(string $key): void + { + app(TicketService::class)->setStatus($this->ticket, $key); + $this->ticket->refresh(); + } + + public function setPriority(string $key): void + { + app(TicketService::class)->setPriority($this->ticket, $key); + $this->ticket->refresh(); + } + + public function setAssignee(string $id): void + { + app(TicketService::class)->setAssignee($this->ticket, $id ? User::query()->find($id) : null); + $this->ticket->refresh(); + } + + public function assignToMe(): void + { + app(TicketService::class)->setAssignee($this->ticket, Auth::user()); + $this->ticket->refresh(); + } + + public function setTeam(string $id): void + { + app(TicketService::class)->setTeam($this->ticket, $id ? Team::query()->find($id) : null); + $this->ticket->refresh(); + } + + // -------- reporter -------- + + public function startEditReporter(): void + { + $this->editingReporter = true; + $this->editReporterCustomerId = (string) $this->ticket->customer_id; + } + + public function cancelEditReporter(): void + { + $this->editingReporter = false; + } + + public function saveReporter(): void + { + $customer = User::query()->find($this->editReporterCustomerId); + + if ($customer) { + app(TicketService::class)->setReporter($this->ticket, $customer); + $this->ticket->refresh(); + } + + $this->editingReporter = false; + } + + // -------- details -------- + + public function toggleEditDetails(): void + { + $this->editingDetails = true; + $this->editDetailsForm = [ + 'subject' => $this->ticket->subject, + 'body' => $this->ticket->body, + 'categoryId' => $this->ticket->subcategory?->category_id, + 'subcategoryId' => $this->ticket->subcategory_id, + 'customValues' => $this->ticket->custom_fields ?? [], + ]; + } + + public function cancelEditDetails(): void + { + $this->editingDetails = false; + } + + public function updatedEditDetailsForm($value, $key): void + { + if ($key === 'categoryId') { + $this->editDetailsForm['subcategoryId'] = ''; + } + } + + public function saveDetails(): void + { + app(TicketService::class)->updateDetails($this->ticket, [ + 'subject' => $this->editDetailsForm['subject'], + 'body' => $this->editDetailsForm['body'], + 'subcategory_id' => $this->editDetailsForm['subcategoryId'] ?: null, + 'custom_values' => $this->editDetailsForm['customValues'], + ]); + $this->ticket->refresh(); + $this->editingDetails = false; + } + + // -------- internal notes -------- + + public function startAddNote(): void + { + $this->addingNote = true; + } + + public function cancelAddNote(): void + { + $this->addingNote = false; + $this->reset(['noteDraft', 'noteAttachments']); + } + + public function addNote(): void + { + if (! trim($this->noteDraft)) { + return; + } + + app(TicketService::class)->operatorNote($this->ticket, Auth::user(), $this->noteDraft, $this->noteAttachments); + $this->reset(['noteDraft', 'noteAttachments']); + $this->addingNote = false; + unset($this->internalMessages); + } + + public function startEditNote(int $id, string $body): void + { + $this->editingNoteId = $id; + $this->editingNoteDraft = $body; + } + + public function cancelEditNote(): void + { + $this->editingNoteId = null; + $this->editingNoteDraft = ''; + } + + public function saveEditNote(): void + { + $message = TicketMessage::query()->findOrFail($this->editingNoteId); + $message->update(['body' => $this->editingNoteDraft, 'edited' => true]); + $this->cancelEditNote(); + unset($this->internalMessages); + } + + // -------- public replies -------- + + public function setTemplate(string $id): void + { + $this->templateId = $id; + $template = ResponseTemplate::query()->find($id); + + if ($template) { + $this->reply = $template->body; + } + } + + protected function resetReply(): void + { + $this->reply = ''; + $this->replyAttachments = []; + $this->templateId = ''; + unset($this->publicMessages); + $this->ticket->refresh(); + } + + public function sendReply(): void + { + if (! trim($this->reply)) { + return; + } + + app(TicketService::class)->operatorReply($this->ticket, Auth::user(), $this->reply, attachments: $this->replyAttachments); + $this->resetReply(); + } + + public function sendAndTransition(int $quickActionId): void + { + if (! trim($this->reply)) { + return; + } + + $status = ReplyQuickAction::query()->find($quickActionId)?->status_key; + + app(TicketService::class)->operatorReply($this->ticket, Auth::user(), $this->reply, $status, $this->replyAttachments); + $this->resetReply(); + } + + // -------- message edit/delete (shared by public + internal bubbles) -------- + + public function startEditMessage(int $id, string $body): void + { + $message = TicketMessage::query()->findOrFail($id); + abort_unless($message->ticket_id === $this->ticket->id && $message->role === 'operator', 403); + + $this->editingMessageId = $id; + $this->editingMessageDraft = $body; + } + + public function cancelEditMessage(): void + { + $this->editingMessageId = null; + $this->editingMessageDraft = ''; + } + + public function saveEditMessage(): void + { + $message = TicketMessage::query()->findOrFail($this->editingMessageId); + abort_unless($message->ticket_id === $this->ticket->id && $message->role === 'operator', 403); + + $message->update(['body' => $this->editingMessageDraft, 'edited' => true]); + $this->cancelEditMessage(); + unset($this->publicMessages, $this->internalMessages); + } + + public function requestDeleteMessage(int $id): void + { + $this->pendingDeleteMessageId = $id; + } + + public function cancelDeleteMessage(): void + { + $this->pendingDeleteMessageId = null; + } + + public function confirmDeleteMessage(): void + { + $message = TicketMessage::query()->findOrFail($this->pendingDeleteMessageId); + abort_unless($message->ticket_id === $this->ticket->id && $message->role === 'operator', 403); + + $message->delete(); + $this->pendingDeleteMessageId = null; + unset($this->publicMessages, $this->internalMessages); + } + + // -------- delete ticket -------- + + public function requestDeleteTicket(): void + { + $this->pendingDeleteTicket = true; + } + + public function cancelDeleteTicket(): void + { + $this->pendingDeleteTicket = false; + } + + public function confirmDeleteTicket(): void + { + $this->ticket->delete(); + $this->redirect(route('operator.queue'), navigate: true); + } + + public function render() + { + // Checkpoints the running timer into the stored total on every + // action (any wire:click/change re-renders the component), so + // progress is saved incrementally rather than only on explicit stop. + $this->ticket->flushTimer(); + + return view('livewire.operator.ticket-show'); + } +} diff --git a/src/app/Models/ApiClient.php b/src/app/Models/ApiClient.php new file mode 100644 index 0000000..8f7eade --- /dev/null +++ b/src/app/Models/ApiClient.php @@ -0,0 +1,42 @@ +user() on + * routes protected by auth:sanctum. + */ +#[Fillable(['name', 'description', 'created_by', 'revoked_at'])] +class ApiClient extends Model implements AuthenticatableContract +{ + /** @use HasFactory */ + use Authenticatable, HasApiTokens, HasFactory; + + protected function casts(): array + { + return [ + 'revoked_at' => 'datetime', + ]; + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function isRevoked(): bool + { + return $this->revoked_at !== null; + } +} diff --git a/src/app/Models/Category.php b/src/app/Models/Category.php new file mode 100644 index 0000000..c0e66ab --- /dev/null +++ b/src/app/Models/Category.php @@ -0,0 +1,16 @@ +hasMany(Subcategory::class); + } +} diff --git a/src/app/Models/CustomField.php b/src/app/Models/CustomField.php new file mode 100644 index 0000000..ab1f3e1 --- /dev/null +++ b/src/app/Models/CustomField.php @@ -0,0 +1,38 @@ + 'boolean', + 'options' => 'array', + ]; + } + + public function subcategories(): BelongsToMany + { + return $this->belongsToMany(Subcategory::class, 'custom_field_subcategory') + ->withPivot('position'); + } + + public function typeLabel(): string + { + return match ($this->type) { + 'text' => 'Tekst krótki', + 'textarea' => 'Tekst długi', + 'select' => 'Lista wyboru', + 'checkbox' => 'Checkbox', + 'date' => 'Data', + 'number' => 'Liczba', + default => $this->type, + }; + } +} diff --git a/src/app/Models/EmailTemplate.php b/src/app/Models/EmailTemplate.php new file mode 100644 index 0000000..39caccc --- /dev/null +++ b/src/app/Models/EmailTemplate.php @@ -0,0 +1,26 @@ + $value) { + $text = str_replace('{'.$key.'}', (string) $value, $text); + } + + return $text; + }; + + return [ + 'subject' => $replace($this->subject), + 'body' => $replace($this->body), + ]; + } +} diff --git a/src/app/Models/NotificationSetting.php b/src/app/Models/NotificationSetting.php new file mode 100644 index 0000000..1a1f5e4 --- /dev/null +++ b/src/app/Models/NotificationSetting.php @@ -0,0 +1,21 @@ + 'boolean']; + } + + public function emailTemplate(): BelongsTo + { + return $this->belongsTo(EmailTemplate::class); + } +} diff --git a/src/app/Models/Priority.php b/src/app/Models/Priority.php new file mode 100644 index 0000000..459d4ad --- /dev/null +++ b/src/app/Models/Priority.php @@ -0,0 +1,32 @@ +hasOne(SlaRule::class, 'priority_key'); + } + + public static function labelFor(string $key): string + { + return static::query()->find($key)?->label ?? $key; + } + + public static function colorFor(string $key): string + { + return static::query()->find($key)?->color ?? '#75798c'; + } +} diff --git a/src/app/Models/ReplyQuickAction.php b/src/app/Models/ReplyQuickAction.php new file mode 100644 index 0000000..d194ae3 --- /dev/null +++ b/src/app/Models/ReplyQuickAction.php @@ -0,0 +1,15 @@ +status_key ? Status::labelFor($this->status_key) : 'Nie zmieniaj statusu'; + } +} diff --git a/src/app/Models/ResponseTemplate.php b/src/app/Models/ResponseTemplate.php new file mode 100644 index 0000000..e21184e --- /dev/null +++ b/src/app/Models/ResponseTemplate.php @@ -0,0 +1,12 @@ +belongsToMany(User::class); + } +} diff --git a/src/app/Models/Setting.php b/src/app/Models/Setting.php new file mode 100644 index 0000000..26ce9f8 --- /dev/null +++ b/src/app/Models/Setting.php @@ -0,0 +1,16 @@ +belongsTo(Priority::class, 'priority_key'); + } +} diff --git a/src/app/Models/Status.php b/src/app/Models/Status.php new file mode 100644 index 0000000..d549a3d --- /dev/null +++ b/src/app/Models/Status.php @@ -0,0 +1,46 @@ + 'boolean']; + } + + public static function labelFor(string $key): string + { + return static::query()->find($key)?->label ?? $key; + } + + public static function colorFor(string $key): string + { + return static::query()->find($key)?->color ?? '#75798c'; + } + + public static function stageFor(string $key): string + { + return static::query()->find($key)?->stage ?? 'open'; + } + + /** + * Keys whose stage is "closed" — in practice always just ['closed'], + * since Admin can only ever add new statuses into the "open" stage, but + * this stays generic instead of hardcoding that assumption everywhere. + */ + public static function closedKeys(): array + { + return static::query()->where('stage', 'closed')->pluck('key')->all(); + } +} diff --git a/src/app/Models/Subcategory.php b/src/app/Models/Subcategory.php new file mode 100644 index 0000000..6b63eca --- /dev/null +++ b/src/app/Models/Subcategory.php @@ -0,0 +1,40 @@ +belongsTo(Category::class); + } + + public function customFields(): BelongsToMany + { + return $this->belongsToMany(CustomField::class, 'custom_field_subcategory') + ->withPivot('position') + ->orderBy('custom_field_subcategory.position'); + } + + public function teams(): BelongsToMany + { + return $this->belongsToMany(Team::class, 'team_subcategory'); + } + + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class); + } + + public function label(): string + { + return $this->category->name.' / '.$this->name; + } +} diff --git a/src/app/Models/Team.php b/src/app/Models/Team.php new file mode 100644 index 0000000..a80aa1b --- /dev/null +++ b/src/app/Models/Team.php @@ -0,0 +1,27 @@ +belongsToMany(User::class, 'team_user'); + } + + public function subcategories(): BelongsToMany + { + return $this->belongsToMany(Subcategory::class, 'team_subcategory'); + } + + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class); + } +} diff --git a/src/app/Models/Ticket.php b/src/app/Models/Ticket.php new file mode 100644 index 0000000..86e3067 --- /dev/null +++ b/src/app/Models/Ticket.php @@ -0,0 +1,337 @@ + 'array', + 'sla_notified_at' => 'datetime', + 'time_spent_seconds' => 'integer', + 'timer_started_at' => 'datetime', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(User::class, 'customer_id'); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assignee_id'); + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function apiClient(): BelongsTo + { + return $this->belongsTo(ApiClient::class); + } + + public function subcategory(): BelongsTo + { + return $this->belongsTo(Subcategory::class); + } + + public function status(): BelongsTo + { + return $this->belongsTo(Status::class, 'status_key'); + } + + public function priority(): BelongsTo + { + return $this->belongsTo(Priority::class, 'priority_key'); + } + + public function messages(): HasMany + { + return $this->hasMany(TicketMessage::class)->orderBy('created_at'); + } + + public function publicMessages(): HasMany + { + return $this->messages()->where('internal', false); + } + + public function internalMessages(): HasMany + { + return $this->messages()->where('internal', true); + } + + public function attachments(): HasMany + { + return $this->hasMany(TicketAttachment::class); + } + + public function histories(): HasMany + { + return $this->hasMany(TicketHistory::class)->orderByDesc('created_at'); + } + + public static function nextNumber(): string + { + $max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max(); + + return (string) (($max ?: 1000) + 1); + } + + public function categoryLabel(): string + { + return $this->subcategory?->label() ?? ''; + } + + /** + * Non-admin operators only see tickets belonging to one of their own + * teams (or not yet routed to any team), plus anything assigned to them + * personally regardless of team. Admins see everything. + */ + public function scopeVisibleToOperator(Builder $query, User $user): Builder + { + if ($user->isAdmin()) { + return $query; + } + + $teamIds = $user->teams->pluck('id')->all(); + + return $query->where(function ($q) use ($teamIds, $user) { + $q->whereNull('team_id') + ->orWhereIn('team_id', $teamIds) + ->orWhere('assignee_id', $user->id); + }); + } + + public function isVisibleToOperator(User $user): bool + { + if ($user->isAdmin()) { + return true; + } + + return $this->team_id === null + || $user->teams->pluck('id')->contains($this->team_id) + || $this->assignee_id === $user->id; + } + + public function addHistory(string $text): TicketHistory + { + return $this->histories()->create(['text' => $text, 'created_at' => now()]); + } + + public function statusLabel(): string + { + return Status::labelFor($this->status_key); + } + + public function priorityLabel(): string + { + return Priority::labelFor($this->priority_key); + } + + public function statusStyle(): string + { + return static::tagStyleFromColor(Status::colorFor($this->status_key)); + } + + public function priorityStyle(): string + { + return static::tagStyleFromColor(Priority::colorFor($this->priority_key)); + } + + public static function tagStyleFromColor(string $hex): string + { + return 'display:inline-flex;align-items:center;font-size:11px;letter-spacing:0.02em;padding:3px 10px;border-radius:6px;'. + "background:color-mix(in srgb, {$hex} 20%, transparent);color:{$hex};". + "border:1px solid color-mix(in srgb, {$hex} 45%, transparent)"; + } + + public function isClosed(): bool + { + return Status::stageFor($this->status_key) === 'closed'; + } + + /** + * A resolution time of 0 minutes means "no SLA" for that priority, not + * "due instantly" — such tickets never count down and never breach. + */ + protected function resolutionDeadline(): ?Carbon + { + $rule = $this->priority?->slaRule; + + return $rule && $rule->resolution_mins > 0 + ? $this->created_at->clone()->addMinutes($rule->resolution_mins) + : null; + } + + /** + * Whether the SLA resolution deadline has already passed — used by the + * scheduled SLA-breach check, kept separate from slaInfo() so it doesn't + * depend on parsing that method's display text. + */ + public function isOverdue(): bool + { + if ($this->isClosed()) { + return false; + } + + $deadline = $this->resolutionDeadline(); + + return $deadline !== null && now()->isAfter($deadline); + } + + /** + * Mirrors the design prototype's slaInfo(): overdue / remaining-hours / closed. + */ + public function slaInfo(): array + { + if ($this->isClosed()) { + return ['text' => 'Zamknięte', 'short' => '—', 'cls' => 'tag tag-neutral']; + } + + $rule = $this->priority?->slaRule; + $deadline = $this->resolutionDeadline(); + + if (! $rule || $deadline === null) { + return ['text' => 'Brak SLA', 'short' => 'Brak SLA', 'cls' => 'tag tag-neutral']; + } + + $diffMinutes = now()->diffInMinutes($deadline, false); + + if ($diffMinutes < 0) { + return ['text' => 'Przekroczono SLA', 'short' => 'Przekroczono', 'cls' => 'tag tag-accent']; + } + + $hrs = (int) round($diffMinutes / 60); + $warn = $diffMinutes < ($rule->resolution_mins * 0.25); + + return [ + 'text' => "Pozostało {$hrs} godz.", + 'short' => "{$hrs} godz.", + 'cls' => $warn ? 'tag tag-outline' : 'tag tag-neutral', + ]; + } + + /** + * Total seconds an operator has spent with this ticket's detail view + * open, including the currently running segment (if the timer is active). + */ + public function timerElapsedSeconds(): int + { + $running = $this->timer_started_at ? $this->secondsSinceTimerStarted() : 0; + + return $this->time_spent_seconds + $running; + } + + /** + * Whole seconds between timer_started_at and now, via raw timestamp + * subtraction rather than diffInSeconds() — Carbon 3 made the sign and + * float-vs-int return type of diffInSeconds() depend on argument order, + * which is easy to get backwards. + */ + protected function secondsSinceTimerStarted(): int + { + return max(0, now()->getTimestamp() - $this->timer_started_at->getTimestamp()); + } + + public function timeSpentLabel(): string + { + $seconds = $this->timerElapsedSeconds(); + $hrs = intdiv($seconds, 3600); + $mins = intdiv($seconds % 3600, 60); + + return $hrs > 0 ? "{$hrs} godz. {$mins} min." : "{$mins} min."; + } + + /** + * Checkpoints the running segment into the stored total without pausing + * — called on every operator action so progress survives even if the + * ticket is never explicitly stopped. + */ + public function flushTimer(): void + { + if ($this->timer_started_at) { + $this->update([ + 'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(), + 'timer_started_at' => now(), + ]); + } + } + + public function stopTimer(): void + { + if ($this->timer_started_at) { + $this->update([ + 'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(), + 'timer_started_at' => null, + ]); + } + } + + public function resumeTimer(): void + { + if (! $this->timer_started_at) { + $this->update(['timer_started_at' => now()]); + } + } + + public function resetTimer(): void + { + $this->update(['time_spent_seconds' => 0, 'timer_started_at' => null]); + } + + /** + * Manually overrides the tracked total (an operator correcting the + * timer). If it's currently running, the running segment is re-based to + * start now, so the edited value takes effect immediately instead of + * being added to on top of the segment already in progress. + */ + public function setTimeSpent(int $seconds): void + { + $this->update([ + 'time_spent_seconds' => max(0, $seconds), + 'timer_started_at' => $this->timer_started_at ? now() : null, + ]); + } + + /** + * Custom field values for this ticket's subcategory, in display order, skipping blanks. + * + * @return array + */ + public function customFieldEntries(): array + { + if (! $this->subcategory_id) { + return []; + } + + $values = $this->custom_fields ?? []; + + return $this->subcategory->customFields + ->filter(fn (CustomField $field) => array_key_exists($field->id, $values) && $values[$field->id] !== '' && $values[$field->id] !== null) + ->map(fn (CustomField $field) => [ + 'id' => $field->id, + 'label' => $field->label, + 'value' => $field->type === 'checkbox' + ? ($values[$field->id] ? 'Tak' : 'Nie') + : (string) $values[$field->id], + ]) + ->values() + ->all(); + } +} diff --git a/src/app/Models/TicketAttachment.php b/src/app/Models/TicketAttachment.php new file mode 100644 index 0000000..3fde8bb --- /dev/null +++ b/src/app/Models/TicketAttachment.php @@ -0,0 +1,21 @@ +belongsTo(Ticket::class); + } + + public function message(): BelongsTo + { + return $this->belongsTo(TicketMessage::class, 'message_id'); + } +} diff --git a/src/app/Models/TicketHistory.php b/src/app/Models/TicketHistory.php new file mode 100644 index 0000000..ff3c7a5 --- /dev/null +++ b/src/app/Models/TicketHistory.php @@ -0,0 +1,25 @@ + 'datetime', + ]; + } + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } +} diff --git a/src/app/Models/TicketMessage.php b/src/app/Models/TicketMessage.php new file mode 100644 index 0000000..40f20e4 --- /dev/null +++ b/src/app/Models/TicketMessage.php @@ -0,0 +1,89 @@ + 'boolean', + 'edited' => 'boolean', + ]; + } + + /** + * "role" and "author_id" are read-only virtual attributes derived from + * authorLink (see below) — neither is a real column anymore, so this + * keeps every existing `$message->role`/`$message->author_id` read + * working unchanged. Write via attachAuthor() instead. + */ + public function getAttribute($key) + { + if ($key === 'role') { + return $this->authorLink?->role?->key ?? 'system'; + } + + if ($key === 'author_id') { + return $this->authorLink?->user_id; + } + + return parent::getAttribute($key); + } + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } + + public function apiClient(): BelongsTo + { + return $this->belongsTo(ApiClient::class); + } + + /** + * Who wrote this message and in what role — replaces the old flat + * author_id/role columns. A message with no row here is a + * system-generated entry (no author, no role). + */ + public function authorLink(): HasOne + { + return $this->hasOne(TicketMessageAuthor::class); + } + + public function author(): HasOneThrough + { + return $this->hasOneThrough(User::class, TicketMessageAuthor::class, 'ticket_message_id', 'id', 'id', 'user_id'); + } + + /** + * Links this message to the given author + role (or does nothing for a + * system message, i.e. $roleKey === null) — the write-side counterpart + * to the role/author_id reads above, since neither is a plain column + * anymore. + */ + public function attachAuthor(?int $userId, ?string $roleKey): void + { + if ($roleKey === null) { + return; + } + + $this->authorLink()->create([ + 'user_id' => $userId, + 'role_id' => Role::query()->where('key', $roleKey)->value('id'), + ]); + } + + public function attachments(): HasMany + { + return $this->hasMany(TicketAttachment::class, 'message_id'); + } +} diff --git a/src/app/Models/TicketMessageAuthor.php b/src/app/Models/TicketMessageAuthor.php new file mode 100644 index 0000000..7cf1b41 --- /dev/null +++ b/src/app/Models/TicketMessageAuthor.php @@ -0,0 +1,33 @@ +belongsTo(TicketMessage::class, 'ticket_message_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function role(): BelongsTo + { + return $this->belongsTo(Role::class); + } +} diff --git a/src/app/Models/User.php b/src/app/Models/User.php new file mode 100644 index 0000000..f092a1e --- /dev/null +++ b/src/app/Models/User.php @@ -0,0 +1,223 @@ + */ + use AuthenticatesWithLdap, HasFactory, Notifiable; + + /** + * Pending values for the "roles" / "custom_field_values" virtual + * attributes (see getAttribute()/setAttribute() below) — neither is a + * real column anymore, so a plain write can't land in $attributes; it's + * stashed here until saved() flushes it into role_user / user_field_values. + */ + protected ?array $pendingRoleKeys = null; + + protected ?array $pendingFieldValues = null; + + protected static function booted(): void + { + static::saved(function (User $user) { + if ($user->pendingRoleKeys !== null) { + $ids = Role::query()->whereIn('key', $user->pendingRoleKeys)->pluck('id'); + $user->roleAssignments()->sync($ids); + $user->unsetRelation('roleAssignments'); + $user->pendingRoleKeys = null; + } + + if ($user->pendingFieldValues !== null) { + $user->syncFieldValues($user->pendingFieldValues); + $user->unsetRelation('fieldValues'); + $user->pendingFieldValues = null; + } + }); + } + + /** + * Get an attribute's value. Overridden (rather than using an Eloquent + * "Attribute" get/set mutator) because "roles" and "custom_field_values" + * no longer back a real column at all — there's nothing for the + * mutator's return value to be written into. + */ + public function getAttribute($key) + { + if ($key === 'roles') { + return $this->pendingRoleKeys ?? $this->roleAssignments->pluck('key')->all(); + } + + if ($key === 'custom_field_values') { + return $this->pendingFieldValues ?? $this->fieldValues->pluck('value', 'user_field_id')->all(); + } + + return parent::getAttribute($key); + } + + public function setAttribute($key, $value) + { + if ($key === 'roles') { + $this->pendingRoleKeys = $value ?? []; + + return $this; + } + + if ($key === 'custom_field_values') { + $this->pendingFieldValues = $value ?? []; + + return $this; + } + + return parent::setAttribute($key, $value); + } + + protected function syncFieldValues(array $values): void + { + $keep = []; + + foreach ($values as $fieldId => $value) { + if ($value === null || $value === '') { + continue; + } + + $keep[] = $fieldId; + + UserFieldValue::query()->updateOrCreate( + ['user_id' => $this->id, 'user_field_id' => $fieldId], + ['value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value] + ); + } + + UserFieldValue::query()->where('user_id', $this->id)->whereNotIn('user_field_id', $keep)->delete(); + } + + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } + + /** + * The roles this user actually holds, stored via the role_user pivot — + * see the roles virtual attribute above for the plain-array reader/writer + * that everything else in the app uses instead of this relation directly. + */ + public function roleAssignments(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } + + /** + * Filters to users holding the given role — replaces the old + * whereJsonContains('roles', $key) now that roles live in role_user. + */ + public function scopeWithRole(Builder $query, string $roleKey): Builder + { + return $query->whereHas('roleAssignments', fn (Builder $q) => $q->where('key', $roleKey)); + } + + /** + * A user can hold several roles at once (e.g. operator + admin) — each + * grants access to its matching area independently. + */ + public function hasRole(string $role): bool + { + return in_array($role, $this->roles ?? [], true); + } + + public function isClient(): bool + { + return $this->hasRole('client'); + } + + public function isOperator(): bool + { + return $this->hasRole('operator'); + } + + public function isAdmin(): bool + { + return $this->hasRole('admin'); + } + + /** + * Where to land a user with (possibly) several roles right after login — + * always the client area, even for admins/operators, since every account + * carries the "client" role by default (see AssignDefaultRole) and staff + * switch into their other areas afterwards via the role switcher in the + * header rather than landing there automatically. + */ + public function defaultArea(): string + { + return match (true) { + $this->isClient() => '/client', + $this->isAdmin() => '/admin', + $this->isOperator() => '/operator', + default => '/client', + }; + } + + public function teams(): BelongsToMany + { + return $this->belongsToMany(Team::class, 'team_user'); + } + + public function ticketsAsCustomer(): HasMany + { + return $this->hasMany(Ticket::class, 'customer_id'); + } + + public function ticketsAssigned(): HasMany + { + return $this->hasMany(Ticket::class, 'assignee_id'); + } + + /** + * The admin-defined "user field" values, stored one row per field in + * user_field_values — see the custom_field_values virtual attribute + * above for the plain array-by-field-id reader/writer everything else + * in the app uses instead of this relation directly. + */ + public function fieldValues(): HasMany + { + return $this->hasMany(UserFieldValue::class); + } + + /** + * The admin-defined "user field" values that actually have something in + * them, label + display value, for showing next to a reporter/user + * elsewhere in the app (mirrors Ticket::customFieldEntries()). + */ + public function customFieldEntries(): array + { + $values = $this->custom_field_values ?? []; + + return UserField::query()->orderBy('sort_order')->get() + ->filter(fn (UserField $field) => array_key_exists($field->id, $values) && $values[$field->id] !== '' && $values[$field->id] !== null) + ->map(fn (UserField $field) => [ + 'label' => $field->label, + 'value' => $field->type === 'checkbox' + ? ($values[$field->id] ? 'Tak' : 'Nie') + : (string) $values[$field->id], + ]) + ->values() + ->all(); + } +} diff --git a/src/app/Models/UserField.php b/src/app/Models/UserField.php new file mode 100644 index 0000000..d8e3dc3 --- /dev/null +++ b/src/app/Models/UserField.php @@ -0,0 +1,30 @@ + 'array', + ]; + } + + public function typeLabel(): string + { + return match ($this->type) { + 'text' => 'Tekst krótki', + 'textarea' => 'Tekst długi', + 'select' => 'Lista wyboru', + 'checkbox' => 'Checkbox', + 'date' => 'Data', + 'number' => 'Liczba', + default => $this->type, + }; + } +} diff --git a/src/app/Models/UserFieldValue.php b/src/app/Models/UserFieldValue.php new file mode 100644 index 0000000..9fb6dd5 --- /dev/null +++ b/src/app/Models/UserFieldValue.php @@ -0,0 +1,21 @@ +belongsTo(User::class); + } + + public function field(): BelongsTo + { + return $this->belongsTo(UserField::class, 'user_field_id'); + } +} diff --git a/src/app/Notifications/TicketNotification.php b/src/app/Notifications/TicketNotification.php new file mode 100644 index 0000000..f884d48 --- /dev/null +++ b/src/app/Notifications/TicketNotification.php @@ -0,0 +1,50 @@ +find($this->emailTemplateId); + + $firstName = trim(explode(' ', $this->ticket->name)[0] ?? $this->ticket->name); + + $rendered = $template?->render([ + 'numer' => $this->ticket->number, + 'imie' => $firstName, + 'temat' => $this->ticket->subject, + 'status' => $this->ticket->statusLabel(), + 'kategoria' => $this->ticket->categoryLabel(), + 'priorytet' => $this->ticket->priorityLabel(), + 'zespol' => $this->ticket->team?->name ?? 'Brak', + 'operator' => $this->ticket->assignee?->name ?? 'Nieprzypisane', + 'link' => route('client.ticket', $this->ticket), + ]) ?? [ + 'subject' => 'Zgłoszenie #'.$this->ticket->number, + 'body' => $this->ticket->subject, + ]; + + return (new MailMessage) + ->subject($rendered['subject']) + ->view('emails.ticket-notification', [ + 'html' => Settings::renderEmailLayout($rendered['body']), + ]); + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..44dda10 --- /dev/null +++ b/src/app/Providers/AppServiceProvider.php @@ -0,0 +1,169 @@ +applyLdapSettingsOverride(); + $this->applyMailSettingsOverride(); + $this->applySessionSettingsOverride(); + $this->applyTimezoneSettingsOverride(); + $this->configureApiRateLimiting(); + + Relation::enforceMorphMap(['api_client' => ApiClient::class]); + } + + /** + * API keys get a generous per-key budget; unauthenticated requests (which + * only ever hit the guard before rejecting with 401) get a much smaller + * per-IP one so a single misconfigured client can't exhaust it for everyone. + */ + protected function configureApiRateLimiting(): void + { + RateLimiter::for('api', function (Request $request) { + $clientId = $request->user()?->id; + + return $clientId + ? Limit::perMinute(120)->by('api-client:'.$clientId) + : Limit::perMinute(30)->by('ip:'.$request->ip()); + }); + } + + /** + * Avoid touching the DB during artisan commands that run before the + * `settings` table exists (e.g. `migrate` itself), or before it can be + * queried at all — shared by every settings-driven config override below. + */ + protected function settingsTableUsable(): bool + { + if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) { + return false; + } + + try { + return Schema::hasTable('settings'); + } catch (\Throwable) { + return false; + } + } + + /** + * Let the Admin -> Konfiguracja -> LDAP/AD screen override the .env-based + * LDAP connection at runtime, so changes take effect without a redeploy. + */ + protected function applyLdapSettingsOverride(): void + { + if (! $this->settingsTableUsable()) { + return; + } + + $host = Settings::get('ldap_host'); + + if (! $host) { + return; + } + + $config = Config::get('ldap.connections.default', []); + $config['hosts'] = [$host]; + $config['port'] = (int) Settings::get('ldap_port', $config['port'] ?? 389); + $config['base_dn'] = Settings::get('ldap_base_dn', $config['base_dn'] ?? ''); + $config['username'] = Settings::get('ldap_bind_dn', $config['username'] ?? ''); + $config['password'] = Settings::get('ldap_bind_password') ?? $config['password'] ?? ''; + $config['use_tls'] = Settings::bool('ldap_use_ssl'); + + Config::set('ldap.connections.default', $config); + Container::addConnection(new Connection($config), 'default'); + } + + /** + * Let the Admin -> Konfiguracja -> E-mail (SMTP) screen override the + * .env-based mail transport/sender at runtime. Sender address/name and + * footer apply regardless; the SMTP transport itself only when the admin + * has explicitly enabled it (otherwise the .env `MAIL_MAILER` stands). + */ + protected function applyMailSettingsOverride(): void + { + if (! $this->settingsTableUsable()) { + return; + } + + if (Settings::bool('mail_smtp_enabled') && Settings::get('mail_smtp_host')) { + Config::set('mail.default', 'smtp'); + Config::set('mail.mailers.smtp.host', Settings::get('mail_smtp_host')); + Config::set('mail.mailers.smtp.port', (int) Settings::get('mail_smtp_port', '587')); + Config::set('mail.mailers.smtp.username', Settings::get('mail_smtp_username') ?: null); + Config::set('mail.mailers.smtp.password', Settings::get('mail_smtp_password')); + Config::set('mail.mailers.smtp.scheme', match (Settings::get('mail_smtp_encryption')) { + 'ssl' => 'smtps', + 'tls' => 'smtp', + default => null, + }); + } + + if ($address = Settings::get('mail_from_address')) { + Config::set('mail.from.address', $address); + Config::set('mail.from.name', Settings::get('mail_from_name') ?: Settings::get('company_name')); + } + } + + /** + * Let the Admin -> Konfiguracja -> "Czas wygaśnięcia sesji" field override + * the .env-based session lifetime. Only affects sessions created/renewed + * after the change — already-active sessions keep their existing expiry. + */ + protected function applySessionSettingsOverride(): void + { + if (! $this->settingsTableUsable()) { + return; + } + + $minutes = Settings::get('session_lifetime_minutes'); + + if ($minutes) { + Config::set('session.lifetime', (int) $minutes); + } + } + + /** + * Let the Admin -> Konfiguracja -> "Strefa czasowa" field override the + * .env-based app timezone at runtime, so every date shown or stored + * (ticket timestamps, SLA deadlines, "x minutes ago" labels, ...) + * reflects the admin's chosen zone instead of the container's UTC default. + */ + protected function applyTimezoneSettingsOverride(): void + { + if (! $this->settingsTableUsable()) { + return; + } + + $timezone = Settings::timezone(); + + Config::set('app.timezone', $timezone); + date_default_timezone_set($timezone); + } +} diff --git a/src/app/Services/LdapUserProvisioner.php b/src/app/Services/LdapUserProvisioner.php new file mode 100644 index 0000000..e28f284 --- /dev/null +++ b/src/app/Services/LdapUserProvisioner.php @@ -0,0 +1,127 @@ + + * Użytkownicy -> "Zaproś użytkownika") when the admin has restricted it + * to only known LDAP identities — unlike findOrCreateByEmail(), this + * never creates anything. + */ + public function existsInLdap(string $email): bool + { + return $this->findLdapEntryByEmail($email) !== null; + } + + public function findOrCreateByEmail(string $email): ?User + { + if ($user = User::query()->where('email', $email)->first()) { + return $user; + } + + $ldapEntry = $this->findLdapEntryByEmail($email); + + if (! $ldapEntry) { + return null; + } + + $user = User::query()->create([ + 'name' => $ldapEntry->getFirstAttribute('cn') ?? $email, + 'email' => $email, + 'roles' => ['client'], + ]); + + $user->guid = $ldapEntry->getConvertedGuid(); + $user->save(); + + $this->applyFieldsFromLdap($user, $ldapEntry); + + return $user; + } + + /** + * Re-pull user field values from LDAP for every existing local user. + * Returns how many were actually matched against an LDAP entry. + */ + public function syncAll(): int + { + $matched = 0; + + foreach (User::query()->get() as $user) { + $ldapEntry = $this->findLdapEntryForUser($user); + + if (! $ldapEntry) { + continue; + } + + $this->applyFieldsFromLdap($user, $ldapEntry); + $matched++; + } + + return $matched; + } + + protected function findLdapEntryForUser(User $user): ?LldapUser + { + if ($user->guid) { + try { + $byGuid = LldapUser::query()->where('entryuuid', '=', $user->guid)->first(); + } catch (Throwable $e) { + Log::warning('LDAP lookup by guid failed: '.$e->getMessage()); + $byGuid = null; + } + + if ($byGuid) { + return $byGuid; + } + } + + return $this->findLdapEntryByEmail($user->email); + } + + protected function findLdapEntryByEmail(string $email): ?LldapUser + { + try { + return LldapUser::query()->where('mail', '=', $email)->first(); + } catch (Throwable $e) { + Log::warning('LDAP lookup by email failed: '.$e->getMessage()); + + return null; + } + } + + public function applyFieldsFromLdap(User $user, LldapUser $ldapEntry): void + { + $values = $user->custom_field_values ?? []; + + foreach (UserField::query()->whereNotNull('ldap_attribute')->where('ldap_attribute', '!=', '')->get() as $field) { + $value = $ldapEntry->getFirstAttribute($field->ldap_attribute); + + if ($value !== null) { + $values[$field->id] = $value; + } + } + + $user->custom_field_values = $values; + $user->save(); + } +} diff --git a/src/app/Services/TicketService.php b/src/app/Services/TicketService.php new file mode 100644 index 0000000..e7d14f0 --- /dev/null +++ b/src/app/Services/TicketService.php @@ -0,0 +1,288 @@ +findOrCreateByEmail($data['email']); + } + + $subcategory = ! empty($data['subcategory_id']) + ? Subcategory::query()->find($data['subcategory_id']) + : null; + + $ticket = Ticket::query()->create([ + 'number' => Ticket::nextNumber(), + 'customer_id' => $customer?->id, + 'email' => $customer?->email ?? $data['email'], + 'name' => $customer?->name ?? ($data['name'] ?? $data['email']), + 'subcategory_id' => $subcategory?->id, + 'subject' => $data['subject'], + 'body' => $data['body'], + 'status_key' => Settings::get('default_status', 'new'), + 'priority_key' => $subcategory?->default_priority_key ?: 'medium', + 'team_id' => $this->autoAssignTeam($subcategory), + 'assignee_id' => $data['assignee_id'] ?? null, + 'custom_fields' => $data['custom_values'] ?? [], + ]); + + $message = $ticket->messages()->create([ + 'author_name' => $authorName ?? $ticket->name, + 'body' => $data['body'], + ]); + $message->attachAuthor($customer?->id, 'client'); + + $this->notify($ticket, 'ticket_created'); + + return $ticket; + } + + protected function autoAssignTeam(?Subcategory $subcategory): ?int + { + if (! $subcategory || ! Settings::bool('auto_assign_by_category')) { + return null; + } + + return Team::query()->whereHas('subcategories', fn ($q) => $q->where('subcategories.id', $subcategory->id)) + ->value('id'); + } + + public function setStatus(Ticket $ticket, string $statusKey): void + { + // Any status change (closing, reopening, moving between open sub-statuses) + // resets the SLA-breach alert so a still-open ticket can report again later. + $ticket->update(['status_key' => $statusKey, 'sla_notified_at' => null]); + $ticket->addHistory('Status zmieniony na: '.Status::labelFor($statusKey)); + + $this->notify($ticket, 'status_changed'); + + if ($statusKey === 'closed') { + $this->notify($ticket, 'ticket_closed'); + } + } + + public function setPriority(Ticket $ticket, string $priorityKey): void + { + $ticket->update(['priority_key' => $priorityKey]); + $ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey)); + $this->notify($ticket, 'priority_changed'); + } + + public function setAssignee(Ticket $ticket, ?User $assignee): void + { + // A newly assigned operator should get a fresh chance at an SLA-breach + // alert instead of staying silent because the previous one already fired. + $ticket->update(['assignee_id' => $assignee?->id, 'sla_notified_at' => null]); + $ticket->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane')); + $this->notify($ticket, 'assignee_changed'); + } + + public function setTeam(Ticket $ticket, ?Team $team): void + { + $ticket->update(['team_id' => $team?->id]); + $ticket->addHistory('Zespół zmieniony na: '.($team?->name ?? 'Brak')); + $this->notify($ticket, 'team_changed'); + } + + public function setReporter(Ticket $ticket, User $customer): void + { + $ticket->update(['customer_id' => $customer->id, 'email' => $customer->email, 'name' => $customer->name]); + $ticket->addHistory('Zgłaszający zmieniony na: '.$customer->name); + } + + public function updateDetails(Ticket $ticket, array $data): void + { + $categoryChanged = ($data['subcategory_id'] ?? null) !== $ticket->subcategory_id; + + $ticket->update([ + 'subject' => $data['subject'], + 'body' => $data['body'], + 'subcategory_id' => $data['subcategory_id'] ?? null, + 'custom_fields' => $data['custom_values'] ?? [], + ]); + $ticket->addHistory('Zaktualizowano dane zgłoszenia'); + + if ($categoryChanged) { + $this->notify($ticket, 'category_changed'); + } + } + + public function operatorReply(Ticket $ticket, User $operator, string $body, ?string $statusAfter = null, array $attachments = []): void + { + $message = $ticket->messages()->create([ + 'author_name' => $operator->name, + 'body' => $body, + ]); + $message->attachAuthor($operator->id, 'operator'); + $ticket->touch(); + $this->attachFiles($ticket, $message, $attachments); + $this->notify($ticket, 'operator_replied'); + + if ($statusAfter) { + $this->setStatus($ticket, $statusAfter); + } + } + + public function operatorNote(Ticket $ticket, User $operator, string $body, array $attachments = []): void + { + $message = $ticket->messages()->create([ + 'author_name' => $operator->name, + 'internal' => true, + 'body' => $body, + ]); + $message->attachAuthor($operator->id, 'operator'); + $this->attachFiles($ticket, $message, $attachments); + } + + public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = []): void + { + $message = $ticket->messages()->create([ + 'author_name' => $client->name, + 'body' => $body, + ]); + $message->attachAuthor($client->id, 'client'); + $ticket->touch(); + $this->attachFiles($ticket, $message, $attachments); + } + + /** + * A message posted by an API integration rather than a logged-in person — + * no User to attach as author, so it lands as a "system" message (mirrors + * merge()'s system notes). Public replies still fire the same outward + * notification as an operator's reply; internal notes don't. + */ + public function apiMessage(Ticket $ticket, ApiClient $client, string $body, bool $internal, string $authorName, array $attachments = []): TicketMessage + { + $message = $ticket->messages()->create([ + 'author_name' => $authorName, + 'internal' => $internal, + 'body' => $body, + 'api_client_id' => $client->id, + ]); + $message->attachAuthor(null, null); + $ticket->touch(); + $this->attachFiles($ticket, $message, $attachments); + + if (! $internal) { + $this->notify($ticket, 'operator_replied'); + } + + return $message; + } + + /** + * Stores each uploaded file and links it to the given message (or, for a + * ticket's opening message, to $message === null-safe callers passing the + * ticket's first message) — shared by every attachment-picking flow, + * including the initial ticket-creation forms. + * + * @param UploadedFile[] $attachments + */ + public function attachFiles(Ticket $ticket, ?TicketMessage $message, array $attachments): void + { + if (! $attachments || ! Settings::bool('allow_attachments')) { + return; + } + + foreach ($attachments as $attachment) { + $path = $attachment->store('attachments', 'public'); + + $ticket->attachments()->create([ + 'message_id' => $message?->id, + 'original_name' => $attachment->getClientOriginalName(), + 'path' => $path, + 'size' => Storage::disk('public')->size($path), + 'mime' => $attachment->getMimeType(), + ]); + } + } + + /** + * Merge tickets: first selected id is primary, others get closed and their + * messages copied onto the primary (mirrors the design's mergeSelected()). + */ + public function merge(array $ticketIds): void + { + if (count($ticketIds) < 2) { + return; + } + + $tickets = Ticket::query()->whereIn('id', $ticketIds)->get()->keyBy('id'); + $primaryId = $ticketIds[0]; + $primary = $tickets->get($primaryId); + $others = $tickets->except($primaryId); + + if (! $primary || $others->isEmpty()) { + return; + } + + $primary->messages()->create([ + 'author_name' => 'System', + 'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => '#'.$o->number)->implode(', '), + ]); + + foreach ($others as $other) { + foreach ($other->messages()->get() as $message) { + $copy = $primary->messages()->create([ + 'author_name' => $message->author_name, + 'internal' => $message->internal, + 'body' => $message->body, + ]); + $copy->attachAuthor($message->author_id, $message->authorLink?->role?->key); + } + + $other->update(['status_key' => 'closed']); + $note = $other->messages()->create([ + 'author_name' => 'System', + 'internal' => true, + 'body' => 'Scalone ze zgłoszeniem #'.$primary->number, + ]); + $note->attachAuthor(null, 'operator'); + } + + $primary->touch(); + } + + /** + * Public so the scheduled SLA-breach check (which isn't a ticket lifecycle + * event raised from within this service) can trigger the same way. + */ + public function notify(Ticket $ticket, string $triggerKey): void + { + $setting = NotificationSetting::query()->where('trigger_key', $triggerKey)->first(); + + if (! $setting || ! $setting->enabled || ! $setting->email_template_id) { + return; + } + + $email = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email; + + if (! $email) { + return; + } + + Notification::route('mail', $email) + ->notify(new TicketNotification($ticket, $setting->email_template_id)); + } +} diff --git a/src/app/Support/Rel.php b/src/app/Support/Rel.php new file mode 100644 index 0000000..1c53256 --- /dev/null +++ b/src/app/Support/Rel.php @@ -0,0 +1,38 @@ +diffInSeconds(now()) / 60); + + if ($minutes < 1) { + return 'przed chwilą'; + } + + if ($minutes < 60) { + return $minutes.' min temu'; + } + + $hours = (int) round($minutes / 60); + + if ($hours < 24) { + return $hours.' godz. temu'; + } + + $days = (int) round($hours / 24); + + if ($days < 30) { + return $days.' dni temu'; + } + + return $time->format('d.m.Y'); + } +} diff --git a/src/app/Support/Settings.php b/src/app/Support/Settings.php new file mode 100644 index 0000000..94484e2 --- /dev/null +++ b/src/app/Support/Settings.php @@ -0,0 +1,297 @@ + 'Servicedesk', + 'default_status' => 'new', + 'auto_assign_by_category' => '1', + 'allow_attachments' => '1', + 'attachment_max_size_kb' => '10240', + 'attachment_max_count' => '5', + 'attachment_max_total_size_kb' => '20480', + 'attachment_allowed_types' => 'jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip,txt', + 'session_lifetime_minutes' => '120', + 'timezone' => 'UTC', + 'ldap_enabled' => '1', + 'ldap_host' => '', + 'ldap_port' => '389', + 'ldap_base_dn' => '', + 'ldap_bind_dn' => '', + 'ldap_use_ssl' => '0', + 'ldap_user_filter' => '(uid={0})', + 'ldap_auto_provision_guests' => '1', + 'restrict_user_creation_to_ldap' => '0', + 'restrict_tickets_to_ldap' => '0', + 'mail_smtp_enabled' => '0', + 'mail_smtp_host' => '', + 'mail_smtp_port' => '587', + 'mail_smtp_username' => '', + 'mail_smtp_encryption' => 'tls', + 'mail_from_address' => '', + 'mail_from_name' => '', + 'email_footer' => '

Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.

', + 'accent_color' => '#7c6fd6', + 'login_notice_type' => 'info', + 'login_notice_html' => '

Zaloguj się danymi z katalogu LDAP/AD Twojej organizacji.

', + 'email_layout_html' => '
' + .'
{firma}
' + .'
{tresc}
' + .'
' + .'
{stopka}
' + .'
', + ]; + + protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password']; + + public static function get(string $key, ?string $default = null): ?string + { + static::load(); + + if (array_key_exists($key, static::$cache)) { + $value = static::$cache[$key]; + + return in_array($key, static::$encrypted, true) && $value !== null + ? Crypt::decryptString($value) + : $value; + } + + return $default ?? (static::$defaults[$key] ?? null); + } + + public static function bool(string $key): bool + { + return in_array(static::get($key), ['1', 'true', true, 1], true); + } + + /** + * The bundled default for a setting, ignoring whatever's currently + * stored — used by admin "Resetuj" buttons (e.g. the e-mail layout/footer + * editors) to restore a field without needing its default hardcoded twice. + */ + public static function default(string $key): ?string + { + return static::$defaults[$key] ?? null; + } + + public static function set(string $key, ?string $value): void + { + static::load(); + + $stored = in_array($key, static::$encrypted, true) && $value !== null + ? Crypt::encryptString($value) + : $value; + + Setting::query()->updateOrCreate(['key' => $key], ['value' => $stored]); + static::$cache[$key] = $stored; + } + + public static function all(): array + { + static::load(); + $keys = array_unique(array_merge(array_keys(static::$defaults), array_keys(static::$cache))); + + return collect($keys)->mapWithKeys(fn ($key) => [$key => static::get($key)])->all(); + } + + protected static function load(): void + { + if (static::$cache !== null) { + return; + } + + static::$cache = Setting::query()->pluck('value', 'key')->all(); + } + + public static function flush(): void + { + static::$cache = null; + } + + /** + * The site-wide accent color (drives buttons, links, focus rings, etc.). + * Validated against a strict hex format since it's echoed straight into + * a + + {{-- QuillJS — powers the rich-text (HTML) editors for e-mail templates in Admin -> Szablony e-mail. --}} + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + @livewireStyles + + + {{ $slot }} + + @livewireScripts + + diff --git a/src/resources/views/livewire/admin/api-keys.blade.php b/src/resources/views/livewire/admin/api-keys.blade.php new file mode 100644 index 0000000..9215933 --- /dev/null +++ b/src/resources/views/livewire/admin/api-keys.blade.php @@ -0,0 +1,114 @@ +
+
+

Klucze API

+
+ Dokumentacja API + +
+
+ +

+ Klucze API reprezentują niezależne integracje zewnętrzne (nie są przypisane do konkretnego użytkownika). Zgłoszenia i wiadomości utworzone przez klucz zachowują się jak zgłoszenia gościa — identyfikowane e-mailem i imieniem podanym w żądaniu. +

+ + @if ($newTokenPlaintext) +
+
+ Klucz „{{ $newTokenClientName }}” utworzony + +
+

Skopiuj ten token teraz — nie zostanie ponownie pokazany.

+
+ {{ $newTokenPlaintext }} + +
+
+ @endif + + @if ($this->apiClients->isNotEmpty()) +
+ + + + + + + + + + + + + + @foreach ($this->apiClients as $client) + @php $token = $client->tokens->first(); @endphp + + + + + + + + + + @endforeach + +
NazwaOpisUprawnieniaUtworzonoOstatnio użytoStatus
{{ $client->name }}{{ $client->description ?: '—' }} + @foreach ($token?->abilities ?? [] as $ability) + {{ $ability }} + @endforeach + {{ $client->created_at->format('Y-m-d H:i') }}{{ $token?->last_used_at?->format('Y-m-d H:i') ?? 'Nigdy' }} + @if ($client->isRevoked() || ! $token) + Odwołany + @else + Aktywny + @endif + +
+ + @unless ($client->isRevoked()) + + @endunless +
+
+
+ @else +

Brak kluczy API. Utwórz pierwszy używając przycisku wyżej.

+ @endif + + @if ($formOpen) +
+
+
Nowy klucz API
+
+ + +
+ @error('form.name') {{ $message }} @enderror +
+ + +
+
+ +
+ @foreach (static::availableAbilities() as $key => $label) + + @endforeach +
+
+ @error('form.abilities') {{ $message }} @enderror +
+ + +
+
+
+ @endif +
diff --git a/src/resources/views/livewire/admin/panel.blade.php b/src/resources/views/livewire/admin/panel.blade.php new file mode 100644 index 0000000..13c0f11 --- /dev/null +++ b/src/resources/views/livewire/admin/panel.blade.php @@ -0,0 +1,1004 @@ +@php +$tabGroups = [ + 'Zgłoszenia' => [ + ['key' => 'categories', 'label' => 'Kategorie', 'icon' => 'category'], + ['key' => 'fields', 'label' => 'Pola dodatkowe', 'icon' => 'list_alt'], + ['key' => 'statuses', 'label' => 'Statusy', 'icon' => 'flag'], + ['key' => 'priorities', 'label' => 'Priorytety i SLA', 'icon' => 'priority_high'], + ['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'], + ['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'], + ], + 'Zespół' => [ + ['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'], + ['key' => 'teams', 'label' => 'Zespoły', 'icon' => 'groups'], + ['key' => 'user-fields', 'label' => 'Pola dodatkowe', 'icon' => 'badge'], + ], + 'Ustawienia' => [ + ['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'], + ['key' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'], + ['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'], + ['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'], + ['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'], + ], +]; +@endphp +
+ + +
+
+ @foreach ($tabGroups as $groupLabel => $items) +
+
{{ $groupLabel }}
+ @foreach ($items as $item) + + @endforeach +
+ @endforeach +
+ +
+ + {{-- ================= CATEGORIES ================= --}} + @if ($tab === 'categories') +

Kategorie i podkategorie

+
+ + +
+ +
+ @foreach ($this->categories as $cat) +
+
+ category + @if ($editingCategoryId === $cat->id) +
+ + +
+
+ + +
+ @else +
+

{{ $cat->name }}

+ @if ($cat->description) +

{{ $cat->description }}

+ @endif +
+
+ + +
+ @endif +
+
+ + + + @foreach ($cat->subcategories as $sub) + + + + + @endforeach + + +
Podkategoria
+
+
folder{{ $sub->name }}
+ @if ($sub->description) +
{{ $sub->description }}
+ @endif +
Pola: {{ $sub->customFields->pluck('label')->implode(', ') ?: '—' }}
+
+
+
+ + +
+
+
+ + +
+
+
+
+ @endforeach +
+ @endif + + {{-- ================= FIELDS ================= --}} + @if ($tab === 'fields') +
+

Pola dodatkowe

+ +
+ @if ($this->customFields->isNotEmpty()) +
+ + + + @foreach ($this->customFields as $f) + + + + + + + @endforeach + +
EtykietaTypWymagane
{{ $f->label }}{{ $f->typeLabel() }}{{ $f->required ? 'Tak' : 'Nie' }} +
+ + +
+
+
+ @else +

Brak pól dodatkowych. Dodaj pierwsze pole używając przycisku wyżej.

+ @endif + @endif + + {{-- ================= USERS ================= --}} + @if ($tab === 'users') +
+

Użytkownicy

+
+ + +
+
+

Logowanie odbywa się przez LDAP/AD. Zaproszenie tworzy tu tylko rolę i przypisanie — po pierwszym logowaniu tożsamość LDAP zwiąże się automatycznie z tym adresem e-mail. „Synchronizuj z LDAP” pobiera od razu wartości pól dodatkowych (zakładka Pola dodatkowe) dla wszystkich istniejących użytkowników.

+ @if ($ldapSyncResult) +
{{ $ldapSyncResult }}
+ @endif +
+ + + + @foreach ($this->users as $u) + + + + + + + + @endforeach + +
Imię i nazwiskoE-mailRolaZespoły
{{ $u->name }}{{ $u->email }} +
+ + + +
+
+ @forelse ($u->teams as $tm) + {{ $tm->name }} + @empty + — + @endforelse + +
+ + + @if ($u->id !== auth()->id()) + + @endif +
+
+
+ @endif + + {{-- ================= TEAMS ================= --}} + @if ($tab === 'teams') +
+

Zespoły

+ +
+
+ @foreach ($this->teams as $tm) +
+
+ {{ $tm->name }} +
+ + +
+
+
Członkowie: {{ $tm->members->pluck('name')->implode(', ') ?: '—' }}
+
Podkategorie: {{ $tm->subcategories->map(fn($s) => $s->category->name.' / '.$s->name)->implode(', ') ?: '—' }}
+
+ @endforeach +
+ @endif + + {{-- ================= USER FIELDS ================= --}} + @if ($tab === 'user-fields') +
+

Pola dodatkowe (użytkownicy)

+ +
+

Te pola pojawiają się przy edycji użytkownika (nie przy zapraszaniu nowego). Jeśli podasz atrybut LDAP, wartość pobierze się automatycznie przy logowaniu oraz przyciskiem „Synchronizuj z LDAP” w zakładce Użytkownicy.

+ @if ($this->userFields->isNotEmpty()) +
+ + + + @foreach ($this->userFields as $f) + + + + + + + + @endforeach + +
NazwaOpisTypAtrybut LDAP
{{ $f->label }}{{ $f->description ?: '—' }}{{ $f->typeLabel() }}{{ $f->ldap_attribute ?: '—' }} +
+ + +
+
+
+ @else +

Brak pól dodatkowych. Dodaj pierwsze pole używając przycisku wyżej.

+ @endif + @endif + + {{-- ================= REPLY QUICK ACTIONS ================= --}} + @if ($tab === 'reply-quick-actions') +
+

Szybkie akcje odpowiedzi

+ +
+

Te opcje pojawiają się w menu obok przycisku „Wyślij odpowiedź” w panelu operatora.

+ @if ($this->replyQuickActions->isNotEmpty()) +
+ + + + @foreach ($this->replyQuickActions as $qa) + + + + + + @endforeach + +
Nazwa przyciskuZmienia status na
{{ $qa->label }} + @if ($qa->status_key) + {{ $qa->statusLabel() }} + @else + Nie zmieniaj + @endif + +
+ + +
+
+
+ @else +

Brak szybkich akcji. Dodaj pierwszą używając przycisku wyżej.

+ @endif + @endif + + {{-- ================= RESPONSE TEMPLATES ================= --}} + @if ($tab === 'response-templates') +
+

Szablony odpowiedzi

+ +
+

Te szablony operator może wstawić do treści odpowiedzi z listy „Wstaw szablon odpowiedzi…” w widoku zgłoszenia.

+ @if ($this->responseTemplates->isNotEmpty()) +
+ @foreach ($this->responseTemplates as $rt) +
+
+ {{ $rt->label }} +
+ + +
+
+

{{ \Illuminate\Support\Str::limit($rt->body, 200) }}

+
+ @endforeach +
+ @else +

Brak szablonów odpowiedzi. Dodaj pierwszy używając przycisku wyżej.

+ @endif + @endif + + {{-- ================= STATUSES ================= --}} + @if ($tab === 'statuses') +

Statusy

+

Nowy, Otwarty i Zamknięty to 3 stałe grupy (nie da się ich usunąć). Pozostałe statusy to tylko dodatkowe warianty grupy „Otwarty” — możesz je dowolnie edytować, usuwać i dodawać nowe.

+
+ + + + @foreach ($this->statuses as $i => $s) + + + + + + + + + @endforeach + +
GrupaNazwaKolorPodgląd
{{ match ($s->stage) { 'new' => 'Nowy', 'closed' => 'Zamknięty', default => 'Otwarty' } }}{{ $s->label }} +
+ + +
+
+ @unless ($s->locked) + + @endunless +
+
+
+ + + + + +
+ @error('newStatusKey') {{ $message }} @enderror + @endif + + {{-- ================= PRIORITIES ================= --}} + @if ($tab === 'priorities') +

Priorytety i SLA

+

Edytuj nazwy, kolory i czasy SLA (w minutach), dodawaj i usuwaj wartości — obowiązuje we wszystkich panelach. Czas rozwiązania równy 0 oznacza brak SLA dla danego priorytetu — zgłoszenie nigdy nie zostanie oznaczone jako przeterminowane.

+
+ + + + @foreach ($this->priorities as $i => $p) + + + + + + + + + + @endforeach + +
PriorytetKolorPodglądCzas odpowiedzi (min)Czas rozwiązania (min)
{{ $p->label }} +
+ + +
+
+
+
+ + + + + +
+ @error('newPriorityKey') {{ $message }} @enderror + @endif + + {{-- ================= EMAIL TEMPLATES ================= --}} + @if ($tab === 'templates') +

Wygląd wiadomości e-mail

+

Każde powiadomienie wysyłane jest w stałym „pudełku” (nazwa firmy, ramka, treść zdarzenia) — tu edytujesz tylko jego stopkę. Po prawej — podgląd na żywo na przykładowym zgłoszeniu.

+ +
+
+
+ + +
+ + Wstawiana w pudełku w miejscu {stopka}; dopisywana do każdego wysyłanego powiadomienia. +
+ +
+
Podgląd przykładowego e-maila
+ +
+
+ +

Powiadomienia e-mail

+

Każde zdarzenie ma stały, przypisany na stałe szablon — możesz go dowolnie edytować, ale nie zmienić na inny. Wyłącz przełącznik, żeby dana wiadomość nigdy nie była wysyłana.

+
+ + + + @foreach ($this->notificationSettings as $ns) + + + + + + + @endforeach + +
ZdarzenieOdbiorcaWysyłane
{{ $ns->trigger_label }}{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }}enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})">
+
+ @endif + + {{-- ================= BRANDING ================= --}} + @if ($tab === 'branding') +

Wygląd i branding

+
+
+
+

Motyw jasny / ciemny / auto ustawia się przełącznikiem w nagłówku — dotyczy całej aplikacji.

+
+ +
+
+ +
+ + + +
+
+

Zastępuje domyślny fioletowy kolor przycisków, linków i etykiet w całej aplikacji.

+
+ +
+
+
+

Widoczne na ekranie logowania. Bez własnego pliku używany jest domyślny znak.

+ +
+ + @if (\App\Support\Settings::get('company_logo_path')) + + @endif +
+
+ +
+
+

Ikona karty przeglądarki. Bez własnego pliku używany jest domyślny znak.

+ + @if ($faviconError) + {{ $faviconError }} + @endif +
+ + @if (\App\Support\Settings::get('favicon_path')) + + @endif +
+
+
+ +
+
+

Wyświetlany nad formularzem logowania, nad polami „Nazwa użytkownika” / „Hasło”. Obsługuje wiele akapitów, pogrubienia, listy itd.

+ +
+ @foreach (\App\Support\Settings::loginNoticeTypes() as $key => $label) + + @endforeach +
+ + + +
+
Podgląd
+ +
+ + +
+
+ @endif + + {{-- ================= CONFIG ================= --}} + @if ($tab === 'config') +

Konfiguracja

+ +
+ +
+
+

Zgłoszenia

+
+ + +
+ +
+ +
+

Załączniki

+ + @if ($systemConfig['allowAttachments']) +
+
+
+
+
+
+ @endif +
+ +
+

Sesja i strefa czasowa

+
+
+ + +
+ Aktualny czas: + +
+
+
+ +
+ +
+
+ +
+

E-mail (SMTP)

+
+
+

Stopka wiadomości e-mail edytowana jest w zakładce „Szablony e-mail”.

+ +
+ + + Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env). + + @if ($mailConfig['smtpEnabled']) +
+
+
+
+ + +
+
+
+
+ +
+ + + @if ($mailTestResult === 'ok') +
check_circleWysłano na Twój adres
+ @elseif ($mailTestResult === 'error') +
errorBłąd wysyłki
+ @endif +
+ @else + + @endif +
+ +
+

LDAP / Active Directory

+ + + @if ($ldapConfig['enabled']) +
+
+
+
+
+
+
+
+
+ + + + +
+ + + @if ($ldapTestResult === 'ok') +
check_circlePołączenie OK
+ @elseif ($ldapTestResult === 'error') +
errorBłąd połączenia
+ @endif +
+ @else + + @endif +
+ +
+ @endif + + {{-- ================= ABOUT ================= --}} + {{-- ================= API KEYS ================= --}} + @if ($tab === 'api-keys') + + @endif + + @if ($tab === 'about') +

O aplikacji

+
+
Aplikacja{{ \App\Support\Settings::get('company_name') }}
+
Wersja1.0.0
+
Kontakt wsparcia{{ config('app.author_contact') ?: '—' }}
+
+ @endif +
+
+ + {{-- ================= DIALOGS ================= --}} + + @if ($replyQuickActionFormOpen) +
+
+
{{ $replyQuickActionForm['id'] ? 'Edytuj szybką akcję' : 'Nowa szybka akcja' }}
+
+ @error('replyQuickActionForm.label') {{ $message }} @enderror +
+ + +
+
+ + + @error('replyQuickActionForm.sortOrder') {{ $message }} @enderror +
+
+ + +
+
+
+ @endif + + @if ($responseTemplateFormOpen) +
+
+
{{ $responseTemplateForm['id'] ? 'Edytuj szablon odpowiedzi' : 'Nowy szablon odpowiedzi' }}
+
+ @error('responseTemplateForm.label') {{ $message }} @enderror +
+ + +
+ @error('responseTemplateForm.body') {{ $message }} @enderror +
+ + +
+
+
+ @endif + + @if ($this->editingTemplate) +
+
+
Edytuj szablon — {{ $this->editingTemplate->name }}
+
+
+ + +
+

Dostępne zmienne: {numer}, {imie}, {temat}, {status}, {kategoria}, {priorytet}, {zespol}, {operator}, {link} — {link} wstawia bezpośredni link do zgłoszenia. Ta treść trafia do wspólnego szablonu-pudełka (u góry listy) w miejscu {tresc}.

+
+ +
+
+
+ @endif + + @if ($fieldFormOpen) +
+
+
{{ $fieldForm['id'] ? 'Edytuj pole' : 'Nowe pole dodatkowe' }}
+
+
+ + +
+ @if ($fieldForm['type'] === 'select') +
+ @endif + +
+ + +
+
+
+ @endif + + @if ($userFieldFormOpen) +
+
+
{{ $userFieldForm['id'] ? 'Edytuj pole' : 'Nowe pole dodatkowe użytkownika' }}
+
+
+
+ + +
+ @if ($userFieldForm['type'] === 'select') +
+ @endif +
+ + +
+

Jeśli podane, wartość tego pola będzie automatycznie pobierana z LDAP dla każdego użytkownika.

+
+ + +
+
+
+ @endif + + @if ($subcategoryEditFormOpen) +
+
+
Edytuj podkategorię
+
+
+
+ + + Priorytet nadawany automatycznie zgłoszeniom z tej podkategorii. +
+
+ +
+ @foreach ($subcategoryEditForm['fieldIds'] as $i => $fieldId) + @php $field = $this->customFields->firstWhere('id', $fieldId); @endphp + @continue(! $field) +
+ + + +
+ @endforeach + @foreach ($this->customFields as $field) + @continue(in_array($field->id, $subcategoryEditForm['fieldIds'], true)) +
+ +
+ @endforeach +
+
+
+ + +
+
+
+ @endif + + @if ($this->viewingUser) +
+
+
{{ $this->viewingUser->name }}
+
+
{{ $this->viewingUser->email }}
+
+ +
+ @forelse ($this->viewingUser->roles ?? [] as $role) + {{ match($role) { 'client' => 'Klient', 'operator' => 'Operator', 'admin' => 'Administrator', default => $role } }} + @empty + — + @endforelse +
+
+
+ +
+ @forelse ($this->viewingUser->teams as $tm) + {{ $tm->name }} + @empty + — + @endforelse +
+
+ @if ($this->userFields->isNotEmpty()) +
+ + @foreach ($this->userFields as $f) + @php $value = $this->viewingUser->custom_field_values[$f->id] ?? null; @endphp +
+ +
{{ $f->type === 'checkbox' ? ($value ? 'Tak' : 'Nie') : ((string) $value !== '' && $value !== null ? $value : '—') }}
+
+ @endforeach +
+ @endif +
+
+ + +
+
+
+ @endif + + @if ($userFormOpen) +
+
+
{{ $userForm['id'] ? 'Edytuj użytkownika' : 'Zaproś użytkownika' }}
+
+
+ @error('userForm.email') {{ $message }} @enderror + +
+ +

Podaj hasło, aby ten użytkownik mógł logować się lokalnie, niezależnie od LDAP/AD — przydatne dla kont serwisowych lub gdy LDAP jest niedostępny.

+
+ @error('userForm.password') {{ $message }} @enderror +
+
+ +
+ +
+ + + +
+ @error('userForm.roles') {{ $message }} @enderror +
+ + @if ($userForm['id'] && $this->userFields->isNotEmpty()) +
+ + @foreach ($this->userFields as $f) +
+ + @if ($f->type === 'textarea') + + @elseif ($f->type === 'select') + + @elseif ($f->type === 'checkbox') + + @elseif ($f->type === 'date') + + @elseif ($f->type === 'number') + + @else + + @endif + @if ($f->description && $f->type !== 'checkbox') + {{ $f->description }} + @endif +
+ @endforeach +
+ @endif + +
+ + +
+
+
+ @endif + + @if ($teamFormOpen) +
+
+
{{ $teamForm['id'] ? 'Edytuj zespół' : 'Nowy zespół' }}
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ @endif + + @if ($pendingDeleteType) +
+
+
Potwierdź usunięcie
+
{{ $pendingDeleteMessage }}
+
+ + +
+
+
+ @endif +
diff --git a/src/resources/views/livewire/auth/login.blade.php b/src/resources/views/livewire/auth/login.blade.php new file mode 100644 index 0000000..5791a52 --- /dev/null +++ b/src/resources/views/livewire/auth/login.blade.php @@ -0,0 +1,27 @@ +
+ + Nowe zgłoszenie bez logowania + + +
+
+ {{ \App\Support\Settings::get('company_name') }} +

Zaloguj się

+ + + @if ($error) +
{{ $error }}
+ @endif + +
+ + +
+
+ + +
+ + +
+
diff --git a/src/resources/views/livewire/client/dashboard.blade.php b/src/resources/views/livewire/client/dashboard.blade.php new file mode 100644 index 0000000..575a292 --- /dev/null +++ b/src/resources/views/livewire/client/dashboard.blade.php @@ -0,0 +1,34 @@ +
+ + +
+
+

Moje zgłoszenia

+ + Nowe zgłoszenie +
+ +
+ + +
+ +
+ @foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket) + +
+
#{{ $ticket->number }} — {{ $ticket->subject }}
+
{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}
+
+
+ {{ $ticket->priorityLabel() }} + {{ $ticket->statusLabel() }} +
+
+ @endforeach + + @if (($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->isEmpty()) +

Brak zgłoszeń w tej zakładce.

+ @endif +
+
+
diff --git a/src/resources/views/livewire/client/new-ticket.blade.php b/src/resources/views/livewire/client/new-ticket.blade.php new file mode 100644 index 0000000..ac5a80e --- /dev/null +++ b/src/resources/views/livewire/client/new-ticket.blade.php @@ -0,0 +1,96 @@ +
+ + +
+
+

Nowe zgłoszenie

+ ← Wróć do zgłoszeń +
+ +
+
+ + +
+
+ +
+ @foreach (['Dział', 'Kategoria', 'Szczegóły'] as $i => $label) + @php $n = $i + 1; @endphp +
+
+
{{ $n }}
+
{{ $label }}
+
+ @if (! $loop->last) +
+ @endif +
+ @endforeach +
+ + @if ($step === 1) +
+ @foreach ($this->categories as $category) +
+
{{ mb_strtoupper(mb_substr($category->name, 0, 1)) }}
+ {{ $category->name }} +

{{ $category->description }}

+ {{ $category->subcategories->count() }} podkategorie +
+ @endforeach +
+ @elseif ($step === 2) +
+ + @foreach ($this->selectedCategory?->subcategories ?? [] as $sub) +
+
+
{{ $sub->name }}
+
{{ $sub->description }}
+
+ +
+ @endforeach +
+ @elseif ($step === 3) +
+
+ {{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }} + +
+
+ + + @error('subject') {{ $message }} @enderror +
+
+ + + @error('body') {{ $message }} @enderror +
+ + @foreach ($this->selectedSubcategory?->customFields ?? [] as $field) + + @endforeach + +
+ +
+ + @forelse ($attachments as $i => $file) + + {{ $file->getClientOriginalName() }} + close + + @empty + Brak plików + @endforelse +
+ @error('attachments') {{ $message }} @enderror +
+ + + @endif +
+
diff --git a/src/resources/views/livewire/client/ticket-show.blade.php b/src/resources/views/livewire/client/ticket-show.blade.php new file mode 100644 index 0000000..b39a1bb --- /dev/null +++ b/src/resources/views/livewire/client/ticket-show.blade.php @@ -0,0 +1,137 @@ +
+ + +
+ ← Wróć do listy + +
+
+ @php + $firstMessage = $this->ticketMessages->first(); + $threadMessages = $this->ticketMessages->skip(1); + @endphp + +
+
Zgłoszenie #{{ $ticket->number }}
+

{{ $ticket->subject }}

+
{{ $ticket->categoryLabel() }} · utworzono {{ \App\Support\Rel::format($ticket->created_at) }}
+
{{ $ticket->body }}
+ @foreach ($firstMessage?->attachments ?? [] as $a) + + @endforeach + @if (count($ticket->customFieldEntries())) +
+ @foreach ($ticket->customFieldEntries() as $cf) +
{{ $cf['label'] }}: {{ $cf['value'] }}
+ @endforeach +
+ @endif +
+ +
+ @foreach ($threadMessages as $m) + @php $mine = $m->role === 'client' && $m->author_id === auth()->id(); @endphp +
+
+
+
{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}
+ @if ($m->role === 'client' && $m->author_id === auth()->id()) +
+ edit + delete +
+ @endif +
+ @if ($editingMessageId === $m->id) + +
+ + +
+ @else +
{{ $m->body }}
+ @endif + @foreach ($m->attachments as $a) + + @endforeach +
+
+ @endforeach +
+ +
+ + @error('reply') {{ $message }} @enderror +
+
+ + @forelse ($attachments as $i => $file) + + attach_file + {{ $file->getClientOriginalName() }} + close + + @empty + Brak załączników + @endforelse +
+ +
+ @error('attachments') {{ $message }} @enderror +
+
+ +
+
+
Twoje dane
+
{{ auth()->user()->name }}
+
{{ auth()->user()->email }}
+
+
+
Status i priorytet
+
+ {{ $ticket->priorityLabel() }} + {{ $ticket->statusLabel() }} +
+ @if (! $ticket->isClosed()) + + @elseif ($ticket->isClosed()) + + @endif +
+
+
Historia zmian
+ @forelse ($ticket->histories as $h) +
{{ $h->text }}
{{ \App\Support\Rel::format($h->created_at) }}
+ @empty +

Brak historii zmian.

+ @endforelse +
+
+
Inne Twoje zgłoszenia
+ @forelse ($this->otherTickets as $ot) + + #{{ $ot->number }} — {{ $ot->subject }} + {{ $ot->statusLabel() }} + + @empty +

Brak innych zgłoszeń.

+ @endforelse +
+
+
+
+ + @if ($pendingDeleteMessageId) +
+
+
Potwierdź usunięcie
+
Czy na pewno usunąć tę wiadomość?
+
+ + +
+
+
+ @endif +
diff --git a/src/resources/views/livewire/landing.blade.php b/src/resources/views/livewire/landing.blade.php new file mode 100644 index 0000000..cfbf42d --- /dev/null +++ b/src/resources/views/livewire/landing.blade.php @@ -0,0 +1,128 @@ +
+ + Zaloguj się + + +
+
+ + @if ($this->submittedTicket) +
+ Zgłoszenie przyjęte +

Zgłoszenie #{{ $this->submittedTicket->number }} zostało utworzone

+

Zapisz numer zgłoszenia i adres e-mail — będziesz mógł/mogła sprawdzić status, kontaktując się z zespołem wsparcia. Aktualizacje będziemy wysyłać na Twój adres e-mail.

+
+
+
Numer zgłoszenia: #{{ $this->submittedTicket->number }}
+
Temat: {{ $this->submittedTicket->subject }}
+
Kategoria: {{ $this->submittedTicket->categoryLabel() }}
+
Zgłaszający: {{ $this->submittedTicket->email }}
+
Status: {{ $this->submittedTicket->statusLabel() }}
+
Priorytet: {{ $this->submittedTicket->priorityLabel() }}
+
Data zgłoszenia: {{ $this->submittedTicket->created_at->format('d.m.Y H:i') }}
+
+ + @if ($this->submittedTicket->customer_id) +
+

Ten adres e-mail ma już konto w systemie — zaloguj się, aby śledzić postęp zgłoszenia online, otrzymywać powiadomienia w panelu i odpowiadać bezpośrednio w rozmowie.

+ Zaloguj się i przejdź do zgłoszenia + @endif + + +
+ @else +
+

Jak możemy pomóc?

+

Utwórz zgłoszenie bez logowania — podaj adres e-mail, na który wyślemy aktualizacje.

+
+ +
+
+ + + @error('email') {{ $message }} @enderror +
+
+ +
+ @foreach (['Dział', 'Kategoria', 'Szczegóły'] as $i => $label) + @php $n = $i + 1; @endphp +
+
+
{{ $n }}
+
{{ $label }}
+
+ @if (! $loop->last) +
+ @endif +
+ @endforeach +
+ + @if ($step === 1) +
+ @foreach ($this->categories as $category) +
+
{{ mb_strtoupper(mb_substr($category->name, 0, 1)) }}
+ {{ $category->name }} +

{{ $category->description }}

+ {{ $category->subcategories->count() }} podkategorie +
+ @endforeach +
+ @elseif ($step === 2) +
+ + @foreach ($this->selectedCategory?->subcategories ?? [] as $sub) +
+
+
{{ $sub->name }}
+
{{ $sub->description }}
+
+ +
+ @endforeach +
+ @elseif ($step === 3) +
+
+ {{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }} + +
+
+ + + @error('subject') {{ $message }} @enderror +
+
+ + + @error('body') {{ $message }} @enderror +
+ + @foreach ($this->selectedSubcategory?->customFields ?? [] as $field) + + @endforeach + +
+ +
+ + @forelse ($attachments as $i => $file) + + {{ $file->getClientOriginalName() }} + close + + @empty + Brak plików + @endforelse +
+ @error('attachments') {{ $message }} @enderror +
+ + + @endif + @endif +
+
+
diff --git a/src/resources/views/livewire/operator/new-ticket.blade.php b/src/resources/views/livewire/operator/new-ticket.blade.php new file mode 100644 index 0000000..d37abbc --- /dev/null +++ b/src/resources/views/livewire/operator/new-ticket.blade.php @@ -0,0 +1,102 @@ +
+ + +
+
+

Zgłoszenie w imieniu klienta

+ ← Wróć do listy +
+ +
+
+ + + @error('customerId') {{ $message }} @enderror +
+
+ +
+ @foreach (['Dział', 'Kategoria', 'Szczegóły'] as $i => $label) + @php $n = $i + 1; @endphp +
+
+
{{ $n }}
+
{{ $label }}
+
+ @if (! $loop->last) +
+ @endif +
+ @endforeach +
+ + @if ($step === 1) +
+ @foreach ($this->categories as $category) +
+
{{ mb_strtoupper(mb_substr($category->name, 0, 1)) }}
+ {{ $category->name }} +

{{ $category->description }}

+ {{ $category->subcategories->count() }} podkategorie +
+ @endforeach +
+ @elseif ($step === 2) +
+ + @foreach ($this->selectedCategory?->subcategories ?? [] as $sub) +
+
+
{{ $sub->name }}
+
{{ $sub->description }}
+
+ +
+ @endforeach +
+ @elseif ($step === 3) +
+
+ {{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }} + +
+
+ + + @error('subject') {{ $message }} @enderror +
+
+ + + @error('body') {{ $message }} @enderror +
+ + @foreach ($this->selectedSubcategory?->customFields ?? [] as $field) + + @endforeach + +
+ +
+ + @forelse ($attachments as $i => $file) + + {{ $file->getClientOriginalName() }} + close + + @empty + Brak plików + @endforelse +
+ @error('attachments') {{ $message }} @enderror +
+ + + @endif +
+
diff --git a/src/resources/views/livewire/operator/queue.blade.php b/src/resources/views/livewire/operator/queue.blade.php new file mode 100644 index 0000000..fc789e0 --- /dev/null +++ b/src/resources/views/livewire/operator/queue.blade.php @@ -0,0 +1,167 @@ +
+ + +
+
+ + +
+ @foreach ($this->queueGroups as $groupLabel => $items) +
+
{{ $groupLabel }}
+ @foreach ($items as $item) + + @endforeach +
+ @endforeach +
+
+ +
+
+ @if ($this->filteredCustomer) + + @endif +
+
+ + +
+ + bar_chart + Statystyki + + + Zgłoszenie w imieniu klienta +
+
+ + @php + $columnDefs = $this->columnDefs(); + $sortableColumns = $this->sortableColumns(); + @endphp + +
+ + + + + +
+ +
+ @foreach ($columnDefs as $key => $label) + + @endforeach +
+
+
+ +
+ + + + + @foreach ($columnDefs as $key => $label) + @continue(! in_array($key, $visibleColumns)) + + @endforeach + + + + @foreach ($this->filteredTickets as $t) + @php $sla = $t->slaInfo(); @endphp + + + @if (in_array('number', $visibleColumns)) + + @endif + @if (in_array('subject', $visibleColumns)) + + @endif + @if (in_array('customer', $visibleColumns)) + + @endif + @if (in_array('category', $visibleColumns)) + + @endif + @if (in_array('priority', $visibleColumns)) + + @endif + @if (in_array('status', $visibleColumns)) + + @endif + @if (in_array('sla', $visibleColumns)) + + @endif + @if (in_array('assignee', $visibleColumns)) + + @endif + + @endforeach + +
+ @if (in_array($key, $sortableColumns)) + + @else + {{ $label }} + @endif +
id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})">{{ $t->number }}{{ $t->subject }}{{ $t->name }}{{ $t->categoryLabel() }}{{ $t->priorityLabel() }}{{ $t->statusLabel() }}{{ $sla['short'] }}{{ $t->assignee?->name ?? 'Nieprzypisane' }}
+
+
+
+ + @if ($pendingDeleteSelected) +
+
+
Potwierdź usunięcie
+
+ {{ count($selectedIds) === 1 ? 'Wybrane zgłoszenie zostanie trwale usunięte.' : 'Wybrane zgłoszenia ('.count($selectedIds).') zostaną trwale usunięte.' }} +
+
+ + +
+
+
+ @endif + +
diff --git a/src/resources/views/livewire/operator/stats.blade.php b/src/resources/views/livewire/operator/stats.blade.php new file mode 100644 index 0000000..2250cf0 --- /dev/null +++ b/src/resources/views/livewire/operator/stats.blade.php @@ -0,0 +1,217 @@ +
+ + +
+
+

Statystyki

+ ← Wróć do listy +
+ + {{-- Filters --}} +
+ + + @if ($range === 'custom') + + + + @endif + + + + + + + + +
+ + {{-- KPI tiles --}} + @php($kpis = $this->kpis) +
+
+
Łącznie zgłoszeń
+
{{ $kpis['total'] }}
+
+
+
Otwarte
+
{{ $kpis['open'] }}
+
+
+
Zamknięte
+
{{ $kpis['closed'] }}
+
{{ $kpis['closedPct'] !== null ? $kpis['closedPct'].'% wszystkich' : 'Brak danych' }}
+
+
+
Śr. czas 1. odpowiedzi
+
{{ $kpis['avgFirstResponseHours'] !== null ? $kpis['avgFirstResponseHours'].' godz.' : '—' }}
+
+
+
Śr. czas rozwiązania
+
{{ $kpis['avgResolutionHours'] !== null ? $kpis['avgResolutionHours'].' godz.' : '—' }}
+
+
+
Naruszenia SLA
+
+ {{ $kpis['sla']['rate'] !== null ? $kpis['sla']['rate'].'%' : '—' }} +
+
{{ $kpis['sla']['breached'] }} / {{ $kpis['sla']['total'] }} zgłoszeń
+
+
+ +
+ {{-- By status --}} +
+
Zgłoszenia wg statusu
+ @php($max = max($this->byStatus->max('count'), 1)) + @forelse ($this->byStatus as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['count'] }}
+
+ @empty +
Brak danych w wybranym okresie.
+ @endforelse +
+ + {{-- By priority --}} +
+
Zgłoszenia wg priorytetu
+ @php($max = max($this->byPriority->max('count'), 1)) + @forelse ($this->byPriority as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['count'] }}
+
+ @empty +
Brak danych w wybranym okresie.
+ @endforelse +
+ + {{-- By category --}} +
+
Zgłoszenia wg kategorii
+ @php($cats = $this->byCategory) + @php($max = max($cats->max('count') ?? 0, 1)) + @forelse ($cats as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['count'] }}
+
+ @empty +
Brak danych w wybranym okresie.
+ @endforelse +
+ + {{-- By team --}} +
+
Obciążenie zespołów
+ @php($teamRows = $this->byTeam) + @php($max = max($teamRows->max('count') ?? 0, 1)) + @forelse ($teamRows as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['count'] }}
+
+ @empty +
Brak danych w wybranym okresie.
+ @endforelse +
+ + {{-- By assignee --}} +
+
Obciążenie operatorów
+ @php($opRows = $this->byAssignee) + @php($max = max($opRows->max('count') ?? 0, 1)) + @forelse ($opRows as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['count'] }}
+
+ @empty +
Brak danych w wybranym okresie.
+ @endforelse +
+
+ + {{-- Trend --}} + @php($trend = $this->trend) + @php($trendMax = max(collect($trend)->max('created'), collect($trend)->max('closed'), 1)) + @php($labelStep = max((int) ceil(count($trend) / 12), 1)) +
+
Trend zgłoszeń
+
Utworzone i zamknięte w czasie (maks. ostatnie 60 dni okresu).
+ + @if (count($trend) === 0) +
Brak danych w wybranym okresie.
+ @else +
+
+
Nowe zgłoszenia
+
+ @foreach ($trend as $day) +
+
+
+ @endforeach +
+
+ @foreach ($trend as $i => $day) +
{{ $i % $labelStep === 0 ? $day['label'] : '' }}
+ @endforeach +
+
+ +
+
Zamknięte zgłoszenia
+
+ @foreach ($trend as $day) +
+
+
+ @endforeach +
+
+ @foreach ($trend as $i => $day) +
{{ $i % $labelStep === 0 ? $day['label'] : '' }}
+ @endforeach +
+
+
+ @endif +
+
+
diff --git a/src/resources/views/livewire/operator/ticket-show.blade.php b/src/resources/views/livewire/operator/ticket-show.blade.php new file mode 100644 index 0000000..1f931e7 --- /dev/null +++ b/src/resources/views/livewire/operator/ticket-show.blade.php @@ -0,0 +1,401 @@ +
+ + +
+
+ ← Wróć do listy + +
+
+
+
+
Zgłoszenie #{{ $ticket->number }}
+ @unless ($editingDetails) + + @endunless +
+ + @unless ($editingDetails) +

{{ $ticket->subject }}

+
{{ $ticket->categoryLabel() }}
+
{{ $ticket->body }}
+ @foreach ($this->publicMessages->first()?->attachments ?? [] as $a) + + @endforeach + @if (count($ticket->customFieldEntries())) +
+ @foreach ($ticket->customFieldEntries() as $cf) +
{{ $cf['label'] }}: {{ $cf['value'] }}
+ @endforeach +
+ @endif + @else +
+
+
+
+ + +
+
+ + +
+
+ + @foreach ($this->editDetailsSelectedSubcategory?->customFields ?? [] as $field) + + @endforeach + +
+ + +
+ @endunless +
+ +
+
Notatki wewnętrzne
+ @forelse ($this->internalMessages as $m) +
+
+
{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}
+ @if ($m->role === 'operator') +
+ edit + delete +
+ @endif +
+ @if ($editingNoteId === $m->id) + +
+ + +
+ @else +
{{ $m->body }}
+ @endif + @foreach ($m->attachments as $a) + + @endforeach +
+ @empty +

Brak notatek wewnętrznych.

+ @endforelse + @if ($addingNote) + +
+
+ + @forelse ($noteAttachments as $i => $file) + + attach_file + {{ $file->getClientOriginalName() }} + close + + @empty + Brak załączników + @endforelse +
+
+ @error('noteAttachments') {{ $message }} @enderror +
+ + +
+ @else + + @endif +
+ + @php $threadMessages = $this->publicMessages->skip(1); @endphp +
+ @foreach ($threadMessages as $m) + @php $mine = $m->role === 'operator'; @endphp +
+
+
+
{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}
+ @if ($m->role === 'operator') +
+ edit + delete +
+ @endif +
+ @if ($editingMessageId === $m->id) + +
+ + +
+ @else +
{{ $m->body }}
+ @endif + @foreach ($m->attachments as $a) + + @endforeach +
+
+ @endforeach +
+ +
+ + +
+
+ + @forelse ($replyAttachments as $i => $file) + + attach_file + {{ $file->getClientOriginalName() }} + close + + @empty + Brak załączników + @endforelse +
+
+ + +
+ @forelse ($this->replyQuickActions as $qa) + + @empty +
Brak skonfigurowanych szybkich akcji.
+ @endforelse +
+
+
+ @error('replyAttachments') {{ $message }} @enderror +
+
+ +
+
+
+
Zgłaszający
+ edit +
+
{{ $ticket->name }}
+
{{ $ticket->email }}
+ @if ($ticket->customer_id) + Zobacz zgłoszenia tej osoby → + @php $reporterFields = $ticket->customer?->customFieldEntries() ?? []; @endphp + @if (count($reporterFields)) +
+ @foreach ($reporterFields as $cf) +
{{ $cf['label'] }}: {{ $cf['value'] }}
+ @endforeach +
+ @endif + @endif +
+ + @if ($editingReporter) +
+
+ + +
+
+ + +
+
+ @endif + +
+
Status i przypisanie
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + @if ($ticket->assignee_id !== auth()->id()) + + @endif +
+
+
+ +
+
SLA
+
{{ $ticket->slaInfo()['text'] }}
+
+ +
+
MONITOR CZASU PRACY
+
+ @if ($editingTimer) +
+ godz. + min. + sek. +
+
+ + +
+ @else +
+ Czas w zgłoszeniu: + edit +
+
+ @if ($ticket->timer_started_at) + + @else + + @endif + +
+ @endif +
+
+ +
+
Historia zmian
+ @forelse ($ticket->histories as $h) +
{{ $h->text }}
{{ \App\Support\Rel::format($h->created_at) }}
+ @empty +

Brak historii zmian.

+ @endforelse +
+ +
+ +
+
+
+
+
+ + @if ($pendingDeleteMessageId) +
+
+
Potwierdź usunięcie
+
Czy na pewno usunąć tę wiadomość?
+
+ + +
+
+
+ @endif + + @if ($pendingDeleteTicket) +
+
+
Potwierdź usunięcie
+
Czy na pewno usunąć zgłoszenie #{{ $ticket->number }}?
+
+ + +
+
+
+ @endif +
diff --git a/src/resources/views/vendor/l5-swagger/.gitkeep b/src/resources/views/vendor/l5-swagger/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/resources/views/vendor/l5-swagger/index.blade.php b/src/resources/views/vendor/l5-swagger/index.blade.php new file mode 100644 index 0000000..4f57040 --- /dev/null +++ b/src/resources/views/vendor/l5-swagger/index.blade.php @@ -0,0 +1,174 @@ + + + + + {{ $documentationTitle }} + + + + + @if(config('l5-swagger.defaults.ui.display.dark_mode')) + + @endif + + + +
+ + + + + + diff --git a/src/resources/views/welcome.blade.php b/src/resources/views/welcome.blade.php new file mode 100644 index 0000000..26e294a --- /dev/null +++ b/src/resources/views/welcome.blade.php @@ -0,0 +1,223 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + @fonts + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

With so many options available to you,
we suggest you start with the following:

+ + + +

+ v{{ app()->version() }} + + View changelog + + + + +

+
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- 13 --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/src/routes/api.php b/src/routes/api.php new file mode 100644 index 0000000..dad620e --- /dev/null +++ b/src/routes/api.php @@ -0,0 +1,36 @@ +middleware('throttle:api')->group(function () { + Route::middleware(['auth:sanctum', 'abilities:tickets:read'])->group(function () { + Route::get('/tickets', [TicketController::class, 'index']); + Route::get('/tickets/{ticket}', [TicketController::class, 'show']); + Route::get('/tickets/{ticket}/messages', [TicketMessageController::class, 'index']); + }); + + Route::middleware(['auth:sanctum', 'abilities:tickets:write'])->group(function () { + Route::post('/tickets', [TicketController::class, 'store']); + Route::patch('/tickets/{ticket}', [TicketController::class, 'update']); + Route::post('/tickets/{ticket}/messages', [TicketMessageController::class, 'store']); + }); + + Route::middleware(['auth:sanctum', 'abilities:dictionaries:read'])->group(function () { + Route::get('/categories', [CategoryController::class, 'index']); + Route::get('/statuses', [StatusController::class, 'index']); + Route::get('/priorities', [PriorityController::class, 'index']); + Route::get('/teams', [TeamController::class, 'index']); + }); + + Route::middleware(['auth:sanctum', 'abilities:users:read'])->group(function () { + Route::get('/users', [UserController::class, 'index']); + Route::get('/users/{user}', [UserController::class, 'show']); + }); +}); diff --git a/src/routes/console.php b/src/routes/console.php new file mode 100644 index 0000000..c187274 --- /dev/null +++ b/src/routes/console.php @@ -0,0 +1,11 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); + +Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes(); diff --git a/src/routes/web.php b/src/routes/web.php new file mode 100644 index 0000000..75ee81d --- /dev/null +++ b/src/routes/web.php @@ -0,0 +1,75 @@ +name('landing'); + +Route::get('/manifest.webmanifest', function () { + return response()->json([ + 'name' => Settings::get('company_name'), + 'short_name' => Settings::get('company_name'), + 'start_url' => '/', + 'scope' => '/', + 'display' => 'standalone', + 'background_color' => '#f3f5fe', + 'theme_color' => Settings::accentColor(), + 'lang' => 'pl', + 'icons' => [ + ['src' => asset('pwa-icons/icon-192.png'), 'sizes' => '192x192', 'type' => 'image/png', 'purpose' => 'any'], + ['src' => asset('pwa-icons/icon-512.png'), 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'any'], + ['src' => asset('pwa-icons/icon-192-maskable.png'), 'sizes' => '192x192', 'type' => 'image/png', 'purpose' => 'maskable'], + ['src' => asset('pwa-icons/icon-512-maskable.png'), 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'maskable'], + ], + ])->header('Content-Type', 'application/manifest+json'); +})->name('manifest'); + +Route::get('/login', Login::class)->middleware('guest')->name('login'); +Route::post('/logout', function () { + Auth::logout(); + request()->session()->invalidate(); + request()->session()->regenerateToken(); + + return redirect('/'); +})->middleware('auth')->name('logout'); + +Route::middleware(['auth', 'role:client'])->prefix('client')->name('client.')->group(function () { + Route::get('/', ClientDashboard::class)->name('dashboard'); + Route::get('/new', ClientNewTicket::class)->name('new'); + Route::get('/tickets/{ticket}', ClientTicketShow::class)->name('ticket'); +}); + +Route::middleware(['auth', 'role:operator'])->prefix('operator')->name('operator.')->group(function () { + Route::get('/', OperatorQueue::class)->name('queue'); + Route::get('/new', OperatorNewTicket::class)->name('new'); + Route::get('/stats', OperatorStats::class)->name('stats'); + Route::get('/tickets/{ticket}', OperatorTicketShow::class)->name('ticket'); + + // Fired via navigator.sendBeacon on tab/browser close (see the + // MONITOR CZASU PRACY widget in ticket-show.blade.php) — a plain + // Livewire call can't be relied on to complete once the page is + // actually unloading, and sendBeacon needs a normal endpoint to hit. + // Excluded from CSRF verification in bootstrap/app.php since a beacon + // request can't carry the token header. + Route::post('/tickets/{ticket}/stop-timer', function (Ticket $ticket) { + $ticket->stopTimer(); + + return response()->noContent(); + })->name('ticket.stop-timer'); +}); + +Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->group(function () { + Route::get('/', AdminPanel::class)->name('panel'); +}); diff --git a/src/storage/api-docs/api-docs.json b/src/storage/api-docs/api-docs.json new file mode 100644 index 0000000..7d98863 --- /dev/null +++ b/src/storage/api-docs/api-docs.json @@ -0,0 +1,1191 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Servicedesk API", + "description": "Dokumentacja REST API systemu Servicedesk.\n\nWszystkie endpointy wymagają nagłówka `Authorization: Bearer {klucz_api}`.\nKlucz API wygenerujesz w panelu administratora, w zakładce **Ustawienia → Klucze API**.\nKażdy klucz ma przypisany zestaw uprawnień (`abilities`) ograniczający dostęp do\nposzczególnych grup endpointów (np. `tickets:read`, `tickets:write`,\n`dictionaries:read`, `users:read`).", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://servicedesk.srv-01.nni.kzbikowski.pl/api/v1" + } + ], + "paths": { + "/categories": { + "get": { + "tags": [ + "Dictionaries" + ], + "summary": "List categories", + "description": "Returns every category with its subcategories and each subcategory's custom fields.", + "operationId": "b1b247b2b491b0491c3c728dc33f23f0", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Category" + } + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Missing the dictionaries:read ability" + } + }, + "security": [ + { + "sanctum": [] + } + ] + } + }, + "/priorities": { + "get": { + "tags": [ + "Dictionaries" + ], + "summary": "List priorities", + "operationId": "6b6e8abcf5a26d89299faef13510ae79", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Priority" + } + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Missing the dictionaries:read ability" + } + }, + "security": [ + { + "sanctum": [] + } + ] + } + }, + "/statuses": { + "get": { + "tags": [ + "Dictionaries" + ], + "summary": "List statuses", + "operationId": "d16c30a3988d26e30d966f839b0268e3", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Status" + } + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Missing the dictionaries:read ability" + } + }, + "security": [ + { + "sanctum": [] + } + ] + } + }, + "/teams": { + "get": { + "tags": [ + "Dictionaries" + ], + "summary": "List teams", + "operationId": "ff69468bf0ed011f59850b626db398a2", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Team" + } + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Missing the dictionaries:read ability" + } + }, + "security": [ + { + "sanctum": [] + } + ] + } + }, + "/tickets": { + "get": { + "tags": [ + "Tickets" + ], + "summary": "List tickets", + "description": "Any filter below accepts a `!` prefix to negate it, e.g. `status_key=!closed` returns everything except closed tickets.", + "operationId": "23abb903edea59ceb366f9f029c26991", + "parameters": [ + { + "name": "status_key", + "in": "query", + "description": "Filter by status key. Prefix with `!` to exclude, e.g. `!closed`.", + "schema": { + "type": "string" + }, + "example": "!closed" + }, + { + "name": "priority_key", + "in": "query", + "description": "Filter by priority key. Prefix with `!` to exclude.", + "schema": { + "type": "string" + }, + "example": "high" + }, + { + "name": "team_id", + "in": "query", + "description": "Filter by team id. Prefix with `!` to exclude.", + "schema": { + "type": "integer" + }, + "example": 1 + }, + { + "name": "assignee_id", + "in": "query", + "description": "Filter by assignee (operator) user id. Prefix with `!` to exclude.", + "schema": { + "type": "integer" + }, + "example": 3 + }, + { + "name": "customer_id", + "in": "query", + "description": "Filter by customer user id. Prefix with `!` to exclude.", + "schema": { + "type": "integer" + }, + "example": 5 + }, + { + "name": "subcategory_id", + "in": "query", + "description": "Filter by subcategory id. Prefix with `!` to exclude.", + "schema": { + "type": "integer" + }, + "example": 2 + }, + { + "name": "per_page", + "in": "query", + "description": "Results per page (max 100).", + "schema": { + "type": "integer" + }, + "example": 25 + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Ticket" + } + }, + "links": { + "properties": { + "first": { + "type": "string", + "example": "/api/v1/tickets?page=1" + }, + "last": { + "type": "string", + "example": "/api/v1/tickets?page=1" + } + }, + "type": "object" + }, + "meta": { + "properties": { + "current_page": { + "type": "integer", + "example": 1 + }, + "per_page": { + "type": "integer", + "example": 25 + }, + "total": { + "type": "integer", + "example": 1 + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Missing the tickets:read ability" + } + }, + "security": [ + { + "sanctum": [] + } + ] + }, + "post": { + "tags": [ + "Tickets" + ], + "summary": "Create a ticket", + "description": "Behaves like a guest ticket submission: no customer account is required, the given email/name identify the reporter (an existing user is matched/created automatically when LDAP guest auto-provisioning is enabled).", + "operationId": "0f82fdde1437d948b90892e1b714a1cc", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "required": [ + "email", + "name", + "subject", + "body" + ], + "properties": { + "email": { + "description": "Reporter email address.", + "type": "string", + "format": "email", + "example": "jan.kowalski@firma.pl" + }, + "name": { + "description": "Reporter display name.", + "type": "string", + "example": "Jan Kowalski" + }, + "subcategory_id": { + "description": "The subcategory to file the ticket under.", + "type": "integer", + "nullable": true + }, + "subject": { + "description": "Ticket subject.", + "type": "string" + }, + "body": { + "description": "Ticket body.", + "type": "string" + }, + "custom_values": { + "description": "Custom field values keyed by field id.", + "type": "object" + }, + "attachments": { + "description": "Files to attach to the opening message.", + "type": "array", + "items": { + "type": "string", + "format": "binary" + } + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Ticket" + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Missing the tickets:write ability" + }, + "422": { + "description": "Validation error" + } + }, + "security": [ + { + "sanctum": [] + } + ] + } + }, + "/tickets/{ticket}": { + "get": { + "tags": [ + "Tickets" + ], + "summary": "Get a ticket", + "operationId": "e266c2c37a5df4ec1d5da7565bd6e2ae", + "parameters": [ + { + "name": "ticket", + "in": "path", + "description": "Ticket id.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Ticket" + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Missing the tickets:read ability" + }, + "404": { + "description": "Ticket not found" + } + }, + "security": [ + { + "sanctum": [] + } + ] + }, + "patch": { + "tags": [ + "Tickets" + ], + "summary": "Update a ticket", + "description": "Every field is optional — only the fields present in the request are changed.", + "operationId": "e4665f09e2a5cc3421f1b0a026a89d23", + "parameters": [ + { + "name": "ticket", + "in": "path", + "description": "Ticket id.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "status_key": { + "description": "New status key.", + "type": "string", + "example": "in_progress" + }, + "priority_key": { + "description": "New priority key.", + "type": "string", + "example": "high" + }, + "assignee_id": { + "description": "New assignee (operator) user id, or null to unassign.", + "type": "integer", + "nullable": true + }, + "team_id": { + "description": "New team id, or null to clear.", + "type": "integer", + "nullable": true + }, + "subject": { + "description": "New subject (details update).", + "type": "string" + }, + "body": { + "description": "New body (details update).", + "type": "string" + }, + "subcategory_id": { + "description": "New subcategory id (details update).", + "type": "integer", + "nullable": true + }, + "custom_values": { + "description": "Custom field values (details update).", + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Ticket" + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Missing the tickets:write ability" + }, + "404": { + "description": "Ticket not found" + }, + "422": { + "description": "Validation error" + } + }, + "security": [ + { + "sanctum": [] + } + ] + } + }, + "/tickets/{ticket}/messages": { + "get": { + "tags": [ + "Ticket Messages" + ], + "summary": "List a ticket's messages", + "description": "Includes internal (operator-only) notes — an API key's tickets:read ability grants the same visibility as an operator.", + "operationId": "807fa3110de882e99e1f08bd0e928015", + "parameters": [ + { + "name": "ticket", + "in": "path", + "description": "Ticket id.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TicketMessage" + } + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Missing the tickets:read ability" + }, + "404": { + "description": "Ticket not found" + } + }, + "security": [ + { + "sanctum": [] + } + ] + }, + "post": { + "tags": [ + "Ticket Messages" + ], + "summary": "Add a message to a ticket", + "operationId": "d48794c0b95cf54881f637643821c23f", + "parameters": [ + { + "name": "ticket", + "in": "path", + "description": "Ticket id.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "required": [ + "body" + ], + "properties": { + "body": { + "type": "string", + "example": "Problem nadal występuje." + }, + "internal": { + "description": "Whether this is an internal (operator-only) note. Defaults to false.", + "type": "boolean" + }, + "author_name": { + "description": "Display name for the message author. Defaults to the API key's name.", + "type": "string", + "nullable": true + }, + "attachments": { + "description": "Files to attach to the message.", + "type": "array", + "items": { + "type": "string", + "format": "binary" + } + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/TicketMessage" + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Missing the tickets:write ability" + }, + "404": { + "description": "Ticket not found" + }, + "422": { + "description": "Validation error" + } + }, + "security": [ + { + "sanctum": [] + } + ] + } + }, + "/users": { + "get": { + "tags": [ + "Users" + ], + "summary": "List users", + "operationId": "7208c29037df2b8bb02499ba39ed8a78", + "parameters": [ + { + "name": "role", + "in": "query", + "description": "Filter by role key.", + "schema": { + "type": "string", + "enum": [ + "client", + "operator", + "admin" + ] + }, + "example": "operator" + }, + { + "name": "per_page", + "in": "query", + "description": "Results per page (max 100).", + "schema": { + "type": "integer" + }, + "example": 25 + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Missing the users:read ability" + } + }, + "security": [ + { + "sanctum": [] + } + ] + } + }, + "/users/{user}": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get a user", + "operationId": "a8a7a1f979a2abb839b4b320238106fc", + "parameters": [ + { + "name": "user", + "in": "path", + "description": "User id.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/User" + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Missing the users:read ability" + }, + "404": { + "description": "User not found" + } + }, + "security": [ + { + "sanctum": [] + } + ] + } + } + }, + "components": { + "schemas": { + "Category": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "Sieć" + }, + "description": { + "type": "string", + "example": "Zgłoszenia dotyczące sieci i VPN", + "nullable": true + }, + "subcategories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Subcategory" + } + } + }, + "type": "object" + }, + "CustomField": { + "properties": { + "id": { + "type": "integer", + "example": 5 + }, + "label": { + "type": "string", + "example": "Numer inwentarzowy" + }, + "type": { + "type": "string", + "example": "text", + "enum": [ + "text", + "textarea", + "select", + "checkbox", + "date", + "number" + ] + }, + "required": { + "type": "boolean", + "example": false + }, + "options": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "sort_order": { + "type": "integer", + "example": 0 + } + }, + "type": "object" + }, + "Priority": { + "properties": { + "key": { + "type": "string", + "example": "high" + }, + "label": { + "type": "string", + "example": "Wysoki" + }, + "color": { + "type": "string", + "example": "#e02424" + }, + "sort_order": { + "type": "integer", + "example": 2 + } + }, + "type": "object" + }, + "Status": { + "properties": { + "key": { + "type": "string", + "example": "in_progress" + }, + "label": { + "type": "string", + "example": "W trakcie" + }, + "color": { + "type": "string", + "example": "#f59e0b" + }, + "stage": { + "type": "string", + "example": "open" + }, + "sort_order": { + "type": "integer", + "example": 1 + } + }, + "type": "object" + }, + "Subcategory": { + "properties": { + "id": { + "type": "integer", + "example": 2 + }, + "name": { + "type": "string", + "example": "VPN" + }, + "description": { + "type": "string", + "example": "Problemy z połączeniem VPN", + "nullable": true + }, + "default_priority_key": { + "type": "string", + "example": "medium", + "nullable": true + }, + "custom_fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomField" + } + } + }, + "type": "object" + }, + "Team": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "Wsparcie IT" + } + }, + "type": "object" + }, + "TicketAttachment": { + "properties": { + "id": { + "type": "integer", + "example": 7 + }, + "original_name": { + "type": "string", + "example": "zrzut-ekranu.png" + }, + "size": { + "type": "integer", + "example": 204800 + }, + "mime": { + "type": "string", + "example": "image/png" + }, + "url": { + "type": "string", + "format": "uri", + "example": "https://servicedesk.example.pl/storage/attachments/zrzut-ekranu.png" + } + }, + "type": "object" + }, + "TicketMessage": { + "properties": { + "id": { + "type": "integer", + "example": 10 + }, + "ticket_id": { + "type": "integer", + "example": 42 + }, + "author_name": { + "type": "string", + "example": "Jan Kowalski" + }, + "role": { + "type": "string", + "example": "customer" + }, + "internal": { + "type": "boolean", + "example": false + }, + "body": { + "type": "string", + "example": "Problem nadal występuje." + }, + "edited": { + "type": "boolean", + "example": false + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TicketAttachment" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + }, + "Ticket": { + "properties": { + "id": { + "type": "integer", + "example": 42 + }, + "number": { + "type": "string", + "example": "1042" + }, + "subject": { + "type": "string", + "example": "VPN nie działa" + }, + "body": { + "type": "string", + "example": "Nie mogę się połączyć z VPN od rana." + }, + "email": { + "type": "string", + "format": "email", + "example": "jan.kowalski@firma.pl" + }, + "name": { + "type": "string", + "example": "Jan Kowalski" + }, + "customer_id": { + "type": "integer", + "example": 5, + "nullable": true + }, + "assignee_id": { + "type": "integer", + "example": 3, + "nullable": true + }, + "team_id": { + "type": "integer", + "example": 1, + "nullable": true + }, + "status": { + "properties": { + "key": { + "type": "string", + "example": "in_progress" + }, + "label": { + "type": "string", + "example": "W trakcie" + } + }, + "type": "object" + }, + "priority": { + "properties": { + "key": { + "type": "string", + "example": "high" + }, + "label": { + "type": "string", + "example": "Wysoki" + } + }, + "type": "object" + }, + "category": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "Sieć" + }, + "description": { + "type": "string", + "nullable": true + }, + "subcategory": { + "$ref": "#/components/schemas/Subcategory" + } + }, + "type": "object", + "nullable": true + }, + "custom_fields": { + "type": "object", + "example": { + "5": "INV-2024-001" + }, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + }, + "User": { + "properties": { + "id": { + "type": "integer", + "example": 3 + }, + "name": { + "type": "string", + "example": "Jan Kowalski" + }, + "email": { + "type": "string", + "format": "email", + "example": "jan.kowalski@firma.pl" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "client", + "operator", + "admin" + ] + } + }, + "custom_fields": { + "type": "array", + "items": { + "properties": { + "label": { + "type": "string", + "example": "Dział" + }, + "value": { + "type": "string", + "example": "Księgowość" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + }, + "securitySchemes": { + "sanctum": { + "type": "http", + "scheme": "bearer", + "description": "Klucz API wygenerujesz w panelu administratora, w zakładce Ustawienia → Klucze API." + } + } + }, + "tags": [ + { + "name": "Dictionaries", + "description": "Dictionaries" + }, + { + "name": "Tickets", + "description": "Tickets" + }, + { + "name": "Ticket Messages", + "description": "Ticket Messages" + }, + { + "name": "Users", + "description": "Users" + } + ], + "security": [ + { + "sanctum": [] + } + ] +} \ No newline at end of file diff --git a/src/storage/app/.gitignore b/src/storage/app/.gitignore new file mode 100644 index 0000000..fedb287 --- /dev/null +++ b/src/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/src/storage/app/private/.gitignore b/src/storage/app/private/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/storage/app/public/.gitignore b/src/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/storage/framework/.gitignore b/src/storage/framework/.gitignore new file mode 100644 index 0000000..05c4471 --- /dev/null +++ b/src/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/src/storage/framework/cache/.gitignore b/src/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/src/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/src/storage/framework/cache/data/.gitignore b/src/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/storage/framework/sessions/.gitignore b/src/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/storage/framework/testing/.gitignore b/src/storage/framework/testing/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/storage/framework/views/.gitignore b/src/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/storage/logs/.gitignore b/src/storage/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/tests/Feature/AdminBrandingTest.php b/src/tests/Feature/AdminBrandingTest.php new file mode 100644 index 0000000..8eba517 --- /dev/null +++ b/src/tests/Feature/AdminBrandingTest.php @@ -0,0 +1,131 @@ +test(Panel::class) + ->call('setTab', 'branding') + ->call('setAccentColor', '#123abc') + ->assertSet('accentColor', '#123abc'); + + expect(Settings::accentColor())->toBe('#123abc'); +}); + +test('an invalid accent color is rejected and the previous value is kept', function () { + $admin = adminUser(); + Settings::set('accent_color', '#123abc'); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'branding') + ->call('setAccentColor', 'not-a-color; }') + ->assertSet('accentColor', '#123abc'); + + expect(Settings::accentColor())->toBe('#123abc'); +}); + +test('resetting the accent color restores the default purple', function () { + $admin = adminUser(); + Settings::set('accent_color', '#123abc'); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'branding') + ->call('resetAccentColor') + ->assertSet('accentColor', '#7c6fd6'); + + expect(Settings::accentColor())->toBe('#7c6fd6'); +}); + +test('a malformed stored accent color falls back to the default instead of breaking every page', function () { + Settings::set('accent_color', 'javascript:alert(1)'); + + expect(Settings::accentColor())->toBe('#7c6fd6'); +}); + +test('the branding tab lists the logo section below company name and colors, not beside them', function () { + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'branding') + ->assertSeeInOrder(['Nazwa firmy', 'Kolor akcentu', 'Logo']); +}); + +test('without any custom upload, the logo and favicon both resolve to the bundled default mark', function () { + expect(Settings::logoUrl())->toContain('branding/default-mark.svg') + ->and(Settings::faviconUrl())->toContain('branding/default-mark.svg'); +}); + +test('admin can upload a custom svg favicon next to the logo config', function () { + Storage::fake('public'); + $admin = adminUser(); + $svg = ''; + $file = UploadedFile::fake()->createWithContent('favicon.svg', $svg); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'branding') + ->set('faviconUpload', $file) + ->assertSet('faviconError', null); + + $path = Settings::get('favicon_path'); + expect($path)->not->toBeNull(); + Storage::disk('public')->assertExists($path); + expect(Storage::disk('public')->get($path))->toContain('create('favicon.png', 10, 'image/png'); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'branding') + ->set('faviconUpload', $file) + ->assertSet('faviconError', 'Favicon musi być plikiem w formacie SVG.'); + + expect(Settings::get('favicon_path'))->toBeNull(); +}); + +test('uploading a favicon strips embedded scripts and inline event handlers before storing it', function () { + Storage::fake('public'); + $admin = adminUser(); + $malicious = ''; + $file = UploadedFile::fake()->createWithContent('favicon.svg', $malicious); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'branding') + ->set('faviconUpload', $file); + + $stored = Storage::disk('public')->get(Settings::get('favicon_path')); + + expect($stored)->not->toContain('and($stored)->not->toContain('onload=') + ->and($stored)->not->toContain('onclick='); +}); + +test('removing a custom favicon falls back to the default mark again', function () { + Storage::fake('public'); + $admin = adminUser(); + $svg = ''; + $file = UploadedFile::fake()->createWithContent('favicon.svg', $svg); + + $component = Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'branding') + ->set('faviconUpload', $file); + + $path = Settings::get('favicon_path'); + + $component->call('removeFavicon'); + + Storage::disk('public')->assertMissing($path); + expect(Settings::get('favicon_path'))->toBeNull() + ->and(Settings::faviconUrl())->toContain('branding/default-mark.svg'); +}); + +test('the login page shows the configured logo', function () { + $this->get('/login')->assertOk()->assertSee('branding/default-mark.svg', false); +}); diff --git a/src/tests/Feature/AdminCategoryManagementTest.php b/src/tests/Feature/AdminCategoryManagementTest.php new file mode 100644 index 0000000..48ea97b --- /dev/null +++ b/src/tests/Feature/AdminCategoryManagementTest.php @@ -0,0 +1,47 @@ +create(['name' => 'IT-Pomoc']); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'categories') + ->call('startEditCategory', $category->id, $category->name, $category->description) + ->set('editingCategoryDescription', 'Wsparcie techniczne dla sprzętu i oprogramowania.') + ->call('saveEditCategory') + ->assertOk(); + + expect($category->fresh()->description)->toBe('Wsparcie techniczne dla sprzętu i oprogramowania.'); +}); + +test('clearing a category description saves it as null, not an empty string', function () { + $admin = adminUser(); + $category = Category::query()->create(['name' => 'IT-Pomoc', 'description' => 'Stary opis']); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'categories') + ->call('startEditCategory', $category->id, $category->name, $category->description) + ->set('editingCategoryDescription', ' ') + ->call('saveEditCategory') + ->assertOk(); + + expect($category->fresh()->description)->toBeNull(); +}); + +test('admin can add a description to a subcategory via the edit dialog', function () { + $admin = adminUser(); + $category = Category::query()->create(['name' => 'IT-Pomoc']); + $sub = $category->subcategories()->create(['name' => 'VPN']); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('openSubcategoryEditForm', $sub->id) + ->set('subcategoryEditForm.description', 'Problemy z połączeniem VPN.') + ->call('submitSubcategoryEdit') + ->assertOk(); + + expect($sub->fresh()->description)->toBe('Problemy z połączeniem VPN.'); +}); diff --git a/src/tests/Feature/AdminStatusPriorityReorderTest.php b/src/tests/Feature/AdminStatusPriorityReorderTest.php new file mode 100644 index 0000000..64be2af --- /dev/null +++ b/src/tests/Feature/AdminStatusPriorityReorderTest.php @@ -0,0 +1,58 @@ +create(['key' => 'in_progress', 'label' => 'W trakcie', 'color' => '#000', 'stage' => 'open', 'sort_order' => 4]); + // seedStatusesAndPriorities() creates new(1)/open(2)/closed(3), so "in_progress" starts last. + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'statuses') + ->call('moveStatusUp', 'in_progress') + ->assertOk(); + + expect($extra->fresh()->sort_order)->toBe(3) + ->and(Status::query()->find('closed')->sort_order)->toBe(4); +}); + +test('moving the first status up is a no-op', function () { + $admin = adminUser(); + seedStatusesAndPriorities(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('moveStatusUp', 'new') + ->assertOk(); + + expect(Status::query()->find('new')->sort_order)->toBe(1); +}); + +test('admin can move a priority down, swapping sort_order with its neighbor', function () { + $admin = adminUser(); + seedStatusesAndPriorities(); + $low = Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#000', 'sort_order' => 2]); + // seedStatusesAndPriorities() creates "high" at sort_order 1. + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'priorities') + ->call('movePriorityDown', 'high') + ->assertOk(); + + expect(Priority::query()->find('high')->sort_order)->toBe(2) + ->and($low->fresh()->sort_order)->toBe(1); +}); + +test('moving the last priority down is a no-op', function () { + $admin = adminUser(); + seedStatusesAndPriorities(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('movePriorityDown', 'high') + ->assertOk(); + + expect(Priority::query()->find('high')->sort_order)->toBe(1); +}); diff --git a/src/tests/Feature/AdminTeamManagementTest.php b/src/tests/Feature/AdminTeamManagementTest.php new file mode 100644 index 0000000..8ec62bf --- /dev/null +++ b/src/tests/Feature/AdminTeamManagementTest.php @@ -0,0 +1,113 @@ +create(['name' => 'Admin', 'email' => 'admin@example.com', 'roles' => ['admin']]); +} + +function operatorUser(string $email = 'op1@example.com'): User +{ + return User::query()->create(['name' => 'Operator '.$email, 'email' => $email, 'roles' => ['operator']]); +} + +function subcategoryFixture(): Subcategory +{ + $category = Category::query()->create(['name' => 'IT']); + + return $category->subcategories()->create(['name' => 'VPN']); +} + +test('admin can create a team with members and subcategories via the multiselect', function () { + $admin = adminUser(); + $op1 = operatorUser('op1@example.com'); + $op2 = operatorUser('op2@example.com'); + $sub = subcategoryFixture(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'teams') + ->call('openTeamForm') + ->set('teamForm.name', 'Infrastruktura') + ->call('toggleTeamMember', $op1->id) + ->call('toggleTeamMember', $op2->id) + ->call('toggleTeamSubcategory', $sub->id) + ->call('submitTeam') + ->assertOk(); + + $team = Team::query()->where('name', 'Infrastruktura')->firstOrFail(); + + expect($team->members->pluck('id')->sort()->values()->all())->toBe([$op1->id, $op2->id]) + ->and($team->subcategories()->pluck('subcategories.id')->all())->toBe([$sub->id]); +}); + +test('admin can edit a team, swap out a member and change its subcategories', function () { + $admin = adminUser(); + $op1 = operatorUser('op1@example.com'); + $op2 = operatorUser('op2@example.com'); + $subA = subcategoryFixture(); + $subB = Category::query()->create(['name' => 'Admin'])->subcategories()->create(['name' => 'Wnioski']); + + $team = Team::query()->create(['name' => 'Zespół A']); + $team->subcategories()->attach($subA->id); + $op1->teams()->attach($team->id); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('editTeam', $team->id) + ->assertSet('teamForm.name', 'Zespół A') + ->assertSet('teamForm.memberIds', [$op1->id]) + ->set('teamForm.name', 'Zespół A zmieniony') + ->call('toggleTeamMember', $op1->id) // remove op1 + ->call('toggleTeamMember', $op2->id) // add op2 + ->call('toggleTeamSubcategory', $subA->id) // remove subA + ->call('toggleTeamSubcategory', $subB->id) // add subB + ->call('submitTeam') + ->assertOk(); + + $team->refresh(); + + expect($team->name)->toBe('Zespół A zmieniony') + ->and($op1->fresh()->teams->pluck('id')->all())->toBe([]) + ->and($op2->fresh()->teams->pluck('id')->all())->toBe([$team->id]) + ->and($team->subcategories()->pluck('subcategories.id')->all())->toBe([$subB->id]); +}); + +test('admin can delete a team, which unassigns its members without touching their roles', function () { + $admin = adminUser(); + $op1 = operatorUser('op1@example.com'); + + $team = Team::query()->create(['name' => 'Do usunięcia']); + $op1->teams()->attach($team->id); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('removeTeam', $team->id) + ->assertSet('pendingDeleteType', 'team') + ->call('confirmPendingDelete') + ->assertOk(); + + expect(Team::query()->find($team->id))->toBeNull() + ->and($op1->fresh()->teams)->toBeEmpty() + ->and($op1->fresh()->roles)->toBe(['operator']); +}); + +test('a user can belong to more than one team at once, and the users list shows all of them', function () { + $admin = adminUser(); + $op1 = operatorUser('op1@example.com'); + + $teamA = Team::query()->create(['name' => 'Infrastruktura']); + $teamB = Team::query()->create(['name' => 'Aplikacje']); + + $op1->teams()->attach([$teamA->id, $teamB->id]); + + expect($op1->fresh()->teams->pluck('name')->sort()->values()->all())->toBe(['Aplikacje', 'Infrastruktura']); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'users') + ->assertSee('Infrastruktura') + ->assertSee('Aplikacje'); +}); diff --git a/src/tests/Feature/AdminUserManagementTest.php b/src/tests/Feature/AdminUserManagementTest.php new file mode 100644 index 0000000..4ca5c11 --- /dev/null +++ b/src/tests/Feature/AdminUserManagementTest.php @@ -0,0 +1,60 @@ +create(['name' => 'Old Name', 'email' => 'old@example.com', 'roles' => ['client']]); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('editUser', $target->id) + ->assertSet('userForm.name', 'Old Name') + ->assertSet('userForm.email', 'old@example.com') + ->set('userForm.name', 'New Name') + ->set('userForm.email', 'new@example.com') + ->call('toggleUserFormRole', 'operator') + ->call('submitUser') + ->assertOk(); + + $target->refresh(); + + expect($target->name)->toBe('New Name') + ->and($target->email)->toBe('new@example.com') + ->and($target->roles)->toContain('client', 'operator'); +}); + +test('editing a user does not trip the unique email rule against itself', function () { + $admin = adminUser(); + $target = User::query()->create(['name' => 'Someone', 'email' => 'someone@example.com', 'roles' => ['client']]); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('editUser', $target->id) + ->set('userForm.name', 'Someone Else') + ->call('submitUser') + ->assertHasNoErrors(); +}); + +test('admin can delete another user', function () { + $admin = adminUser(); + $target = User::query()->create(['name' => 'Delete Me', 'email' => 'deleteme@example.com', 'roles' => ['client']]); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('removeUser', $target->id) + ->assertSet('pendingDeleteType', 'user') + ->call('confirmPendingDelete') + ->assertOk(); + + expect(User::query()->find($target->id))->toBeNull(); +}); + +test('an admin cannot delete their own account', function () { + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('removeUser', $admin->id) + ->assertSet('pendingDeleteType', null); + + expect(User::query()->find($admin->id))->not->toBeNull(); +}); diff --git a/src/tests/Feature/ApiAbilityEnforcementTest.php b/src/tests/Feature/ApiAbilityEnforcementTest.php new file mode 100644 index 0000000..ee3d4af --- /dev/null +++ b/src/tests/Feature/ApiAbilityEnforcementTest.php @@ -0,0 +1,43 @@ + ['GET', '/api/v1/tickets', 'tickets:read'], + 'GET categories' => ['GET', '/api/v1/categories', 'dictionaries:read'], + 'GET users' => ['GET', '/api/v1/users', 'users:read'], + ]; +}); + +test('a request with no token is unauthenticated', function (string $method, string $uri) { + $this->json($method, $uri)->assertUnauthorized(); +})->with('protected_endpoints'); + +test('a token missing the required ability is forbidden', function (string $method, string $uri, string $requiredAbility) { + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['some:other-ability']); + + $this->json($method, $uri)->assertForbidden(); +})->with('protected_endpoints'); + +test('a token with the required ability is allowed', function (string $method, string $uri, string $requiredAbility) { + seedStatusesAndPriorities(); + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, [$requiredAbility]); + + $this->json($method, $uri)->assertOk(); +})->with('protected_endpoints'); + +test('a token whose underlying access token has been deleted is rejected', function () { + $client = ApiClient::factory()->create(); + $token = $client->createToken('test', ['tickets:read']); + $plaintext = $token->plainTextToken; + + $client->tokens()->delete(); + + $this->withHeader('Authorization', 'Bearer '.$plaintext) + ->getJson('/api/v1/tickets') + ->assertUnauthorized(); +}); diff --git a/src/tests/Feature/ApiKeyManagementTest.php b/src/tests/Feature/ApiKeyManagementTest.php new file mode 100644 index 0000000..6cd3190 --- /dev/null +++ b/src/tests/Feature/ApiKeyManagementTest.php @@ -0,0 +1,79 @@ +create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]); + + $this->actingAs($operator)->get('/admin')->assertForbidden(); +}); + +test('admin creates an api key and sees the plaintext token exactly once', function () { + $admin = adminUser(); + + $component = Livewire::actingAs($admin)->test(ApiKeys::class) + ->call('openForm') + ->set('form.name', 'Monitoring integracja') + ->set('form.description', 'Tworzy zgłoszenia z systemu monitoringu') + ->set('form.abilities', ['tickets:read', 'tickets:write']) + ->call('submit') + ->assertOk(); + + $client = ApiClient::query()->where('name', 'Monitoring integracja')->firstOrFail(); + + expect($client->created_by)->toBe($admin->id); + expect($client->tokens()->count())->toBe(1); + expect($client->tokens()->first()->abilities)->toBe(['tickets:read', 'tickets:write']); + + $component->assertSet('newTokenPlaintext', fn ($value) => is_string($value) && str_contains($value, '|')); + + // Re-rendering (e.g. a page refresh) must not resurrect the plaintext secret. + $fresh = Livewire::actingAs($admin)->test(ApiKeys::class); + $fresh->assertSet('newTokenPlaintext', null); +}); + +test('creating a key requires a name and at least one ability', function () { + $admin = adminUser(); + + Livewire::actingAs($admin)->test(ApiKeys::class) + ->call('openForm') + ->set('form.name', '') + ->set('form.abilities', []) + ->call('submit') + ->assertHasErrors(['form.name', 'form.abilities']); +}); + +test('admin revokes a key and its token stops working', function () { + $admin = adminUser(); + $apiClient = ApiClient::factory()->create(); + $token = $apiClient->createToken('test', ['tickets:read']); + $plaintext = $token->plainTextToken; + + Livewire::actingAs($admin)->test(ApiKeys::class) + ->call('revoke', $apiClient->id) + ->assertOk(); + + $apiClient->refresh(); + expect($apiClient->isRevoked())->toBeTrue(); + expect($apiClient->tokens()->count())->toBe(0); +}); + +test('admin regenerates a key, keeping its abilities but issuing a new token', function () { + $admin = adminUser(); + $apiClient = ApiClient::factory()->create(); + $oldToken = $apiClient->createToken('test', ['tickets:read', 'users:read']); + $oldTokenId = $oldToken->accessToken->id; + + Livewire::actingAs($admin)->test(ApiKeys::class) + ->call('regenerate', $apiClient->id) + ->assertOk(); + + $apiClient->refresh(); + expect($apiClient->isRevoked())->toBeFalse(); + expect($apiClient->tokens()->count())->toBe(1); + expect($apiClient->tokens()->first()->abilities)->toBe(['tickets:read', 'users:read']); + expect($apiClient->tokens()->first()->id)->not->toBe($oldTokenId); +}); diff --git a/src/tests/Feature/AttachmentLimitsTest.php b/src/tests/Feature/AttachmentLimitsTest.php new file mode 100644 index 0000000..d0c2af8 --- /dev/null +++ b/src/tests/Feature/AttachmentLimitsTest.php @@ -0,0 +1,123 @@ +create('big.txt', 200); // 200 KB > 100 KB limit + + expect(Settings::validateAttachment($file))->toContain('za duży'); +}); + +test('Settings::validateAttachment rejects a disallowed file extension', function () { + Settings::set('attachment_max_size_kb', '10240'); + Settings::set('attachment_allowed_types', 'jpg,png,pdf'); + + $file = UploadedFile::fake()->create('script.exe', 10); + + expect(Settings::validateAttachment($file))->toContain('Niedozwolony typ'); +}); + +test('Settings::validateAttachment passes a file within size and an allowed extension', function () { + Settings::set('attachment_max_size_kb', '10240'); + Settings::set('attachment_allowed_types', 'jpg,png,pdf'); + + $file = UploadedFile::fake()->create('photo.jpg', 500); + + expect(Settings::validateAttachment($file))->toBeNull(); +}); + +test('an empty allowed-types setting means no extension restriction, only size', function () { + Settings::set('attachment_max_size_kb', '10240'); + Settings::set('attachment_allowed_types', ''); + + $file = UploadedFile::fake()->create('whatever.xyz', 10); + + expect(Settings::validateAttachment($file))->toBeNull(); +}); + +test('an oversized attachment on a new client ticket is rejected client-side, resets the picker, and the ticket still submits without it', function () { + Storage::fake('public'); + seedStatusesAndPriorities(); + Settings::set('attachment_max_size_kb', '50'); + + $client = User::query()->create(['name' => 'Ola', 'email' => 'ola-attach@example.com', 'roles' => ['client']]); + $category = Category::query()->create(['name' => 'IT']); + $sub = $category->subcategories()->create(['name' => 'VPN']); + $bigFile = UploadedFile::fake()->create('too-big.pdf', 500); + + Livewire::actingAs($client)->test(ClientNewTicket::class) + ->call('selectCategory', $category->id) + ->call('selectSubcategory', $sub->id) + ->set('subject', 'Temat') + ->set('body', 'Opis') + ->set('attachments', [$bigFile]) + ->assertHasErrors(['attachments']) + ->assertSet('attachments', []); +}); + +test('an attachment of a disallowed type on a client reply is rejected and never reaches the ticket', function () { + Storage::fake('public'); + seedStatusesAndPriorities(); + Settings::set('attachment_allowed_types', 'jpg,png,pdf'); + + $client = User::query()->create(['name' => 'Ewa', 'email' => 'ewa-attach2@example.com', 'roles' => ['client']]); + $ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]); + $badFile = UploadedFile::fake()->create('malware.exe', 10); + + Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket]) + ->set('reply', 'Oto plik.') + ->set('attachments', [$badFile]) + ->assertHasErrors(['attachments']) + ->assertSet('attachments', []) + ->call('sendReply'); + + $message = $ticket->messages()->where('body', 'Oto plik.')->firstOrFail(); + expect($message->attachments)->toHaveCount(0); +}); + +test('Settings::validateAttachments rejects a batch over the configured max file count', function () { + Settings::set('attachment_max_count', '2'); + + $files = [ + UploadedFile::fake()->create('one.pdf', 10), + UploadedFile::fake()->create('two.pdf', 10), + UploadedFile::fake()->create('three.pdf', 10), + ]; + + expect(Settings::validateAttachments($files))->toContain('maksymalnie 2'); +}); + +test('Settings::validateAttachments rejects a batch over the configured max combined size', function () { + Settings::set('attachment_max_count', '10'); + Settings::set('attachment_max_total_size_kb', '100'); + + $files = [ + UploadedFile::fake()->create('one.pdf', 60), + UploadedFile::fake()->create('two.pdf', 60), + ]; + + expect(Settings::validateAttachments($files))->toContain('Łączny rozmiar'); +}); + +test('Settings::validateAttachments passes a batch within count and size limits', function () { + Settings::set('attachment_max_count', '5'); + Settings::set('attachment_max_total_size_kb', '10240'); + Settings::set('attachment_max_size_kb', '10240'); + Settings::set('attachment_allowed_types', ''); + + $files = [ + UploadedFile::fake()->create('one.pdf', 10), + UploadedFile::fake()->create('two.pdf', 10), + ]; + + expect(Settings::validateAttachments($files))->toBeNull(); +}); diff --git a/src/tests/Feature/DictionariesApiTest.php b/src/tests/Feature/DictionariesApiTest.php new file mode 100644 index 0000000..24a7b13 --- /dev/null +++ b/src/tests/Feature/DictionariesApiTest.php @@ -0,0 +1,61 @@ +create(['name' => 'IT']); + $sub = $category->subcategories()->create(['name' => 'VPN']); + + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['dictionaries:read']); + + $this->getJson('/api/v1/categories') + ->assertOk() + ->assertJsonPath('data.0.name', 'IT') + ->assertJsonPath('data.0.subcategories.0.name', 'VPN'); +}); + +test('statuses are returned ordered by sort_order', function () { + seedStatusesAndPriorities(); + + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['dictionaries:read']); + + $this->getJson('/api/v1/statuses') + ->assertOk() + ->assertJsonPath('data.0.key', 'new'); +}); + +test('priorities are returned ordered by sort_order', function () { + seedStatusesAndPriorities(); + + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['dictionaries:read']); + + $this->getJson('/api/v1/priorities') + ->assertOk() + ->assertJsonPath('data.0.key', 'high'); +}); + +test('teams are returned', function () { + Team::query()->create(['name' => 'Infra']); + + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['dictionaries:read']); + + $this->getJson('/api/v1/teams') + ->assertOk() + ->assertJsonPath('data.0.name', 'Infra'); +}); + +test('dictionaries require the dictionaries:read ability', function () { + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['tickets:read']); + + $this->getJson('/api/v1/categories')->assertForbidden(); +}); diff --git a/src/tests/Feature/EmailLayoutTest.php b/src/tests/Feature/EmailLayoutTest.php new file mode 100644 index 0000000..3cb081e --- /dev/null +++ b/src/tests/Feature/EmailLayoutTest.php @@ -0,0 +1,94 @@ +{tresc}'); + Settings::set('email_footer', 'Pozdrawiamy, Wsparcie'); + Settings::set('company_name', 'Acme'); + + $html = Settings::renderEmailLayout('

Treść zgłoszenia

'); + + expect($html)->not->toContain('should-be-ignored') + ->and($html)->toBe(strtr(Settings::default('email_layout_html'), [ + '{tresc}' => '

Treść zgłoszenia

', + '{stopka}' => 'Pozdrawiamy, Wsparcie', + '{firma}' => 'Acme', + ])); +}); + +test('the default footer is not empty and its own {firma} placeholder resolves through the layout', function () { + Settings::set('company_name', 'Acme'); + + $html = Settings::renderEmailLayout('

Treść

'); + + expect(Settings::default('email_footer'))->not->toBeEmpty() + ->and($html)->toContain('Acme') + ->and($html)->not->toContain('{firma}'); +}); + +test('admin can reset the email footer back to its default, remounting the editor with a fresh key', function () { + $admin = adminUser(); + + $component = Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'templates') + ->call('saveEmailFooter', 'Coś innego') + ->assertSet('emailFooterVersion', 0); + + $component->call('resetEmailFooter') + ->assertOk() + ->assertSet('emailFooterHtml', Settings::default('email_footer')) + ->assertSet('emailFooterVersion', 1) + ->assertSee('email-footer-1', escape: false); + + expect(Settings::get('email_footer'))->toBe(Settings::default('email_footer')); +}); + +test('admin can save the email footer from the Szablony e-mail tab (moved out of Konfiguracja)', function () { + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'templates') + ->call('saveEmailFooter', '

Pozdrawiamy, Zespół Wsparcia

') + ->assertOk() + ->assertSet('emailFooterHtml', '

Pozdrawiamy, Zespół Wsparcia

'); + + expect(Settings::get('email_footer'))->toBe('

Pozdrawiamy, Zespół Wsparcia

'); +}); + +test('the live example preview reflects the currently saved footer', function () { + $admin = adminUser(); + + $component = Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'templates') + ->call('saveEmailFooter', 'Stopka na żywo'); + + expect($component->instance()->emailPreviewHtml)->toContain('Stopka na żywo') + ->and($component->instance()->emailPreviewHtml)->toContain('#1234'); +}); + +test('there is no more editor for the box layout itself, only for its footer', function () { + expect(method_exists(Panel::class, 'saveEmailLayout'))->toBeFalse() + ->and(method_exists(Panel::class, 'resetEmailLayout'))->toBeFalse(); +}); + +test('a rendered ticket notification embeds its template body inside the fixed box layout', function () { + seedStatusesAndPriorities(); + + $template = EmailTemplate::query()->create([ + 'key' => 'tpl-layout-test', 'name' => 'x', 'trigger_label' => 'x', + 'subject' => 'S', 'body' => '

Zgłoszenie #{numer} zostało utworzone.

', + ]); + $ticket = makeTicket(); + + $mail = (new TicketNotification($ticket, $template->id))->toMail((object) ['routes' => ['mail' => $ticket->email]]); + + expect($mail->viewData['html'])->toBe(Settings::renderEmailLayout('

Zgłoszenie #'.$ticket->number.' zostało utworzone.

')) + ->and($mail->view)->toBe('emails.ticket-notification'); +}); diff --git a/src/tests/Feature/EmailNotificationsAdminTest.php b/src/tests/Feature/EmailNotificationsAdminTest.php new file mode 100644 index 0000000..3777c7e --- /dev/null +++ b/src/tests/Feature/EmailNotificationsAdminTest.php @@ -0,0 +1,80 @@ +where('trigger_key', 'status_changed')->firstOrFail(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'templates') + ->call('toggleNotificationEnabled', $statusChanged->id) + ->assertOk(); + + expect($statusChanged->fresh()->enabled)->toBeFalse(); + + expect(method_exists(Panel::class, 'openEmailTemplateForm'))->toBeFalse() + ->and(method_exists(Panel::class, 'submitEmailTemplateForm'))->toBeFalse() + ->and(method_exists(Panel::class, 'removeEmailTemplate'))->toBeFalse() + ->and(method_exists(Panel::class, 'setNotificationTemplate'))->toBeFalse(); +}); + +test('admin can edit the subject and body of a trigger\'s fixed template', function () { + $admin = adminUser(); + + // A bare migrated (unseeded) database has no email_templates rows yet, so + // ticket_created's binding is still null at this point — give it one. + $template = EmailTemplate::query()->create(['key' => 'tpl-created-test', 'name' => 'Nowe', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B']); + $ticketCreated = NotificationSetting::query()->where('trigger_key', 'ticket_created')->firstOrFail(); + $ticketCreated->update(['email_template_id' => $template->id]); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'templates') + ->call('editEmailTemplate', $ticketCreated->email_template_id) + ->call('setTemplateSubject', 'Nowy temat #{numer}') + ->call('setTemplateBody', 'Nowa treść {imie}') + ->call('closeEmailTemplateEditor') + ->assertSet('editingTemplateId', null) + ->assertOk(); + + $template = EmailTemplate::query()->find($ticketCreated->email_template_id); + expect($template->subject)->toBe('Nowy temat #{numer}') + ->and($template->body)->toBe('Nowa treść {imie}'); +}); + +test('a disabled trigger sends no notification, an enabled one sends the assigned template with a working ticket link', function () { + Notification::fake(); + seedStatusesAndPriorities(); + + $template = EmailTemplate::query()->create([ + 'key' => 'tpl-new-test', 'name' => 'Nowe', 'trigger_label' => 'x', + 'subject' => 'Zgloszenie #{numer}', 'body' => 'Podglad: {link}', + ]); + NotificationSetting::query()->where('trigger_key', 'ticket_created')->update(['email_template_id' => $template->id]); + NotificationSetting::query()->where('trigger_key', 'status_changed')->update(['enabled' => false]); + + $ticket = app(TicketService::class)->create([ + 'email' => 'client-notify@example.com', + 'subject' => 'Problem testowy', + 'body' => 'Opis problemu.', + ], null); + + Notification::assertSentOnDemand( + TicketNotification::class, + fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'client-notify@example.com' + ); + + app(TicketService::class)->setStatus($ticket, 'in_progress'); + + Notification::assertSentOnDemandTimes(TicketNotification::class, 1); + + $mail = (new TicketNotification($ticket->fresh(), $template->id))->toMail((object) ['routes' => ['mail' => $ticket->email]]); + expect($mail->subject)->toBe('Zgloszenie #'.$ticket->number) + ->and($mail->viewData['html'])->toContain(route('client.ticket', $ticket)); +}); diff --git a/src/tests/Feature/ExampleTest.php b/src/tests/Feature/ExampleTest.php new file mode 100644 index 0000000..8fdc86b --- /dev/null +++ b/src/tests/Feature/ExampleTest.php @@ -0,0 +1,7 @@ +get('/'); + + $response->assertStatus(200); +}); diff --git a/src/tests/Feature/ExtendedNotificationTriggersTest.php b/src/tests/Feature/ExtendedNotificationTriggersTest.php new file mode 100644 index 0000000..7841da4 --- /dev/null +++ b/src/tests/Feature/ExtendedNotificationTriggersTest.php @@ -0,0 +1,123 @@ +get()->keyBy('trigger_key'); + + expect($settings->keys()->sort()->values()->all())->toBe([ + 'assignee_changed', 'category_changed', 'operator_replied', 'priority_changed', + 'sla_breached', 'status_changed', 'team_changed', 'ticket_closed', 'ticket_created', + ]); + + foreach (['ticket_created', 'status_changed'] as $key) { + expect($settings[$key]->enabled)->toBeTrue(); + } + + foreach (['category_changed', 'assignee_changed', 'priority_changed', 'team_changed', 'ticket_closed', 'operator_replied', 'sla_breached'] as $key) { + expect($settings[$key]->enabled)->toBeFalse() + ->and($settings[$key]->email_template_id)->not->toBeNull(); + } +}); + +test('a disabled-by-default trigger sends nothing until an admin turns it on', function () { + Notification::fake(); + seedStatusesAndPriorities(); + $ticket = makeTicket(); + + app(TicketService::class)->setPriority($ticket, 'high'); + Notification::assertNothingSent(); + + NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]); + app(TicketService::class)->setPriority($ticket, 'high'); + Notification::assertSentOnDemandTimes(TicketNotification::class, 1); +}); + +test('changing the assignee fires assignee_changed with the {operator} placeholder once enabled', function () { + Notification::fake(); + seedStatusesAndPriorities(); + NotificationSetting::query()->where('trigger_key', 'assignee_changed')->update(['enabled' => true]); + + $ticket = makeTicket(); + $operator = User::query()->create(['name' => 'Jan Kowalski', 'email' => 'jan@example.com', 'roles' => ['operator']]); + + app(TicketService::class)->setAssignee($ticket, $operator); + + $templateId = NotificationSetting::query()->where('trigger_key', 'assignee_changed')->value('email_template_id'); + $mail = (new TicketNotification($ticket->fresh(), $templateId))->toMail((object) ['routes' => ['mail' => $ticket->email]]); + expect($mail->viewData['html'])->toContain('Jan Kowalski'); +}); + +test('changing the team fires team_changed with the {zespol} placeholder once enabled', function () { + Notification::fake(); + seedStatusesAndPriorities(); + NotificationSetting::query()->where('trigger_key', 'team_changed')->update(['enabled' => true]); + + $ticket = makeTicket(); + $team = Team::query()->create(['name' => 'IT']); + + app(TicketService::class)->setTeam($ticket, $team); + + $templateId = NotificationSetting::query()->where('trigger_key', 'team_changed')->value('email_template_id'); + $mail = (new TicketNotification($ticket->fresh(), $templateId))->toMail((object) ['routes' => ['mail' => $ticket->email]]); + expect($mail->viewData['html'])->toContain('IT'); +}); + +test('changing the subcategory fires category_changed, but re-saving details without changing it does not', function () { + Notification::fake(); + seedStatusesAndPriorities(); + NotificationSetting::query()->where('trigger_key', 'category_changed')->update(['enabled' => true]); + + $category = Category::query()->create(['name' => 'IT-Pomoc']); + $sub = $category->subcategories()->create(['name' => 'VPN']); + $ticket = makeTicket(); + + app(TicketService::class)->updateDetails($ticket, [ + 'subject' => $ticket->subject, 'body' => $ticket->body, 'subcategory_id' => $sub->id, 'custom_values' => [], + ]); + Notification::assertSentOnDemandTimes(TicketNotification::class, 1); + + app(TicketService::class)->updateDetails($ticket->fresh(), [ + 'subject' => 'Nowy temat', 'body' => $ticket->body, 'subcategory_id' => $sub->id, 'custom_values' => [], + ]); + Notification::assertSentOnDemandTimes(TicketNotification::class, 1); +}); + +test('closing a ticket fires both status_changed and ticket_closed', function () { + Notification::fake(); + seedStatusesAndPriorities(); + NotificationSetting::query()->where('trigger_key', 'ticket_closed')->update(['enabled' => true]); + + // status_changed ships enabled by default, but in a bare migrated (unseeded) + // database it has no template assigned yet — give it one so both triggers + // actually have something to send, isolating this test from seeding order. + $statusTemplate = EmailTemplate::query()->create([ + 'key' => 'tpl-status-test', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B', + ]); + NotificationSetting::query()->where('trigger_key', 'status_changed')->update(['email_template_id' => $statusTemplate->id]); + + $ticket = makeTicket(); + + app(TicketService::class)->setStatus($ticket, 'closed'); + + Notification::assertSentOnDemandTimes(TicketNotification::class, 2); +}); + +test('an operator reply fires operator_replied once enabled, independent of any status change', function () { + Notification::fake(); + seedStatusesAndPriorities(); + NotificationSetting::query()->where('trigger_key', 'operator_replied')->update(['enabled' => true]); + $ticket = makeTicket(); + $operator = User::query()->create(['name' => 'Op', 'email' => 'op-reply@example.com', 'roles' => ['operator']]); + + app(TicketService::class)->operatorReply($ticket, $operator, 'Sprawdzam temat.'); + + Notification::assertSentOnDemandTimes(TicketNotification::class, 1); +}); diff --git a/src/tests/Feature/LdapAccountCreationTogglesTest.php b/src/tests/Feature/LdapAccountCreationTogglesTest.php new file mode 100644 index 0000000..303a50d --- /dev/null +++ b/src/tests/Feature/LdapAccountCreationTogglesTest.php @@ -0,0 +1,106 @@ + 'znany.klient', + 'cn' => 'Znany Klient', + 'mail' => 'znany.klient@firma.pl', + 'entryuuid' => (string) Str::uuid(), + ]); + + $ticket = app(TicketService::class)->create([ + 'email' => 'znany.klient@firma.pl', + 'subject' => 'Zgłoszenie', + 'body' => 'Opis.', + ], null); + + expect($ticket->customer_id)->toBeNull() + ->and(User::query()->where('email', 'znany.klient@firma.pl')->exists())->toBeFalse(); +}); + +test('leaving ldap_auto_provision_guests enabled (the default) still auto-provisions as before', function () { + DirectoryEmulator::setup(); + seedStatusesAndPriorities(); + + LldapUser::create([ + 'uid' => 'znany.klient2', + 'cn' => 'Znany Klient Dwa', + 'mail' => 'znany.klient2@firma.pl', + 'entryuuid' => (string) Str::uuid(), + ]); + + $ticket = app(TicketService::class)->create([ + 'email' => 'znany.klient2@firma.pl', + 'subject' => 'Zgłoszenie', + 'body' => 'Opis.', + ], null); + + expect($ticket->customer_id)->not->toBeNull(); +}); + +test('restricting manual user creation to LDAP rejects an invite for an unknown e-mail', function () { + DirectoryEmulator::setup(); + Settings::set('restrict_user_creation_to_ldap', '1'); + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('openUserForm') + ->set('userForm.name', 'Ktoś Spoza') + ->set('userForm.email', 'nieznany@firma.pl') + ->call('submitUser') + ->assertHasErrors(['userForm.email']); + + expect(User::query()->where('email', 'nieznany@firma.pl')->exists())->toBeFalse(); +}); + +test('restricting manual user creation to LDAP still allows inviting an e-mail that exists in LDAP', function () { + DirectoryEmulator::setup(); + Settings::set('restrict_user_creation_to_ldap', '1'); + $admin = adminUser(); + + LldapUser::create([ + 'uid' => 'znany.pracownik', + 'cn' => 'Znany Pracownik', + 'mail' => 'znany.pracownik@firma.pl', + 'entryuuid' => (string) Str::uuid(), + ]); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('openUserForm') + ->set('userForm.name', 'Znany Pracownik') + ->set('userForm.email', 'znany.pracownik@firma.pl') + ->call('submitUser') + ->assertOk(); + + expect(User::query()->where('email', 'znany.pracownik@firma.pl')->exists())->toBeTrue(); +}); + +test('leaving the LDAP restriction off (the default) allows inviting any e-mail', function () { + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('openUserForm') + ->set('userForm.name', 'Ktokolwiek') + ->set('userForm.email', 'ktokolwiek@example.com') + ->call('submitUser') + ->assertOk(); + + expect(User::query()->where('email', 'ktokolwiek@example.com')->exists())->toBeTrue(); +}); diff --git a/src/tests/Feature/LdapLoginTest.php b/src/tests/Feature/LdapLoginTest.php new file mode 100644 index 0000000..afde671 --- /dev/null +++ b/src/tests/Feature/LdapLoginTest.php @@ -0,0 +1,65 @@ +create([ + 'name' => 'Placeholder Name', + 'email' => 'anna.kowalska@firma.pl', + 'roles' => ['operator', 'admin'], + ]); + + $ldapUser = LldapUser::create([ + 'uid' => 'anna.kowalska', + 'cn' => 'Anna Kowalska', + 'mail' => 'anna.kowalska@firma.pl', + 'entryuuid' => (string) Str::uuid(), + ]); + + $fake->actingAs($ldapUser); + + expect(Auth::attempt(['uid' => 'anna.kowalska', 'password' => 'whatever']))->toBeTrue(); + + expect(User::count())->toBe(1); + + $placeholder->refresh(); + expect($placeholder->name)->toBe('Anna Kowalska') + ->and($placeholder->roles)->toEqualCanonicalizing(['operator', 'admin']) // roles assigned by admin are preserved, not overwritten by sync + ->and($placeholder->guid)->not->toBeNull(); +}); + +test('a brand new ldap identity is provisioned locally with the client role by default', function () { + $fake = DirectoryEmulator::setup(); + + $ldapUser = LldapUser::create([ + 'uid' => 'new.person', + 'cn' => 'New Person', + 'mail' => 'new.person@firma.pl', + 'entryuuid' => (string) Str::uuid(), + ]); + + $fake->actingAs($ldapUser); + + expect(Auth::attempt(['uid' => 'new.person', 'password' => 'whatever']))->toBeTrue(); + + $user = User::query()->where('email', 'new.person@firma.pl')->first(); + + expect($user)->not->toBeNull() + ->and($user->roles)->toBe(['client']); +}); + +test('an unknown username does not authenticate', function () { + DirectoryEmulator::setup(); + + expect(Auth::attempt(['uid' => 'someone.else', 'password' => 'whatever']))->toBeFalse(); +}); diff --git a/src/tests/Feature/LdapRestrictedGuestTicketsAndLocalLoginTest.php b/src/tests/Feature/LdapRestrictedGuestTicketsAndLocalLoginTest.php new file mode 100644 index 0000000..95986f8 --- /dev/null +++ b/src/tests/Feature/LdapRestrictedGuestTicketsAndLocalLoginTest.php @@ -0,0 +1,173 @@ +set('email', 'nieznany@firma.pl') + ->set('subject', 'Temat') + ->set('body', 'Treść') + ->call('submit') + ->assertHasErrors(['email']) + ->assertSee('Nie znaleziono użytkownika o podanym adresie e-mail.'); + + expect(Ticket::query()->count())->toBe(0); +}); + +test('restricting guest tickets to LDAP still allows a submission from an e-mail that exists in LDAP', function () { + DirectoryEmulator::setup(); + seedStatusesAndPriorities(); + Settings::set('restrict_tickets_to_ldap', '1'); + + LldapUser::create([ + 'uid' => 'znany.gosc', + 'cn' => 'Znany Gość', + 'mail' => 'znany.gosc@firma.pl', + 'entryuuid' => (string) Str::uuid(), + ]); + + Livewire::test(Landing::class) + ->set('email', 'znany.gosc@firma.pl') + ->set('subject', 'Temat') + ->set('body', 'Treść') + ->call('submit') + ->assertHasNoErrors(); + + expect(Ticket::query()->where('email', 'znany.gosc@firma.pl')->exists())->toBeTrue(); +}); + +test('restricting guest tickets to LDAP still allows an already-known local account even if LDAP lookup fails', function () { + DirectoryEmulator::setup(); + seedStatusesAndPriorities(); + Settings::set('restrict_tickets_to_ldap', '1'); + + User::query()->create(['name' => 'Istniejący Klient', 'email' => 'istniejacy@firma.pl', 'roles' => ['client']]); + + Livewire::test(Landing::class) + ->set('email', 'istniejacy@firma.pl') + ->set('subject', 'Temat') + ->set('body', 'Treść') + ->call('submit') + ->assertHasNoErrors(); + + expect(Ticket::query()->where('email', 'istniejacy@firma.pl')->exists())->toBeTrue(); +}); + +test('leaving the restriction off (the default) allows any guest e-mail to file a ticket', function () { + DirectoryEmulator::setup(); + seedStatusesAndPriorities(); + + Livewire::test(Landing::class) + ->set('email', 'ktokolwiek@example.com') + ->set('subject', 'Temat') + ->set('body', 'Treść') + ->call('submit') + ->assertHasNoErrors(); + + expect(Ticket::query()->where('email', 'ktokolwiek@example.com')->exists())->toBeTrue(); +}); + +test('admin can create a local user with a password who can then log in without an LDAP entry', function () { + DirectoryEmulator::setup(); + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('openUserForm') + ->set('userForm.name', 'Konto Lokalne') + ->set('userForm.email', 'lokalny@firma.pl') + ->set('userForm.password', 'sekretne-haslo') + ->set('userForm.password_confirmation', 'sekretne-haslo') + ->call('submitUser') + ->assertOk(); + + $user = User::query()->where('email', 'lokalny@firma.pl')->firstOrFail(); + expect($user->password)->not->toBeNull(); + + $ok = Auth::attempt([ + 'uid' => 'lokalny@firma.pl', + 'password' => 'sekretne-haslo', + 'fallback' => ['email' => 'lokalny@firma.pl'], + ]); + + expect($ok)->toBeTrue() + ->and(Auth::id())->toBe($user->id); +}); + +test('a mismatched password confirmation is rejected when creating a local user', function () { + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('openUserForm') + ->set('userForm.name', 'Konto Lokalne') + ->set('userForm.email', 'zle-haslo@firma.pl') + ->set('userForm.password', 'sekretne-haslo') + ->set('userForm.password_confirmation', 'inne-haslo') + ->call('submitUser') + ->assertHasErrors(['userForm.password']); + + expect(User::query()->where('email', 'zle-haslo@firma.pl')->exists())->toBeFalse(); +}); + +test('leaving the password blank keeps a user LDAP-only, as before', function () { + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('openUserForm') + ->set('userForm.name', 'Tylko LDAP') + ->set('userForm.email', 'tylko-ldap@firma.pl') + ->call('submitUser') + ->assertOk(); + + $user = User::query()->where('email', 'tylko-ldap@firma.pl')->firstOrFail(); + expect($user->password)->toBeNull(); +}); + +/** + * Exercises Login::safeRedirectTarget() directly via reflection rather than + * through a full ->call('submit') — Livewire's test harness disables the + * middleware stack (see RequestBroker::temporarilyDisableExceptionHandlingAndMiddleware), + * so no session is ever bound to the request during a component test, and + * submit() unconditionally touches the session to regenerate it. The + * open-redirect guard itself has no such dependency, so it's tested in isolation. + */ +function safeRedirectTargetFor(?string $redirect): ?string +{ + $login = new Login; + $ref = new ReflectionClass($login); + $prop = $ref->getProperty('redirect'); + $prop->setAccessible(true); + $prop->setValue($login, $redirect); + + $method = $ref->getMethod('safeRedirectTarget'); + $method->setAccessible(true); + + return $method->invoke($login); +} + +test('a same-app ?redirect= path is honored', function () { + expect(safeRedirectTargetFor('/client/tickets/42'))->toBe('/client/tickets/42'); +}); + +test('an external ?redirect= target is rejected to prevent an open redirect', function () { + expect(safeRedirectTargetFor('https://evil.example.com/'))->toBeNull() + ->and(safeRedirectTargetFor('//evil.example.com/'))->toBeNull() + ->and(safeRedirectTargetFor(null))->toBeNull(); +}); diff --git a/src/tests/Feature/MailSmtpConfigTest.php b/src/tests/Feature/MailSmtpConfigTest.php new file mode 100644 index 0000000..6178081 --- /dev/null +++ b/src/tests/Feature/MailSmtpConfigTest.php @@ -0,0 +1,111 @@ +test(Panel::class) + ->call('setTab', 'config') + ->set('mailConfig.fromAddress', 'wsparcie@firma.pl') + ->set('mailConfig.fromName', 'Zespół Wsparcia') + ->set('mailConfig.smtpEnabled', true) + ->set('mailConfig.smtpHost', 'smtp.firma.pl') + ->set('mailConfig.smtpPort', '587') + ->set('mailConfig.smtpUsername', 'no-reply') + ->set('mailConfig.smtpPassword', 'sekret123') + ->set('mailConfig.smtpEncryption', 'tls') + ->call('saveMailConfig') + ->assertOk(); + + expect(Settings::get('mail_from_address'))->toBe('wsparcie@firma.pl') + ->and(Settings::get('mail_from_name'))->toBe('Zespół Wsparcia') + ->and(Settings::bool('mail_smtp_enabled'))->toBeTrue() + ->and(Settings::get('mail_smtp_host'))->toBe('smtp.firma.pl') + ->and(Settings::get('mail_smtp_password'))->toBe('sekret123'); + + // Saving again with a blank password field must not wipe the stored one. + Livewire::actingAs($admin)->test(Panel::class) + ->set('mailConfig.smtpHost', 'smtp.firma.pl') + ->set('mailConfig.smtpPassword', '') + ->call('saveMailConfig') + ->assertOk(); + + expect(Settings::get('mail_smtp_password'))->toBe('sekret123'); +}); + +test('the SMTP test button reports an error without a host/from address, and success once mail is faked and both are set', function () { + Mail::fake(); + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('testMailConnection') + ->assertSet('mailTestResult', 'error'); + + // Mail::fake()'s raw() is a no-op that never throws, so a valid config + // reports success — this exercises the same config-override/restore path + // real sends use, without needing a reachable SMTP server in tests. + Livewire::actingAs($admin)->test(Panel::class) + ->set('mailConfig.fromAddress', 'wsparcie@firma.pl') + ->set('mailConfig.smtpHost', 'smtp.firma.pl') + ->call('testMailConnection') + ->assertSet('mailTestResult', 'ok'); +}); + +test('the configured footer is appended to every rendered notification', function () { + Settings::set('email_footer', 'Stopka testowa.'); + $template = EmailTemplate::query()->create([ + 'key' => 'tpl-footer-test', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'Treść wiadomości.', + ]); + $ticket = makeTicket(); + + $mail = (new TicketNotification($ticket, $template->id))->toMail((object) ['routes' => ['mail' => $ticket->email]]); + + expect($mail->viewData['html'])->toContain('Treść wiadomości.') + ->and($mail->viewData['html'])->toContain('Stopka testowa.'); +}); + +test('an empty footer setting adds nothing extra to the notification', function () { + Settings::set('email_footer', ''); + $template = EmailTemplate::query()->create([ + 'key' => 'tpl-footer-test-2', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'Treść wiadomości.', + ]); + $ticket = makeTicket(); + + $mail = (new TicketNotification($ticket, $template->id))->toMail((object) ['routes' => ['mail' => $ticket->email]]); + + expect($mail->viewData['html'])->toContain('Treść wiadomości.'); +}); + +test('AppServiceProvider overrides the mail config from settings only when SMTP is enabled', function () { + Settings::set('mail_smtp_enabled', '0'); + Settings::set('mail_smtp_host', 'smtp.disabled.example'); + (new AppServiceProvider(app()))->boot(); + expect(config('mail.default'))->not->toBe('smtp'); + + Settings::set('mail_smtp_enabled', '1'); + Settings::set('mail_smtp_host', 'smtp.enabled.example'); + Settings::set('mail_smtp_port', '2525'); + (new AppServiceProvider(app()))->boot(); + + expect(config('mail.default'))->toBe('smtp') + ->and(config('mail.mailers.smtp.host'))->toBe('smtp.enabled.example') + ->and(config('mail.mailers.smtp.port'))->toBe(2525); +}); + +test('AppServiceProvider always applies the from-address override regardless of SMTP toggle', function () { + Settings::set('mail_smtp_enabled', '0'); + Settings::set('mail_from_address', 'wsparcie@firma.pl'); + Settings::set('mail_from_name', 'Wsparcie'); + + (new AppServiceProvider(app()))->boot(); + + expect(config('mail.from.address'))->toBe('wsparcie@firma.pl') + ->and(config('mail.from.name'))->toBe('Wsparcie'); +}); diff --git a/src/tests/Feature/MessageAttachmentsTest.php b/src/tests/Feature/MessageAttachmentsTest.php new file mode 100644 index 0000000..83e52ef --- /dev/null +++ b/src/tests/Feature/MessageAttachmentsTest.php @@ -0,0 +1,145 @@ +create(['name' => 'Anna Kowalska', 'email' => 'anna@example.com', 'roles' => ['client']]); + $ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]); + $file = UploadedFile::fake()->create('zrzut-ekranu.png', 200, 'image/png'); + + Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket]) + ->set('reply', 'Oto zrzut ekranu problemu.') + ->set('attachments', [$file]) + ->call('sendReply') + ->assertSee('zrzut-ekranu.png'); + + $message = $ticket->messages()->where('body', 'Oto zrzut ekranu problemu.')->firstOrFail(); + expect($message->attachments)->toHaveCount(1); + Storage::disk('public')->assertExists($message->attachments->first()->path); +}); + +test('a client can attach multiple files to a single reply', function () { + Storage::fake('public'); + seedStatusesAndPriorities(); + + $client = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'anna-multi@example.com', 'roles' => ['client']]); + $ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]); + $files = [ + UploadedFile::fake()->create('zrzut-1.png', 100, 'image/png'), + UploadedFile::fake()->create('zrzut-2.png', 100, 'image/png'), + ]; + + Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket]) + ->set('reply', 'Dwa zrzuty ekranu.') + ->set('attachments', $files) + ->call('sendReply') + ->assertSee('zrzut-1.png') + ->assertSee('zrzut-2.png'); + + $message = $ticket->messages()->where('body', 'Dwa zrzuty ekranu.')->firstOrFail(); + expect($message->attachments)->toHaveCount(2); +}); + +test('an operator can attach a file to a public reply', function () { + Storage::fake('public'); + seedStatusesAndPriorities(); + + $operator = operatorUser('op-attach@example.com'); + $ticket = makeTicket(); + $file = UploadedFile::fake()->create('instrukcja.pdf', 100, 'application/pdf'); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->set('reply', 'W załączniku instrukcja.') + ->set('replyAttachments', [$file]) + ->call('sendReply') + ->assertSee('instrukcja.pdf'); + + $message = $ticket->messages()->where('body', 'W załączniku instrukcja.')->firstOrFail(); + expect($message->attachments)->toHaveCount(1) + ->and($message->attachments->first()->original_name)->toBe('instrukcja.pdf'); +}); + +test('an operator can attach multiple files to an internal note', function () { + Storage::fake('public'); + seedStatusesAndPriorities(); + + $operator = operatorUser('op-note-attach@example.com'); + $ticket = makeTicket(); + $files = [ + UploadedFile::fake()->create('log.txt', 10, 'text/plain'), + UploadedFile::fake()->create('log2.txt', 10, 'text/plain'), + ]; + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->call('startAddNote') + ->set('noteDraft', 'Logi z serwera w załączniku.') + ->set('noteAttachments', $files) + ->call('addNote') + ->assertSee('log.txt') + ->assertSee('log2.txt'); + + $message = $ticket->messages()->where('body', 'Logi z serwera w załączniku.')->firstOrFail(); + expect($message->internal)->toBeTrue() + ->and($message->attachments)->toHaveCount(2); +}); + +test('attachments are silently skipped when the admin has disabled them', function () { + Storage::fake('public'); + seedStatusesAndPriorities(); + Settings::set('allow_attachments', '0'); + + $operator = operatorUser('op-attach-disabled@example.com'); + $ticket = makeTicket(); + $file = UploadedFile::fake()->create('plik.txt', 10); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->set('reply', 'Odpowiedź bez załącznika.') + ->set('replyAttachments', [$file]) + ->call('sendReply'); + + $message = $ticket->messages()->where('body', 'Odpowiedź bez załącznika.')->firstOrFail(); + expect($message->attachments)->toHaveCount(0); +}); + +test('a client can remove a picked attachment before sending, via the same method the "x" button uses', function () { + Storage::fake('public'); + seedStatusesAndPriorities(); + + $client = User::query()->create(['name' => 'Ewa Zielińska', 'email' => 'ewa@example.com', 'roles' => ['client']]); + $ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]); + $file = UploadedFile::fake()->create('niechciany.txt', 5); + + Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket]) + ->set('reply', 'Jednak bez pliku.') + ->set('attachments', [$file]) + ->call('removeAttachment', 0) // mirrors the "x" button's wire:click="removeAttachment(0)" + ->assertSet('attachments', []) + ->call('sendReply'); + + $message = $ticket->messages()->where('body', 'Jednak bez pliku.')->firstOrFail(); + expect($message->attachments)->toHaveCount(0); +}); + +test('sending a reply without picking a file does not create an attachment row', function () { + Storage::fake('public'); + seedStatusesAndPriorities(); + + $client = User::query()->create(['name' => 'Piotr Nowak', 'email' => 'piotr@example.com', 'roles' => ['client']]); + $ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]); + + Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket]) + ->set('reply', 'Bez załącznika.') + ->call('sendReply'); + + $message = $ticket->messages()->where('body', 'Bez załącznika.')->firstOrFail(); + expect($message->attachments)->toHaveCount(0); +}); diff --git a/src/tests/Feature/OperatorNewTicketTest.php b/src/tests/Feature/OperatorNewTicketTest.php new file mode 100644 index 0000000..30ec48d --- /dev/null +++ b/src/tests/Feature/OperatorNewTicketTest.php @@ -0,0 +1,44 @@ +create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]); + $client = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'anna@example.com', 'roles' => ['client']]); + + $category = Category::query()->create(['name' => 'IT']); + $sub = $category->subcategories()->create(['name' => 'VPN']); + + Livewire::actingAs($operator)->test(NewTicket::class) + ->assertSee('Anna Kowalska (anna@example.com)') + ->set('customerId', $client->id) + ->call('selectCategory', $category->id) + ->assertSet('step', 2) + ->call('selectSubcategory', $sub->id) + ->assertSet('step', 3) + ->set('subject', 'Nie działa VPN') + ->set('body', 'Klient zgłasza problem z VPN.') + ->call('submit') + ->assertHasNoErrors(); + + $ticket = Ticket::query()->where('subject', 'Nie działa VPN')->firstOrFail(); + + expect($ticket->customer_id)->toBe($client->id) + ->and($ticket->email)->toBe($client->email) + ->and($ticket->assignee_id)->toBe($operator->id) + ->and($ticket->messages()->first()->body)->toContain('zgłoszenie utworzone przez operatora w imieniu klienta'); +}); + +test('submitting without selecting a client fails validation', function () { + $operator = User::query()->create(['name' => 'Op', 'email' => 'op2@example.com', 'roles' => ['operator']]); + + Livewire::actingAs($operator)->test(NewTicket::class) + ->set('subject', 'Temat') + ->set('body', 'Treść') + ->call('submit') + ->assertHasErrors(['customerId' => 'required']); +}); diff --git a/src/tests/Feature/OperatorQueueBulkActionsTest.php b/src/tests/Feature/OperatorQueueBulkActionsTest.php new file mode 100644 index 0000000..03aa618 --- /dev/null +++ b/src/tests/Feature/OperatorQueueBulkActionsTest.php @@ -0,0 +1,75 @@ + '1001']); + + Livewire::actingAs($operator)->test(Queue::class) + ->call('toggleSelect', $ticket->id) + ->call('requestDeleteSelected') + ->assertSet('pendingDeleteSelected', true) + ->call('confirmDeleteSelected') + ->assertOk(); + + expect(Ticket::query()->find($ticket->id))->toBeNull(); +}); + +test('operator can delete several selected tickets at once, not just merge them', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('bulk-2@example.com'); + $a = makeTicket(['number' => '1001']); + $b = makeTicket(['number' => '1002']); + $c = makeTicket(['number' => '1003']); + + Livewire::actingAs($operator)->test(Queue::class) + ->call('toggleSelect', $a->id) + ->call('toggleSelect', $b->id) + ->call('requestDeleteSelected') + ->call('confirmDeleteSelected') + ->assertOk(); + + expect(Ticket::query()->find($a->id))->toBeNull() + ->and(Ticket::query()->find($b->id))->toBeNull() + ->and(Ticket::query()->find($c->id))->not->toBeNull(); // untouched, wasn't selected +}); + +test('deleting selected tickets requires confirmation first', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('bulk-3@example.com'); + $ticket = makeTicket(['number' => '1001']); + + Livewire::actingAs($operator)->test(Queue::class) + ->call('toggleSelect', $ticket->id) + ->call('requestDeleteSelected') + ->assertSet('pendingDeleteSelected', true); + + expect(Ticket::query()->find($ticket->id))->not->toBeNull(); +}); + +test('cancelling the delete confirmation leaves the tickets untouched', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('bulk-4@example.com'); + $ticket = makeTicket(['number' => '1001']); + + Livewire::actingAs($operator)->test(Queue::class) + ->call('toggleSelect', $ticket->id) + ->call('requestDeleteSelected') + ->call('cancelDeleteSelected') + ->assertSet('pendingDeleteSelected', false); + + expect(Ticket::query()->find($ticket->id))->not->toBeNull(); +}); + +test('requesting delete with nothing selected is a no-op', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('bulk-5@example.com'); + + Livewire::actingAs($operator)->test(Queue::class) + ->call('requestDeleteSelected') + ->assertSet('pendingDeleteSelected', false); +}); diff --git a/src/tests/Feature/OperatorQueueClosedTabTest.php b/src/tests/Feature/OperatorQueueClosedTabTest.php new file mode 100644 index 0000000..ca87ff2 --- /dev/null +++ b/src/tests/Feature/OperatorQueueClosedTabTest.php @@ -0,0 +1,64 @@ + '1001', 'status_key' => 'new']); + $open = makeTicket(['number' => '1002', 'status_key' => 'open']); + $closed = makeTicket(['number' => '1003', 'status_key' => 'closed']); + + Livewire::actingAs($operator)->test(Queue::class) + ->call('setQueue', 'closed') + ->assertSee($closed->number) + ->assertDontSee($new->number) + ->assertDontSee($open->number); +}); + +test('the "Otwarte" tab never shows closed tickets', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('all-hides-closed@example.com'); + + $open = makeTicket(['number' => '2001', 'status_key' => 'new']); + $closed = makeTicket(['number' => '2002', 'status_key' => 'closed']); + + Livewire::actingAs($operator)->test(Queue::class) + ->assertSet('queue', 'all') + ->assertSee($open->number) + ->assertDontSee($closed->number); +}); + +test('the "Otwarte" tab does not offer "Zamknięte" as a status filter option', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('all-no-closed-filter@example.com'); + + $keys = Livewire::actingAs($operator)->test(Queue::class) + ->instance()->filterableStatuses->pluck('key')->all(); + + expect($keys)->not->toContain('closed'); +}); + +test('other tabs still offer "Zamknięte" as a status filter option', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('mine-has-closed-filter@example.com'); + + $keys = Livewire::actingAs($operator)->test(Queue::class) + ->call('setQueue', 'mine') + ->instance()->filterableStatuses->pluck('key')->all(); + + expect($keys)->toContain('closed'); +}); + +test('switching to "Otwarte" resets an active "Zamknięte" status filter, since it would always be empty there', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('reset-filter@example.com'); + + Livewire::actingAs($operator)->test(Queue::class) + ->call('setQueue', 'closed') + ->set('filterStatus', 'closed') + ->call('setQueue', 'all') + ->assertSet('filterStatus', 'all'); +}); diff --git a/src/tests/Feature/OperatorQueueSearchSortColumnsTest.php b/src/tests/Feature/OperatorQueueSearchSortColumnsTest.php new file mode 100644 index 0000000..5167794 --- /dev/null +++ b/src/tests/Feature/OperatorQueueSearchSortColumnsTest.php @@ -0,0 +1,72 @@ + '1001', 'subject' => 'VPN nie działa', 'name' => 'Jan Kowalski', 'email' => 'jan@example.com']); + makeTicket(['number' => '1002', 'subject' => 'Reset hasła', 'name' => 'Anna Nowak', 'email' => 'anna@example.com']); + + $component = Livewire::actingAs($operator)->test(Queue::class)->set('search', 'VPN'); + expect($component->instance()->filteredTickets)->toHaveCount(1); + expect($component->instance()->filteredTickets->first()->number)->toBe('1001'); + + $component->set('search', 'Nowak'); + expect($component->instance()->filteredTickets)->toHaveCount(1); + expect($component->instance()->filteredTickets->first()->number)->toBe('1002'); + + $component->set('search', ''); + expect($component->instance()->filteredTickets)->toHaveCount(2); +}); + +test('clicking a sortable column header sorts ascending, then descending on a second click', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('sort-1@example.com'); + makeTicket(['number' => '1001', 'subject' => 'Zebra']); + makeTicket(['number' => '1002', 'subject' => 'Alfa']); + makeTicket(['number' => '1003', 'subject' => 'Mango']); + + $component = Livewire::actingAs($operator)->test(Queue::class) + ->call('sortByColumn', 'subject'); + + expect($component->get('sortDir'))->toBe('asc'); + expect($component->instance()->filteredTickets->pluck('subject')->all())->toBe(['Alfa', 'Mango', 'Zebra']); + + $component->call('sortByColumn', 'subject'); + + expect($component->get('sortDir'))->toBe('desc'); + expect($component->instance()->filteredTickets->pluck('subject')->all())->toBe(['Zebra', 'Mango', 'Alfa']); +}); + +test('sla is not a clickable sortable column', function () { + $operator = operatorUser('sort-2@example.com'); + + Livewire::actingAs($operator)->test(Queue::class) + ->call('sortByColumn', 'sla') + ->assertSet('sortBy', 'updated_at'); +}); + +test('columns can be hidden and shown again, but at least one must stay visible', function () { + $operator = operatorUser('columns-1@example.com'); + + $component = Livewire::actingAs($operator)->test(Queue::class) + ->call('toggleColumn', 'sla') + ->assertSet('visibleColumns', fn ($cols) => ! in_array('sla', $cols, true)); + + $component->call('toggleColumn', 'sla') + ->assertSet('visibleColumns', fn ($cols) => in_array('sla', $cols, true)); + + // Hide every column except one, then try to hide the last one too. + foreach (array_keys((new \App\Livewire\Operator\Queue)->columnDefs()) as $key) { + if ($key !== 'number') { + $component->call('toggleColumn', $key); + } + } + + expect($component->get('visibleColumns'))->toBe(['number']); + + $component->call('toggleColumn', 'number'); + expect($component->get('visibleColumns'))->toBe(['number']); +}); diff --git a/src/tests/Feature/OperatorTeamScopingTest.php b/src/tests/Feature/OperatorTeamScopingTest.php new file mode 100644 index 0000000..3785f57 --- /dev/null +++ b/src/tests/Feature/OperatorTeamScopingTest.php @@ -0,0 +1,137 @@ +create(['name' => 'Infrastruktura']); + $otherTeam = Team::query()->create(['name' => 'Aplikacje']); + $operator->teams()->attach($myTeam->id); + + $teamKeys = Livewire::actingAs($operator)->test(Queue::class) + ->instance()->teams->pluck('name')->all(); + + expect($teamKeys)->toBe(['Infrastruktura']); +}); + +test('an admin (even if also an operator) sees every team in the queue sidebar', function () { + seedStatusesAndPriorities(); + $admin = User::query()->create(['name' => 'Admin Op', 'email' => 'admin-op@example.com', 'roles' => ['operator', 'admin']]); + Team::query()->create(['name' => 'Infrastruktura']); + Team::query()->create(['name' => 'Aplikacje']); + + $teamNames = Livewire::actingAs($admin)->test(Queue::class) + ->instance()->teams->pluck('name')->sort()->values()->all(); + + expect($teamNames)->toBe(['Aplikacje', 'Infrastruktura']); +}); + +test('a non-admin operator only sees tickets from their own team, unrouted tickets, or ones assigned to them', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('scoped-2@example.com'); + $myTeam = Team::query()->create(['name' => 'Infrastruktura']); + $otherTeam = Team::query()->create(['name' => 'Aplikacje']); + $operator->teams()->attach($myTeam->id); + + $mine = makeTicket(['number' => '1001', 'team_id' => $myTeam->id]); + $others = makeTicket(['number' => '1002', 'team_id' => $otherTeam->id]); + $unrouted = makeTicket(['number' => '1003', 'team_id' => null]); + $assignedToMeElsewhere = makeTicket(['number' => '1004', 'team_id' => $otherTeam->id, 'assignee_id' => $operator->id]); + + Livewire::actingAs($operator)->test(Queue::class) + ->assertSee($mine->number) + ->assertSee($unrouted->number) + ->assertSee($assignedToMeElsewhere->number) + ->assertDontSee($others->number); +}); + +test('an admin operator still sees tickets from every team', function () { + seedStatusesAndPriorities(); + $admin = User::query()->create(['name' => 'Admin Op', 'email' => 'admin-op-2@example.com', 'roles' => ['operator', 'admin']]); + $teamA = Team::query()->create(['name' => 'Infrastruktura']); + $teamB = Team::query()->create(['name' => 'Aplikacje']); + + $ticketA = makeTicket(['number' => '2001', 'team_id' => $teamA->id]); + $ticketB = makeTicket(['number' => '2002', 'team_id' => $teamB->id]); + + Livewire::actingAs($admin)->test(Queue::class) + ->assertSee($ticketA->number) + ->assertSee($ticketB->number); +}); + +test('a non-admin operator gets a 403 opening a ticket outside their scope directly', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('scoped-3@example.com'); + $myTeam = Team::query()->create(['name' => 'Infrastruktura']); + $otherTeam = Team::query()->create(['name' => 'Aplikacje']); + $operator->teams()->attach($myTeam->id); + + $outOfScope = makeTicket(['number' => '3001', 'team_id' => $otherTeam->id]); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $outOfScope]) + ->assertForbidden(); +}); + +test('a non-admin operator can still open a ticket outside their team if it is personally assigned to them', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('scoped-4@example.com'); + $myTeam = Team::query()->create(['name' => 'Infrastruktura']); + $otherTeam = Team::query()->create(['name' => 'Aplikacje']); + $operator->teams()->attach($myTeam->id); + + $assignedElsewhere = makeTicket(['number' => '4001', 'team_id' => $otherTeam->id, 'assignee_id' => $operator->id]); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $assignedElsewhere]) + ->assertOk(); +}); + +test('the team reassignment dropdown on a ticket only offers a non-admin operator their own teams', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('scoped-5@example.com'); + $myTeam = Team::query()->create(['name' => 'Infrastruktura']); + Team::query()->create(['name' => 'Aplikacje']); + $operator->teams()->attach($myTeam->id); + + $ticket = makeTicket(['number' => '5001', 'team_id' => $myTeam->id]); + + $teamNames = Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->instance()->teams->pluck('name')->all(); + + expect($teamNames)->toBe(['Infrastruktura']); +}); + +test('merging cannot pull in a ticket outside the operators scope via a crafted selection', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('scoped-6@example.com'); + $myTeam = Team::query()->create(['name' => 'Infrastruktura']); + $otherTeam = Team::query()->create(['name' => 'Aplikacje']); + $operator->teams()->attach($myTeam->id); + + $mineA = makeTicket(['number' => '6001', 'team_id' => $myTeam->id]); + $mineB = makeTicket(['number' => '6002', 'team_id' => $myTeam->id]); + $outOfScope = makeTicket(['number' => '6003', 'team_id' => $otherTeam->id]); + + Livewire::actingAs($operator)->test(Queue::class) + ->call('toggleSelect', $mineA->id) + ->call('toggleSelect', $outOfScope->id) + ->call('mergeSelected'); // only 1 ticket is actually in scope, so this must be a no-op + + expect(Ticket::query()->find($outOfScope->id)->status_key)->not->toBe('closed'); + + Livewire::actingAs($operator)->test(Queue::class) + ->call('toggleSelect', $mineA->id) + ->call('toggleSelect', $mineB->id) + ->call('toggleSelect', $outOfScope->id) + ->call('requestDeleteSelected') + ->call('confirmDeleteSelected'); + + expect(Ticket::query()->find($mineA->id))->toBeNull() + ->and(Ticket::query()->find($mineB->id))->toBeNull() + ->and(Ticket::query()->find($outOfScope->id))->not->toBeNull(); +}); diff --git a/src/tests/Feature/OperatorTicketDetailsEditTest.php b/src/tests/Feature/OperatorTicketDetailsEditTest.php new file mode 100644 index 0000000..e62a6d7 --- /dev/null +++ b/src/tests/Feature/OperatorTicketDetailsEditTest.php @@ -0,0 +1,47 @@ +create(['name' => 'IT-Pomoc']); + $subcategory = $category->subcategories()->create(['name' => 'VPN']); + $field = CustomField::query()->create(['label' => 'Teamviewer-ID', 'type' => 'text', 'required' => false, 'sort_order' => 1]); + $field->subcategories()->attach($subcategory->id, ['position' => 1]); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->call('toggleEditDetails') + ->assertDontSee('Teamviewer-ID') + ->set('editDetailsForm.categoryId', $category->id) + ->set('editDetailsForm.subcategoryId', $subcategory->id) + ->assertSee('Teamviewer-ID'); +}); + +test('changing the category while editing details resets the previously selected subcategory', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('details-edit-2@example.com'); + + $categoryA = Category::query()->create(['name' => 'IT-Pomoc']); + $subA = $categoryA->subcategories()->create(['name' => 'VPN']); + $fieldA = CustomField::query()->create(['label' => 'Teamviewer-ID', 'type' => 'text', 'required' => false, 'sort_order' => 1]); + $fieldA->subcategories()->attach($subA->id, ['position' => 1]); + + $categoryB = Category::query()->create(['name' => 'Zamówienia']); + $categoryB->subcategories()->create(['name' => 'Zamówienie sprzętu IT']); + + $ticket = makeTicket(['subcategory_id' => $subA->id]); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->call('toggleEditDetails') + ->assertSet('editDetailsForm.subcategoryId', $subA->id) + ->assertSee('Teamviewer-ID') + ->set('editDetailsForm.categoryId', $categoryB->id) + ->assertSet('editDetailsForm.subcategoryId', '') + ->assertDontSee('Teamviewer-ID'); +}); diff --git a/src/tests/Feature/PriorityStatusReplyQuickActionKeyOrderTest.php b/src/tests/Feature/PriorityStatusReplyQuickActionKeyOrderTest.php new file mode 100644 index 0000000..37d62cb --- /dev/null +++ b/src/tests/Feature/PriorityStatusReplyQuickActionKeyOrderTest.php @@ -0,0 +1,103 @@ +test(Panel::class) + ->set('newPriorityLabel', 'Krytyczny') + ->set('newPriorityKey', 'critical') + ->set('newPrioritySortOrder', '0') + ->call('addPriority') + ->assertOk(); + + $priority = Priority::query()->findOrFail('critical'); + expect($priority->label)->toBe('Krytyczny') + ->and($priority->sort_order)->toBe(0) + ->and(SlaRule::query()->where('priority_key', 'critical')->exists())->toBeTrue(); +}); + +test('creating a priority with a duplicate key fails validation instead of a DB error', function () { + seedStatusesAndPriorities(); + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->set('newPriorityLabel', 'Duplikat') + ->set('newPriorityKey', 'high') + ->call('addPriority') + ->assertHasErrors(['newPriorityKey']); + + expect(Priority::query()->where('label', 'Duplikat')->exists())->toBeFalse(); +}); + +test('leaving the priority key blank still auto-generates one, as before', function () { + seedStatusesAndPriorities(); + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->set('newPriorityLabel', 'Auto Key Priority') + ->call('addPriority') + ->assertOk(); + + expect(Priority::query()->where('label', 'Auto Key Priority')->exists())->toBeTrue(); +}); + +test('admin can set a custom key and sort_order when creating a status', function () { + seedStatusesAndPriorities(); + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->set('newStatusLabel', 'Czeka na klienta') + ->set('newStatusKey', 'waiting_on_customer') + ->set('newStatusSortOrder', '5') + ->call('addStatus') + ->assertOk(); + + $status = Status::query()->findOrFail('waiting_on_customer'); + expect($status->label)->toBe('Czeka na klienta') + ->and($status->sort_order)->toBe(5); +}); + +test('creating a status with a duplicate key fails validation instead of a DB error', function () { + seedStatusesAndPriorities(); + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->set('newStatusLabel', 'Duplikat') + ->set('newStatusKey', 'open') + ->call('addStatus') + ->assertHasErrors(['newStatusKey']); + + expect(Status::query()->where('label', 'Duplikat')->exists())->toBeFalse(); +}); + +test('admin can set sort_order when creating and editing a reply quick action', function () { + seedStatusesAndPriorities(); + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('openReplyQuickActionForm') + ->set('replyQuickActionForm.label', 'Wyślij i eskaluj') + ->set('replyQuickActionForm.sortOrder', '1') + ->call('submitReplyQuickAction') + ->assertOk(); + + $action = ReplyQuickAction::query()->where('label', 'Wyślij i eskaluj')->firstOrFail(); + expect($action->sort_order)->toBe(1); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('editReplyQuickAction', $action->id) + ->assertSet('replyQuickActionForm.sortOrder', '1') + ->set('replyQuickActionForm.sortOrder', '9') + ->call('submitReplyQuickAction') + ->assertOk(); + + expect($action->fresh()->sort_order)->toBe(9); +}); diff --git a/src/tests/Feature/ReplyQuickActionsTest.php b/src/tests/Feature/ReplyQuickActionsTest.php new file mode 100644 index 0000000..329338c --- /dev/null +++ b/src/tests/Feature/ReplyQuickActionsTest.php @@ -0,0 +1,111 @@ +orderBy('sort_order')->pluck('status_key', 'label')->all())->toBe([ + 'Wyślij i „Oczekuje na klienta”' => 'waiting_customer', + 'Wyślij i oznacz jako rozwiązane' => 'closed', + 'Wyślij i zamknij' => 'closed', + ]); +}); + +test('admin can create a reply quick action that changes the ticket status', function () { + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'reply-quick-actions') + ->call('openReplyQuickActionForm') + ->set('replyQuickActionForm.label', 'Wyślij i eskaluj') + ->set('replyQuickActionForm.statusKey', 'in_progress') + ->call('submitReplyQuickAction') + ->assertOk(); + + $action = ReplyQuickAction::query()->where('label', 'Wyślij i eskaluj')->firstOrFail(); + expect($action->status_key)->toBe('in_progress'); +}); + +test('admin can create a reply quick action that does not change the status ("nie zmieniaj")', function () { + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'reply-quick-actions') + ->call('openReplyQuickActionForm') + ->set('replyQuickActionForm.label', 'Wyślij bez zmian') + ->set('replyQuickActionForm.statusKey', '') + ->call('submitReplyQuickAction') + ->assertOk(); + + $action = ReplyQuickAction::query()->where('label', 'Wyślij bez zmian')->firstOrFail(); + expect($action->status_key)->toBeNull() + ->and($action->statusLabel())->toBe('Nie zmieniaj statusu'); +}); + +test('admin can edit and delete a reply quick action', function () { + $admin = adminUser(); + $action = ReplyQuickAction::query()->create(['label' => 'Tymczasowa', 'status_key' => 'closed', 'sort_order' => 99]); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('editReplyQuickAction', $action->id) + ->assertSet('replyQuickActionForm.label', 'Tymczasowa') + ->set('replyQuickActionForm.label', 'Zmieniona nazwa') + ->set('replyQuickActionForm.statusKey', '') + ->call('submitReplyQuickAction') + ->assertOk(); + + expect($action->fresh()->label)->toBe('Zmieniona nazwa') + ->and($action->fresh()->status_key)->toBeNull(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('removeReplyQuickAction', $action->id) + ->assertSet('pendingDeleteType', 'reply-quick-action') + ->call('confirmPendingDelete') + ->assertOk(); + + expect(ReplyQuickAction::query()->find($action->id))->toBeNull(); +}); + +test('the operator ticket view lists the configured reply quick actions in the send menu', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('quickaction-view@example.com'); + $ticket = makeTicket(['number' => '1001']); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->assertSee('Wyślij i „Oczekuje na klienta”') + ->assertSee('Wyślij i oznacz jako rozwiązane') + ->assertSee('Wyślij i zamknij'); +}); + +test('sending via a status-changing quick action updates the ticket status', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('quickaction-send@example.com'); + $ticket = makeTicket(['number' => '1001', 'status_key' => 'new']); + $action = ReplyQuickAction::query()->where('label', 'Wyślij i oznacz jako rozwiązane')->firstOrFail(); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->set('reply', 'Naprawione, proszę potwierdzić.') + ->call('sendAndTransition', $action->id); + + expect($ticket->fresh()->status_key)->toBe('closed') + ->and($ticket->fresh()->messages()->where('body', 'Naprawione, proszę potwierdzić.')->exists())->toBeTrue(); +}); + +test('sending via a "nie zmieniaj" quick action posts the reply without changing the status', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('quickaction-nochange@example.com'); + $ticket = makeTicket(['number' => '1001', 'status_key' => 'new']); + $action = ReplyQuickAction::query()->create(['label' => 'Wyślij bez zmian', 'status_key' => null, 'sort_order' => 50]); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->set('reply', 'Sprawdzam temat.') + ->call('sendAndTransition', $action->id); + + expect($ticket->fresh()->status_key)->toBe('new') + ->and($ticket->fresh()->messages()->where('body', 'Sprawdzam temat.')->exists())->toBeTrue(); +}); diff --git a/src/tests/Feature/ResponseTemplatesAdminTest.php b/src/tests/Feature/ResponseTemplatesAdminTest.php new file mode 100644 index 0000000..ce6ed66 --- /dev/null +++ b/src/tests/Feature/ResponseTemplatesAdminTest.php @@ -0,0 +1,66 @@ +test(Panel::class) + ->call('setTab', 'response-templates') + ->call('openResponseTemplateForm') + ->set('responseTemplateForm.label', 'Prośba o zrzut ekranu') + ->set('responseTemplateForm.body', 'Czy mógłby Pan/Pani załączyć zrzut ekranu problemu?') + ->call('submitResponseTemplate') + ->assertOk(); + + $template = ResponseTemplate::query()->where('label', 'Prośba o zrzut ekranu')->firstOrFail(); + expect($template->body)->toBe('Czy mógłby Pan/Pani załączyć zrzut ekranu problemu?'); +}); + +test('creating a response template without a label fails validation', function () { + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('openResponseTemplateForm') + ->set('responseTemplateForm.label', '') + ->set('responseTemplateForm.body', 'Treść.') + ->call('submitResponseTemplate') + ->assertHasErrors(['responseTemplateForm.label' => 'required']); + + expect(ResponseTemplate::query()->count())->toBe(0); +}); + +test('admin can edit and delete a response template', function () { + $admin = adminUser(); + $template = ResponseTemplate::query()->create(['label' => 'Tymczasowy', 'body' => 'Stara treść.']); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('editResponseTemplate', $template->id) + ->assertSet('responseTemplateForm.label', 'Tymczasowy') + ->set('responseTemplateForm.label', 'Zmieniona nazwa') + ->set('responseTemplateForm.body', 'Nowa treść.') + ->call('submitResponseTemplate') + ->assertOk(); + + expect($template->fresh()->label)->toBe('Zmieniona nazwa') + ->and($template->fresh()->body)->toBe('Nowa treść.'); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('removeResponseTemplate', $template->id) + ->assertSet('pendingDeleteType', 'response-template') + ->call('confirmPendingDelete') + ->assertOk(); + + expect(ResponseTemplate::query()->find($template->id))->toBeNull(); +}); + +test('the response templates tab lists existing templates', function () { + $admin = adminUser(); + ResponseTemplate::query()->create(['label' => 'Restart usuwa problem', 'body' => 'Prosimy zrestartować urządzenie.']); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'response-templates') + ->assertSee('Restart usuwa problem'); +}); diff --git a/src/tests/Feature/RoleMiddlewareTest.php b/src/tests/Feature/RoleMiddlewareTest.php new file mode 100644 index 0000000..16b6ebc --- /dev/null +++ b/src/tests/Feature/RoleMiddlewareTest.php @@ -0,0 +1,46 @@ +create([ + 'name' => ucfirst($role).' User', + 'email' => $role.'@example.com', + 'roles' => [$role], + ]); +} + +test('guests are redirected to login for every protected area', function (string $uri) { + $this->get($uri)->assertRedirect('/login'); +})->with(['/client', '/operator', '/admin']); + +test('each role can only access its own area', function () { + $client = userWithRole('client'); + $operator = userWithRole('operator'); + $admin = userWithRole('admin'); + + $this->actingAs($client)->get('/client')->assertOk(); + $this->actingAs($client)->get('/operator')->assertForbidden(); + $this->actingAs($client)->get('/admin')->assertForbidden(); + + $this->actingAs($operator)->get('/operator')->assertOk(); + $this->actingAs($operator)->get('/client')->assertForbidden(); + $this->actingAs($operator)->get('/admin')->assertForbidden(); + + $this->actingAs($admin)->get('/admin')->assertOk(); + $this->actingAs($admin)->get('/client')->assertForbidden(); + $this->actingAs($admin)->get('/operator')->assertForbidden(); +}); + +test('a user with multiple roles can access every matching area', function () { + $user = User::query()->create([ + 'name' => 'Multi Role', + 'email' => 'multi@example.com', + 'roles' => ['operator', 'admin'], + ]); + + $this->actingAs($user)->get('/operator')->assertOk(); + $this->actingAs($user)->get('/admin')->assertOk(); + $this->actingAs($user)->get('/client')->assertForbidden(); +}); diff --git a/src/tests/Feature/RolesFieldValuesAndMessageAuthorTest.php b/src/tests/Feature/RolesFieldValuesAndMessageAuthorTest.php new file mode 100644 index 0000000..240ef7a --- /dev/null +++ b/src/tests/Feature/RolesFieldValuesAndMessageAuthorTest.php @@ -0,0 +1,147 @@ +pluck('key')->all())->toEqualCanonicalizing(['client', 'operator', 'admin']); +}); + +test('a users roles are stored in the role_user pivot, not a JSON column', function () { + $user = User::query()->create(['name' => 'Multi Role', 'email' => 'multirole@example.com', 'roles' => ['operator', 'admin']]); + + expect(Schema::hasColumn('users', 'roles'))->toBeFalse() + ->and(DB::table('role_user')->where('user_id', $user->id)->count())->toBe(2) + ->and($user->fresh()->roles)->toEqualCanonicalizing(['operator', 'admin']) + ->and($user->isAdmin())->toBeTrue() + ->and($user->isOperator())->toBeTrue() + ->and($user->isClient())->toBeFalse(); +}); + +test('admin toggling a users role in the panel updates role_user, not a JSON column', function () { + $admin = adminUser(); + $target = User::query()->create(['name' => 'Toggle Me', 'email' => 'toggle@example.com', 'roles' => ['client']]); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('toggleUserRole', $target->id, 'operator') + ->assertOk(); + + expect($target->fresh()->roles)->toEqualCanonicalizing(['client', 'operator']); +}); + +test('a users custom field values are stored in user_field_values, not a JSON column', function () { + $field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'sort_order' => 1]); + $user = User::query()->create([ + 'name' => 'Field Test', 'email' => 'fieldtest@example.com', 'roles' => ['client'], + 'custom_field_values' => [$field->id => 'IT'], + ]); + + expect(Schema::hasColumn('users', 'custom_field_values'))->toBeFalse() + ->and(UserFieldValue::query()->where('user_id', $user->id)->where('user_field_id', $field->id)->value('value'))->toBe('IT') + ->and($user->fresh()->custom_field_values)->toBe([$field->id => 'IT']); +}); + +test('replacing a users field values drops the ones no longer present, matching the old JSON full-replace semantics', function () { + $field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'sort_order' => 1]); + $user = User::query()->create([ + 'name' => 'Field Test 2', 'email' => 'fieldtest2@example.com', 'roles' => ['client'], + 'custom_field_values' => [$field->id => 'IT'], + ]); + + $user->update(['custom_field_values' => []]); + + expect(UserFieldValue::query()->where('user_id', $user->id)->exists())->toBeFalse(); +}); + +test('a client reply links the message to its author and the client role via ticket_message_authors', function () { + seedStatusesAndPriorities(); + $client = User::query()->create(['name' => 'Klient', 'email' => 'klient-msg@example.com', 'roles' => ['client']]); + $ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]); + + app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź klienta'); + + $message = $ticket->messages()->latest('id')->first(); + + expect(Schema::hasColumn('ticket_messages', 'author_id'))->toBeFalse() + ->and(Schema::hasColumn('ticket_messages', 'role'))->toBeFalse() + ->and($message->role)->toBe('client') + ->and($message->author_id)->toBe($client->id) + ->and($message->author->id)->toBe($client->id); +}); + +test('an operator reply and note link to the operator role', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('op-msg@example.com'); + $ticket = makeTicket(); + + app(TicketService::class)->operatorReply($ticket, $operator, 'Odpowiedź operatora'); + app(TicketService::class)->operatorNote($ticket, $operator, 'Notatka wewnętrzna'); + + $reply = $ticket->messages()->where('internal', false)->latest('id')->first(); + $note = $ticket->messages()->where('internal', true)->latest('id')->first(); + + expect($reply->role)->toBe('operator')->and($reply->author_id)->toBe($operator->id) + ->and($note->role)->toBe('operator')->and($note->author_id)->toBe($operator->id); +}); + +test('a system-generated message (merge announcement) has no author and role defaults to "system"', function () { + seedStatusesAndPriorities(); + $primary = makeTicket(['number' => '2001']); + $other = makeTicket(['number' => '2002']); + + app(TicketService::class)->merge([$primary->id, $other->id]); + + $announcement = $primary->messages()->where('body', 'like', '%Scalono zgłoszenia%')->firstOrFail(); + + expect($announcement->role)->toBe('system') + ->and($announcement->author_id)->toBeNull() + ->and(TicketMessageAuthor::query()->where('ticket_message_id', $announcement->id)->exists())->toBeFalse(); +}); + +test('a client can edit their own message but not an operators message on the same ticket', function () { + seedStatusesAndPriorities(); + $client = User::query()->create(['name' => 'Owner', 'email' => 'owner@example.com', 'roles' => ['client']]); + $operator = operatorUser('op-on-client-ticket@example.com'); + $ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]); + + app(TicketService::class)->clientReply($ticket, $client, 'Treść klienta'); + $ownMessage = $ticket->messages()->latest('id')->first(); + + app(TicketService::class)->operatorReply($ticket, $operator, 'Odpowiedź operatora'); + $operatorMessage = $ticket->messages()->latest('id')->first(); + + Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket]) + ->call('startEdit', $ownMessage->id, 'zmieniona treść') + ->assertSet('editingMessageId', $ownMessage->id); + + // abort_unless() inside an action call doesn't translate into a clean + // HTTP response under Livewire's test harness (it disables the app's + // exception handling for ->call()), so assert on the instance directly. + $component = Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])->instance(); + + expect(fn () => $component->startEdit($operatorMessage->id, 'zmieniona treść')) + ->toThrow(Symfony\Component\HttpKernel\Exception\HttpException::class); +}); + +test('an operator can edit an operator-authored message', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('op-edit@example.com'); + $ticket = makeTicket(); + + app(TicketService::class)->operatorReply($ticket, $operator, 'Treść operatora'); + $message = $ticket->messages()->latest('id')->first(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->call('startEditMessage', $message->id, 'zmieniona treść') + ->assertSet('editingMessageId', $message->id); +}); diff --git a/src/tests/Feature/SessionLifetimeConfigTest.php b/src/tests/Feature/SessionLifetimeConfigTest.php new file mode 100644 index 0000000..e3f9d1c --- /dev/null +++ b/src/tests/Feature/SessionLifetimeConfigTest.php @@ -0,0 +1,26 @@ +test(Panel::class) + ->call('setTab', 'config') + ->set('systemConfig.sessionLifetimeMinutes', '45') + ->call('saveSystemConfig') + ->assertOk(); + + expect(Settings::get('session_lifetime_minutes'))->toBe('45'); +}); + +test('AppServiceProvider overrides session.lifetime from the saved setting', function () { + Settings::set('session_lifetime_minutes', '30'); + + (new AppServiceProvider(app()))->boot(); + + expect(config('session.lifetime'))->toBe(30); +}); diff --git a/src/tests/Feature/SlaBreachNotificationTest.php b/src/tests/Feature/SlaBreachNotificationTest.php new file mode 100644 index 0000000..70fd35a --- /dev/null +++ b/src/tests/Feature/SlaBreachNotificationTest.php @@ -0,0 +1,95 @@ +create(['name' => 'Op', 'email' => 'sla-op-1@example.com', 'roles' => ['operator']]); + $ticket = makeTicket(['assignee_id' => $operator->id, 'status_key' => 'new', 'created_at' => now()->subDays(2)]); + + Artisan::call('tickets:check-sla-breaches'); + + Notification::assertNothingSent(); + expect($ticket->fresh()->sla_notified_at)->toBeNull(); +}); + +test('an overdue ticket with an assigned operator gets notified once the trigger is enabled, and only once', function () { + Notification::fake(); + seedStatusesAndPriorities(); + NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]); + + $operator = User::query()->create(['name' => 'Ola Operator', 'email' => 'sla-op-2@example.com', 'roles' => ['operator']]); + $ticket = makeTicket(['assignee_id' => $operator->id, 'status_key' => 'new', 'created_at' => now()->subDays(2)]); + + Artisan::call('tickets:check-sla-breaches'); + + Notification::assertSentOnDemand( + TicketNotification::class, + fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'sla-op-2@example.com' + ); + expect($ticket->fresh()->sla_notified_at)->not->toBeNull(); + + Artisan::call('tickets:check-sla-breaches'); + + Notification::assertSentOnDemandTimes(TicketNotification::class, 1); +}); + +test('an overdue but unassigned ticket is never notified (nobody to send it to)', function () { + Notification::fake(); + seedStatusesAndPriorities(); + NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]); + + makeTicket(['assignee_id' => null, 'status_key' => 'new', 'created_at' => now()->subDays(2)]); + + Artisan::call('tickets:check-sla-breaches'); + + Notification::assertNothingSent(); +}); + +test('a closed overdue ticket is never notified', function () { + Notification::fake(); + seedStatusesAndPriorities(); + NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]); + + $operator = User::query()->create(['name' => 'Op', 'email' => 'sla-op-3@example.com', 'roles' => ['operator']]); + makeTicket(['assignee_id' => $operator->id, 'status_key' => 'closed', 'created_at' => now()->subDays(2)]); + + Artisan::call('tickets:check-sla-breaches'); + + Notification::assertNothingSent(); +}); + +test('a ticket whose priority has resolution_mins set to 0 (no SLA) is never notified, however old', function () { + Notification::fake(); + seedStatusesAndPriorities(); + NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]); + SlaRule::query()->where('priority_key', 'high')->update(['resolution_mins' => 0]); + + $operator = User::query()->create(['name' => 'Op', 'email' => 'sla-op-5@example.com', 'roles' => ['operator']]); + $ticket = makeTicket(['assignee_id' => $operator->id, 'status_key' => 'new', 'created_at' => now()->subYears(2)]); + + Artisan::call('tickets:check-sla-breaches'); + + Notification::assertNothingSent(); + expect($ticket->fresh()->sla_notified_at)->toBeNull(); +}); + +test('a ticket still within its SLA window is not notified', function () { + Notification::fake(); + seedStatusesAndPriorities(); + NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]); + + $operator = User::query()->create(['name' => 'Op', 'email' => 'sla-op-4@example.com', 'roles' => ['operator']]); + $ticket = makeTicket(['assignee_id' => $operator->id, 'status_key' => 'new', 'created_at' => now()->subHour()]); + + Artisan::call('tickets:check-sla-breaches'); + + Notification::assertNothingSent(); + expect($ticket->fresh()->sla_notified_at)->toBeNull(); +}); diff --git a/src/tests/Feature/SmokeTest.php b/src/tests/Feature/SmokeTest.php new file mode 100644 index 0000000..2055dc6 --- /dev/null +++ b/src/tests/Feature/SmokeTest.php @@ -0,0 +1,34 @@ +seed(); + + $client = User::query()->withRole('client')->firstOrFail(); + $operator = User::query()->withRole('operator')->firstOrFail(); + $admin = User::query()->withRole('admin')->firstOrFail(); + $clientTicket = Ticket::query()->where('customer_id', $client->id)->firstOrFail(); + $anyTicket = Ticket::query()->firstOrFail(); + + $this->get('/')->assertOk()->assertSee('Jak możemy pomóc'); + $this->get('/login')->assertOk()->assertSee('Nowe zgłoszenie bez logowania'); + + $this->actingAs($client)->get('/client')->assertOk()->assertSee('Moje zgłoszenia')->assertSee('Panel Klienta'); + $this->actingAs($client)->get('/client/new')->assertOk()->assertSee('Panel Klienta'); + $this->actingAs($client)->get(route('client.ticket', $clientTicket))->assertOk()->assertSee($clientTicket->subject)->assertSee('Panel Klienta'); + + $this->actingAs($operator)->get('/operator')->assertOk()->assertSee('Otwarte')->assertSee('Panel Operatora'); + $this->actingAs($operator)->get('/operator/new')->assertOk()->assertSee($client->email)->assertSee('Panel Operatora'); + $this->actingAs($operator)->get(route('operator.ticket', $anyTicket))->assertOk()->assertSee($anyTicket->subject)->assertSee('Panel Operatora'); + + foreach (['categories', 'fields', 'users', 'teams', 'user-fields', 'reply-quick-actions', 'response-templates', 'statuses', 'priorities', 'templates', 'branding', 'config', 'about'] as $tab) { + $this->actingAs($admin)->get('/admin')->assertOk()->assertSee('Panel Administratora'); + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', $tab) + ->assertOk(); + } +}); diff --git a/src/tests/Feature/StatusStageRestructureTest.php b/src/tests/Feature/StatusStageRestructureTest.php new file mode 100644 index 0000000..6bd66ee --- /dev/null +++ b/src/tests/Feature/StatusStageRestructureTest.php @@ -0,0 +1,68 @@ +test(Panel::class) + ->call('removeStatus', $key) + ->call('confirmPendingDelete') + ->assertOk(); + + expect(Status::query()->find($key))->not->toBeNull(); + } +}); + +test('a custom status created by the admin is always unlocked and belongs to the "open" stage', function () { + seedStatusesAndPriorities(); + $admin = adminUser(); + + Livewire::actingAs($admin)->test(Panel::class) + ->set('newStatusLabel', 'Czeka na dostawę części') + ->call('addStatus') + ->assertOk(); + + $status = Status::query()->where('label', 'Czeka na dostawę części')->firstOrFail(); + expect($status->stage)->toBe('open') + ->and($status->locked)->toBeFalse(); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('removeStatus', $status->key) + ->call('confirmPendingDelete') + ->assertOk(); + + expect(Status::query()->find($status->key))->toBeNull(); +}); + +test('reopening a closed ticket sends it back to "Otwarty", not "Nowy"', function () { + seedStatusesAndPriorities(); + $client = User::query()->create(['name' => 'Ewa', 'email' => 'ewa-reopen@example.com', 'roles' => ['client']]); + $ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'closed']); + + Livewire::actingAs($client)->test(TicketShow::class, ['ticket' => $ticket]) + ->call('reopen'); + + expect($ticket->fresh()->status_key)->toBe('open'); +}); + +test('the client dashboard buckets any closed-stage status into "archiwalne" and everything else into "aktualne"', function () { + seedStatusesAndPriorities(); + Status::query()->create(['key' => 'on_hold', 'label' => 'Wstrzymany', 'color' => '#b5abfc', 'stage' => 'open', 'locked' => false, 'sort_order' => 4]); + + $client = User::query()->create(['name' => 'Piotr', 'email' => 'piotr-dash@example.com', 'roles' => ['client']]); + $onHold = makeTicket(['number' => '3001', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'on_hold']); + $closed = makeTicket(['number' => '3002', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'closed']); + + $component = Livewire::actingAs($client)->test(Dashboard::class); + + expect($component->instance()->currentTickets->pluck('number')->all())->toBe([$onHold->number]) + ->and($component->instance()->archiveTickets->pluck('number')->all())->toBe([$closed->number]); +}); diff --git a/src/tests/Feature/SubcategoryDefaultPriorityTest.php b/src/tests/Feature/SubcategoryDefaultPriorityTest.php new file mode 100644 index 0000000..a46711a --- /dev/null +++ b/src/tests/Feature/SubcategoryDefaultPriorityTest.php @@ -0,0 +1,66 @@ +create(['name' => 'IT-Pomoc']); + $sub = $category->subcategories()->create(['name' => 'VPN']); + Priority::query()->create(['key' => 'critical', 'label' => 'Krytyczny', 'color' => '#000', 'sort_order' => 1]); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('openSubcategoryEditForm', $sub->id) + ->set('subcategoryEditForm.defaultPriorityKey', 'critical') + ->call('submitSubcategoryEdit') + ->assertOk(); + + expect($sub->fresh()->default_priority_key)->toBe('critical'); +}); + +test('a new ticket takes its priority from the chosen subcategory default', function () { + seedStatusesAndPriorities(); + Priority::query()->create(['key' => 'critical', 'label' => 'Krytyczny', 'color' => '#000', 'sort_order' => 1]); + + $category = Category::query()->create(['name' => 'IT-Pomoc']); + $sub = $category->subcategories()->create(['name' => 'VPN', 'default_priority_key' => 'critical']); + + $ticket = app(TicketService::class)->create([ + 'email' => 'client-priority@example.com', + 'subject' => 'Problem VPN', + 'body' => 'Opis.', + 'subcategory_id' => $sub->id, + ], null); + + expect($ticket->priority_key)->toBe('critical'); +}); + +test('a new ticket falls back to "medium" when its subcategory has no default priority set', function () { + seedStatusesAndPriorities(); + $category = Category::query()->create(['name' => 'IT-Pomoc']); + $sub = $category->subcategories()->create(['name' => 'Drukarki']); + + $ticket = app(TicketService::class)->create([ + 'email' => 'client-priority-2@example.com', + 'subject' => 'Problem drukarki', + 'body' => 'Opis.', + 'subcategory_id' => $sub->id, + ], null); + + expect($ticket->priority_key)->toBe('medium'); +}); + +test('a new ticket with no subcategory at all still falls back to "medium"', function () { + seedStatusesAndPriorities(); + + $ticket = app(TicketService::class)->create([ + 'email' => 'client-priority-3@example.com', + 'subject' => 'Zgłoszenie bez podkategorii', + 'body' => 'Opis.', + ], null); + + expect($ticket->priority_key)->toBe('medium'); +}); diff --git a/src/tests/Feature/TicketApiTest.php b/src/tests/Feature/TicketApiTest.php new file mode 100644 index 0000000..f875183 --- /dev/null +++ b/src/tests/Feature/TicketApiTest.php @@ -0,0 +1,180 @@ +create(); + Sanctum::actingAs($client, $abilities); + + return $client; +} + +test('index lists tickets for a client with tickets:read', function () { + seedStatusesAndPriorities(); + makeTicket(['number' => '1001']); + makeTicket(['number' => '1002']); + apiClientWithAbilities(['tickets:read']); + + $this->getJson('/api/v1/tickets') + ->assertOk() + ->assertJsonCount(2, 'data'); +}); + +test('custom_fields are returned with their field id and label, and category nests its subcategory', function () { + seedStatusesAndPriorities(); + + $category = Category::query()->create(['name' => 'IT Pomoc']); + $subcategory = $category->subcategories()->create(['name' => 'VPN']); + $field = CustomField::query()->create(['label' => 'Wpływ na pracę zespołu', 'type' => 'text', 'required' => false]); + $subcategory->customFields()->attach($field->id, ['position' => 0]); + + $ticket = makeTicket([ + 'subcategory_id' => $subcategory->id, + 'custom_fields' => [$field->id => 'Częściowy'], + ]); + apiClientWithAbilities(['tickets:read']); + + $this->getJson("/api/v1/tickets/{$ticket->id}") + ->assertOk() + ->assertJsonPath('data.category.name', 'IT Pomoc') + ->assertJsonPath('data.category.subcategory.name', 'VPN') + ->assertJsonPath('data.custom_fields.0.id', $field->id) + ->assertJsonPath('data.custom_fields.0.label', 'Wpływ na pracę zespołu') + ->assertJsonPath('data.custom_fields.0.value', 'Częściowy'); +}); + +test('index filters by status_key', function () { + seedStatusesAndPriorities(); + makeTicket(['number' => '1001', 'status_key' => 'new']); + makeTicket(['number' => '1002', 'status_key' => 'closed']); + apiClientWithAbilities(['tickets:read']); + + $this->getJson('/api/v1/tickets?status_key=closed') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.number', '1002'); +}); + +test('index filters by a negated status_key, excluding it instead of matching it', function () { + seedStatusesAndPriorities(); + makeTicket(['number' => '1001', 'status_key' => 'new']); + makeTicket(['number' => '1002', 'status_key' => 'closed']); + apiClientWithAbilities(['tickets:read']); + + $this->getJson('/api/v1/tickets?status_key=!closed') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.number', '1001'); +}); + +test('index without tickets:read ability is forbidden', function () { + apiClientWithAbilities(['dictionaries:read']); + + $this->getJson('/api/v1/tickets')->assertForbidden(); +}); + +test('unauthenticated requests are rejected', function () { + $this->getJson('/api/v1/tickets')->assertUnauthorized(); +}); + +test('store creates a guest-style ticket stamped with the api client', function () { + seedStatusesAndPriorities(); + $client = apiClientWithAbilities(['tickets:write']); + + $response = $this->postJson('/api/v1/tickets', [ + 'email' => 'zgloszenie@firma.pl', + 'name' => 'Jan Kowalski', + 'subject' => 'VPN nie działa', + 'body' => 'Nie mogę się połączyć z VPN.', + ])->assertCreated(); + + $ticket = Ticket::query()->latest('id')->first(); + + expect($ticket)->not->toBeNull(); + expect($ticket->customer_id)->toBeNull(); + expect($ticket->email)->toBe('zgloszenie@firma.pl'); + expect($ticket->api_client_id)->toBe($client->id); + expect($ticket->messages()->first()->api_client_id)->toBe($client->id); + $response->assertJsonPath('data.number', $ticket->number); +}); + +test('store validates required fields', function () { + apiClientWithAbilities(['tickets:write']); + + $this->postJson('/api/v1/tickets', []) + ->assertStatus(422) + ->assertJsonValidationErrors(['email', 'name', 'subject', 'body']); +}); + +test('store without tickets:write ability is forbidden', function () { + apiClientWithAbilities(['tickets:read']); + + $this->postJson('/api/v1/tickets', [ + 'email' => 'a@example.com', 'name' => 'A', 'subject' => 'S', 'body' => 'B', + ])->assertForbidden(); +}); + +test('update changes status, priority, assignee and team and records history', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(); + $operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]); + $team = Team::query()->create(['name' => 'Infra']); + apiClientWithAbilities(['tickets:write']); + + $this->patchJson("/api/v1/tickets/{$ticket->id}", [ + 'status_key' => 'closed', + 'priority_key' => 'high', + 'assignee_id' => $operator->id, + 'team_id' => $team->id, + ])->assertOk()->assertJsonPath('data.status.key', 'closed'); + + $ticket->refresh(); + expect($ticket->status_key)->toBe('closed'); + expect($ticket->assignee_id)->toBe($operator->id); + expect($ticket->team_id)->toBe($team->id); + expect(TicketHistory::query()->where('ticket_id', $ticket->id)->count())->toBeGreaterThan(0); +}); + +test('update only touches fields present in the request', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(['subject' => 'Original subject']); + apiClientWithAbilities(['tickets:write']); + + $this->patchJson("/api/v1/tickets/{$ticket->id}", ['status_key' => 'closed'])->assertOk(); + + $ticket->refresh(); + expect($ticket->subject)->toBe('Original subject'); + expect($ticket->status_key)->toBe('closed'); +}); + +test('a real bearer token authenticates end to end', function () { + seedStatusesAndPriorities(); + makeTicket(); + + $client = ApiClient::factory()->create(); + $token = $client->createToken('test', ['tickets:read']); + + $this->withHeader('Authorization', 'Bearer '.$token->plainTextToken) + ->getJson('/api/v1/tickets') + ->assertOk(); +}); + +test('a revoked token no longer authenticates', function () { + $client = ApiClient::factory()->create(); + $token = $client->createToken('test', ['tickets:read']); + $plaintext = $token->plainTextToken; + + $client->tokens()->delete(); + + $this->withHeader('Authorization', 'Bearer '.$plaintext) + ->getJson('/api/v1/tickets') + ->assertUnauthorized(); +}); diff --git a/src/tests/Feature/TicketBodyAndAttachmentDisplayTest.php b/src/tests/Feature/TicketBodyAndAttachmentDisplayTest.php new file mode 100644 index 0000000..d1cdd3e --- /dev/null +++ b/src/tests/Feature/TicketBodyAndAttachmentDisplayTest.php @@ -0,0 +1,71 @@ +create([ + 'email' => 'client-body@example.com', + 'subject' => 'Problem z drukarką', + 'body' => 'Drukarka nie drukuje od rana, unikalny-fragment-tresci-XYZ', + ], null); + + $html = Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])->html(); + + expect(substr_count($html, 'unikalny-fragment-tresci-XYZ'))->toBe(1); +}); + +test('the ticket body shows once under the subject in the client view, not as a duplicated message bubble', function () { + seedStatusesAndPriorities(); + $client = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'client-body-2@example.com', 'roles' => ['client']]); + + $ticket = app(TicketService::class)->create([ + 'email' => $client->email, + 'subject' => 'Problem z VPN', + 'body' => 'VPN się rozłącza, unikalny-fragment-tresci-ABC', + ], $client); + + $html = Livewire::actingAs($client)->test(ClientTicketShow::class, ['ticket' => $ticket])->html(); + + expect(substr_count($html, 'unikalny-fragment-tresci-ABC'))->toBe(1); +}); + +test('an attachment uploaded with the original ticket still shows, now under the header instead of a message', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('body-attach@example.com'); + + $ticket = app(TicketService::class)->create([ + 'email' => 'client-attach@example.com', + 'subject' => 'Zrzut ekranu w załączniku', + 'body' => 'Patrz załącznik.', + ], null); + + $firstMessage = $ticket->messages()->firstOrFail(); + TicketAttachment::query()->create([ + 'ticket_id' => $ticket->id, + 'message_id' => $firstMessage->id, + 'original_name' => 'poczatkowy-zalacznik.png', + 'path' => 'attachments/poczatkowy-zalacznik.png', + 'size' => 100, + 'mime' => 'image/png', + ]); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->assertSee('poczatkowy-zalacznik.png'); +}); + +test('the reply attachment box shows "Brak załączników" when nothing is picked yet', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('no-attachment-yet@example.com'); + $ticket = makeTicket(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->assertSeeHtml('Brak załączników'); +}); diff --git a/src/tests/Feature/TicketBusinessRulesTest.php b/src/tests/Feature/TicketBusinessRulesTest.php new file mode 100644 index 0000000..67c6eee --- /dev/null +++ b/src/tests/Feature/TicketBusinessRulesTest.php @@ -0,0 +1,130 @@ +create(['key' => 'new', 'label' => 'Nowe', 'color' => '#9184d9', 'stage' => 'new', 'locked' => true, 'sort_order' => 1]); + Status::query()->create(['key' => 'open', 'label' => 'Otwarty', 'color' => '#b5abfc', 'stage' => 'open', 'locked' => true, 'sort_order' => 2]); + Status::query()->create(['key' => 'closed', 'label' => 'Zamknięte', 'color' => '#75798c', 'stage' => 'closed', 'locked' => true, 'sort_order' => 3]); + + Priority::query()->create(['key' => 'high', 'label' => 'Wysoki', 'color' => '#9184d9', 'sort_order' => 1]); + SlaRule::query()->create(['priority_key' => 'high', 'response_mins' => 60, 'resolution_mins' => 480]); +} + +function makeTicket(array $overrides = []): Ticket +{ + return Ticket::query()->create(array_merge([ + 'number' => '1001', + 'email' => 'client@example.com', + 'name' => 'Test Client', + 'subject' => 'Test subject', + 'body' => 'Test body', + 'status_key' => 'new', + 'priority_key' => 'high', + 'custom_fields' => [], + ], $overrides)); +} + +test('next ticket number is one above the current max', function () { + seedStatusesAndPriorities(); + + expect(Ticket::nextNumber())->toBe('1001'); + + makeTicket(['number' => '1001']); + makeTicket(['number' => '0998']); // archived tickets can have lower numbers + + expect(Ticket::nextNumber())->toBe('1002'); +}); + +test('sla info reports overdue once the resolution deadline has passed', function () { + seedStatusesAndPriorities(); + + $ticket = makeTicket(['created_at' => now()->subHours(9)]); // 480 min = 8h resolution window + + expect($ticket->slaInfo()['text'])->toBe('Przekroczono SLA'); +}); + +test('sla info reports remaining hours before the deadline', function () { + seedStatusesAndPriorities(); + + $ticket = makeTicket(['created_at' => now()->subHours(1)]); + + expect($ticket->slaInfo()['text'])->toContain('Pozostało'); +}); + +test('sla info is closed once the ticket status is in the "closed" stage', function () { + seedStatusesAndPriorities(); + + $ticket = makeTicket(['status_key' => 'closed', 'created_at' => now()->subDays(10)]); + + expect($ticket->slaInfo())->toBe(['text' => 'Zamknięte', 'short' => '—', 'cls' => 'tag tag-neutral']); +}); + +test('sla info respects any status marked with the "closed" stage, not just the literal "closed" key', function () { + seedStatusesAndPriorities(); + Status::query()->create(['key' => 'archived', 'label' => 'Zarchiwizowane', 'color' => '#75798c', 'stage' => 'closed', 'locked' => false, 'sort_order' => 9]); + + $ticket = makeTicket(['status_key' => 'archived', 'created_at' => now()->subDays(10)]); + + expect($ticket->isClosed())->toBeTrue() + ->and($ticket->slaInfo()['text'])->toBe('Zamknięte'); +}); + +test('a priority with resolution_mins set to 0 has no SLA and is never overdue, no matter how old the ticket is', function () { + seedStatusesAndPriorities(); + SlaRule::query()->where('priority_key', 'high')->update(['resolution_mins' => 0]); + + $ticket = makeTicket(['created_at' => now()->subYears(2)]); + + expect($ticket->isOverdue())->toBeFalse() + ->and($ticket->slaInfo())->toBe(['text' => 'Brak SLA', 'short' => 'Brak SLA', 'cls' => 'tag tag-neutral']); +}); + +test('merging tickets copies messages onto the primary and closes the others', function () { + seedStatusesAndPriorities(); + + $primary = makeTicket(['number' => '1001']); + $primary->messages()->create(['author_name' => 'A', 'body' => 'first'])->attachAuthor(null, 'client'); + + $other = makeTicket(['number' => '1002']); + $other->messages()->create(['author_name' => 'B', 'body' => 'second'])->attachAuthor(null, 'client'); + + app(TicketService::class)->merge([$primary->id, $other->id]); + + $primary->refresh(); + $other->refresh(); + + expect($other->status_key)->toBe('closed') + ->and($primary->messages()->count())->toBe(3) // original + copied + system note + ->and($primary->messages()->where('body', 'second')->exists())->toBeTrue() + ->and($other->messages()->where('internal', true)->where('body', 'like', '%Scalone ze zgłoszeniem #1001%')->exists())->toBeTrue(); +}); + +test('merge is a no-op with fewer than two tickets', function () { + seedStatusesAndPriorities(); + + $primary = makeTicket(['number' => '1001']); + + app(TicketService::class)->merge([$primary->id]); + + expect($primary->fresh()->status_key)->toBe('new'); +}); + +test('creating a ticket via the service records the initial client message', function () { + seedStatusesAndPriorities(); + + $ticket = app(TicketService::class)->create([ + 'email' => 'guest@example.com', + 'subject' => 'Help please', + 'body' => 'Something is broken', + ], null); + + expect($ticket->number)->toBe('1001') + ->and($ticket->messages()->count())->toBe(1) + ->and($ticket->messages()->first()->role)->toBe('client'); +}); diff --git a/src/tests/Feature/TicketMessageApiTest.php b/src/tests/Feature/TicketMessageApiTest.php new file mode 100644 index 0000000..6e9b3a8 --- /dev/null +++ b/src/tests/Feature/TicketMessageApiTest.php @@ -0,0 +1,84 @@ +messages()->create(['author_name' => 'Client', 'body' => 'Public message']); + $ticket->messages()->create(['author_name' => 'Op', 'internal' => true, 'body' => 'Internal note']); + + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['tickets:read']); + + $this->getJson("/api/v1/tickets/{$ticket->id}/messages") + ->assertOk() + ->assertJsonCount(2, 'data'); +}); + +test('store creates a public message with system authorship', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(); + $client = ApiClient::factory()->create(['name' => 'Monitoring Bot']); + Sanctum::actingAs($client, ['tickets:write']); + + $response = $this->postJson("/api/v1/tickets/{$ticket->id}/messages", [ + 'body' => 'Automated update from monitoring.', + ])->assertCreated(); + + $response->assertJsonPath('data.role', 'system'); + $response->assertJsonPath('data.author_name', 'Monitoring Bot'); + + $message = $ticket->messages()->latest('id')->first(); + expect($message->internal)->toBeFalse(); + expect($message->api_client_id)->toBe($client->id); +}); + +test('store creates an internal note when internal is true', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(); + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['tickets:write']); + + $this->postJson("/api/v1/tickets/{$ticket->id}/messages", [ + 'body' => 'Internal-only note.', + 'internal' => true, + ])->assertCreated()->assertJsonPath('data.internal', true); +}); + +test('store respects a custom author_name', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(); + $client = ApiClient::factory()->create(['name' => 'Bot']); + Sanctum::actingAs($client, ['tickets:write']); + + $this->postJson("/api/v1/tickets/{$ticket->id}/messages", [ + 'body' => 'Message body', + 'author_name' => 'External System', + ])->assertCreated()->assertJsonPath('data.author_name', 'External System'); +}); + +test('store attaches uploaded files', function () { + Storage::fake('public'); + seedStatusesAndPriorities(); + $ticket = makeTicket(); + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['tickets:write']); + + $this->postJson("/api/v1/tickets/{$ticket->id}/messages", [ + 'body' => 'See attached file.', + 'attachments' => [UploadedFile::fake()->create('log.txt', 50)], + ])->assertCreated()->assertJsonCount(1, 'data.attachments'); +}); + +test('store requires tickets:write ability', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(); + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['tickets:read']); + + $this->postJson("/api/v1/tickets/{$ticket->id}/messages", ['body' => 'x'])->assertForbidden(); +}); diff --git a/src/tests/Feature/TicketTimeTrackingTest.php b/src/tests/Feature/TicketTimeTrackingTest.php new file mode 100644 index 0000000..f38114b --- /dev/null +++ b/src/tests/Feature/TicketTimeTrackingTest.php @@ -0,0 +1,202 @@ +travelTo(now()); + $operator = operatorUser('timer-start@example.com'); + $ticket = makeTicket(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]); + + $ticket->refresh(); + expect($ticket->timer_started_at)->not->toBeNull() + ->and($ticket->time_spent_seconds)->toBe(0); +}); + +test('the elapsed time grows while the timer runs and is exposed via timerElapsedSeconds', function () { + seedStatusesAndPriorities(); + $this->travelTo(now()); + $operator = operatorUser('timer-elapse@example.com'); + $ticket = makeTicket(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]); + $ticket->refresh(); + + $this->travel(90)->seconds(); + + expect($ticket->fresh()->timerElapsedSeconds())->toBe(90); +}); + +test('stopping the timer checkpoints the elapsed seconds and clears the running marker', function () { + seedStatusesAndPriorities(); + $this->travelTo(now()); + $operator = operatorUser('timer-stop@example.com'); + $ticket = makeTicket(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]); + $ticket->refresh(); + + $this->travel(60)->seconds(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->call('stopTimer'); + + $ticket->refresh(); + expect($ticket->timer_started_at)->toBeNull() + ->and($ticket->time_spent_seconds)->toBe(60); +}); + +test('resuming a stopped timer starts a new running segment on top of the saved total', function () { + seedStatusesAndPriorities(); + $this->travelTo(now()); + $operator = operatorUser('timer-resume@example.com'); + $ticket = makeTicket(['time_spent_seconds' => 120, 'timer_started_at' => null]); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->call('resumeTimer'); + + $ticket->refresh(); + expect($ticket->timer_started_at)->not->toBeNull() + ->and($ticket->timerElapsedSeconds())->toBe(120); + + $this->travel(15)->seconds(); + + expect($ticket->fresh()->timerElapsedSeconds())->toBe(135); +}); + +test('opening a previously-stopped ticket resumes the timer, since it only tracks time the ticket is actually open', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('timer-reopen-resumes@example.com'); + $ticket = makeTicket(['time_spent_seconds' => 45, 'timer_started_at' => null]); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]); + + $ticket->refresh(); + expect($ticket->timer_started_at)->not->toBeNull() + ->and($ticket->timerElapsedSeconds())->toBe(45); +}); + +test('resetting the timer zeroes the saved total and stops the running segment', function () { + seedStatusesAndPriorities(); + $this->travelTo(now()); + $operator = operatorUser('timer-reset@example.com'); + $ticket = makeTicket(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]); + $ticket->refresh(); + + $this->travel(30)->seconds(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->call('resetTimer'); + + $ticket->refresh(); + expect($ticket->timer_started_at)->toBeNull() + ->and($ticket->time_spent_seconds)->toBe(0); +}); + +test('an operator action checkpoints the running timer via flushTimer without pausing it', function () { + seedStatusesAndPriorities(); + $this->travelTo(now()); + $operator = operatorUser('timer-flush@example.com'); + $ticket = makeTicket(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]); + $ticket->refresh(); + + $this->travel(40)->seconds(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->call('setPriority', 'high'); + + $ticket->refresh(); + expect($ticket->timer_started_at)->not->toBeNull() + ->and($ticket->time_spent_seconds)->toBe(40); +}); + +test('an operator can manually correct the tracked time while the timer is stopped', function () { + seedStatusesAndPriorities(); + $this->travelTo(now()); + $operator = operatorUser('timer-edit-stopped@example.com'); + $ticket = makeTicket(['time_spent_seconds' => 45, 'timer_started_at' => null]); + + // Mounting resumes the timer (it now only tracks time the ticket is + // actually open), so stop it explicitly to get back to a stopped state. + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->call('stopTimer') + ->call('startEditTimer') + ->assertSet('editTimerHours', '0') + ->assertSet('editTimerMinutes', '0') + ->assertSet('editTimerSeconds', '45') + ->set('editTimerHours', '2') + ->set('editTimerMinutes', '30') + ->set('editTimerSeconds', '0') + ->call('saveEditTimer') + ->assertSet('editingTimer', false); + + $ticket->refresh(); + expect($ticket->time_spent_seconds)->toBe(9000) // 2h30m + ->and($ticket->timer_started_at)->toBeNull(); +}); + +test('editing the time while the timer is running re-bases the running segment instead of stacking on top', function () { + seedStatusesAndPriorities(); + $this->travelTo(now()); + $operator = operatorUser('timer-edit-running@example.com'); + $ticket = makeTicket(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]); + $ticket->refresh(); + + $this->travel(20)->seconds(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->call('startEditTimer') + ->set('editTimerHours', '1') + ->set('editTimerMinutes', '0') + ->set('editTimerSeconds', '0') + ->call('saveEditTimer'); + + $ticket->refresh(); + expect($ticket->time_spent_seconds)->toBe(3600) + ->and($ticket->timer_started_at)->not->toBeNull() + ->and($ticket->fresh()->timerElapsedSeconds())->toBe(3600); +}); + +test('the stop-timer beacon endpoint checkpoints and stops a running timer', function () { + seedStatusesAndPriorities(); + $this->travelTo(now()); + $operator = operatorUser('timer-beacon@example.com'); + $ticket = makeTicket(); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]); + $ticket->refresh(); + + $this->travel(50)->seconds(); + + $this->actingAs($operator) + ->post(route('operator.ticket.stop-timer', $ticket)) + ->assertNoContent(); + + $ticket->refresh(); + expect($ticket->timer_started_at)->toBeNull() + ->and($ticket->time_spent_seconds)->toBe(50); +}); + +test('cancelling the timer edit leaves the tracked time untouched', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('timer-edit-cancel@example.com'); + $ticket = makeTicket(['time_spent_seconds' => 45, 'timer_started_at' => null]); + + Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]) + ->call('startEditTimer') + ->set('editTimerHours', '9') + ->call('cancelEditTimer') + ->assertSet('editingTimer', false); + + $ticket->refresh(); + expect($ticket->time_spent_seconds)->toBe(45); +}); diff --git a/src/tests/Feature/TimezoneConfigTest.php b/src/tests/Feature/TimezoneConfigTest.php new file mode 100644 index 0000000..6d3e28c --- /dev/null +++ b/src/tests/Feature/TimezoneConfigTest.php @@ -0,0 +1,48 @@ +test(Panel::class) + ->call('setTab', 'config') + ->set('systemConfig.timezone', 'Europe/Warsaw') + ->call('saveSystemConfig') + ->assertOk(); + + expect(Settings::get('timezone'))->toBe('Europe/Warsaw'); +}); + +test('an unrecognized timezone identifier is silently rejected, keeping the previous value', function () { + $admin = adminUser(); + Settings::set('timezone', 'Europe/Warsaw'); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'config') + ->set('systemConfig.timezone', 'Not/A_Real_Zone') + ->call('saveSystemConfig') + ->assertOk(); + + expect(Settings::get('timezone'))->toBe('Europe/Warsaw'); +}); + +test('Settings::timezone() falls back to the default when the stored value is invalid', function () { + Settings::set('timezone', 'Not/A_Real_Zone'); + + expect(Settings::timezone())->toBe('UTC'); +}); + +test('AppServiceProvider overrides app.timezone and the PHP default timezone from the saved setting', function () { + Settings::set('timezone', 'Europe/Warsaw'); + + (new AppServiceProvider(app()))->boot(); + + expect(config('app.timezone'))->toBe('Europe/Warsaw') + ->and(date_default_timezone_get())->toBe('Europe/Warsaw'); + + date_default_timezone_set('UTC'); // restore, since this is a process-wide PHP setting +}); diff --git a/src/tests/Feature/UserFieldsTest.php b/src/tests/Feature/UserFieldsTest.php new file mode 100644 index 0000000..4cfc9a4 --- /dev/null +++ b/src/tests/Feature/UserFieldsTest.php @@ -0,0 +1,185 @@ +test(Panel::class) + ->call('setTab', 'user-fields') + ->call('openUserFieldForm') + ->set('userFieldForm.label', 'Numer telefonu') + ->set('userFieldForm.type', 'text') + ->set('userFieldForm.ldapAttribute', 'mobile') + ->call('submitUserField') + ->assertOk(); + + $field = UserField::query()->where('label', 'Numer telefonu')->firstOrFail(); + expect($field->ldap_attribute)->toBe('mobile'); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('editUserField', $field->id) + ->assertSet('userFieldForm.label', 'Numer telefonu') + ->set('userFieldForm.label', 'Telefon komórkowy') + ->call('submitUserField') + ->assertOk(); + + expect($field->fresh()->label)->toBe('Telefon komórkowy'); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('removeUserField', $field->id) + ->assertSet('pendingDeleteType', 'user-field') + ->call('confirmPendingDelete') + ->assertOk(); + + expect(UserField::query()->find($field->id))->toBeNull(); +}); + +test('user field values only show up and save when editing a user, never when inviting one', function () { + $admin = adminUser(); + $op = operatorUser('phonefield@example.com'); + + $field = UserField::query()->create(['label' => 'Numer telefonu', 'type' => 'text', 'sort_order' => 1]); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'users') + ->call('openUserForm') + ->assertSet('userForm.fieldValues', []); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('editUser', $op->id) + ->set("userForm.fieldValues.{$field->id}", '+48 111 222 333') + ->call('submitUser') + ->assertOk(); + + expect($op->fresh()->custom_field_values)->toBe([(string) $field->id => '+48 111 222 333']); +}); + +test('the ldap sync button pulls attribute values for existing users into their user fields', function () { + $fake = DirectoryEmulator::setup(); + $admin = adminUser(); + $op = operatorUser('anna.sync@firma.pl'); + + $field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'ldap_attribute' => 'departmentNumber', 'sort_order' => 1]); + + LldapUser::create([ + 'uid' => 'anna.sync', + 'cn' => 'Anna Sync', + 'mail' => 'anna.sync@firma.pl', + 'departmentnumber' => 'IT-42', + 'entryuuid' => (string) Str::uuid(), + ]); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'users') + ->call('syncUsersWithLdap') + ->assertOk(); + + expect($op->fresh()->custom_field_values)->toBe([(string) $field->id => 'IT-42']); +}); + +test('a first-time guest ticket from a known ldap e-mail auto-provisions the account and fills its ldap-backed fields', function () { + $fake = DirectoryEmulator::setup(); + + $field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'ldap_attribute' => 'departmentnumber', 'sort_order' => 1]); + + LldapUser::create([ + 'uid' => 'nowy.klient', + 'cn' => 'Nowy Klient', + 'mail' => 'nowy.klient@firma.pl', + 'departmentnumber' => 'HR-7', + 'entryuuid' => (string) Str::uuid(), + ]); + + expect(User::query()->where('email', 'nowy.klient@firma.pl')->exists())->toBeFalse(); + + $ticket = app(TicketService::class)->create([ + 'email' => 'nowy.klient@firma.pl', + 'subject' => 'Test', + 'body' => 'Treść zgłoszenia', + ], null); + + $user = User::query()->where('email', 'nowy.klient@firma.pl')->first(); + + expect($user)->not->toBeNull() + ->and($user->name)->toBe('Nowy Klient') + ->and($user->roles)->toBe(['client']) + ->and($user->custom_field_values)->toBe([(string) $field->id => 'HR-7']) + ->and($ticket->customer_id)->toBe($user->id); +}); + +test('a guest ticket from an e-mail unknown to ldap stays anonymous, as before', function () { + DirectoryEmulator::setup(); + + $ticket = app(TicketService::class)->create([ + 'email' => 'ktos@nieznany.pl', + 'subject' => 'Test', + 'body' => 'Treść zgłoszenia', + ], null); + + expect($ticket->customer_id)->toBeNull() + ->and(User::query()->where('email', 'ktos@nieznany.pl')->exists())->toBeFalse(); +}); + +test('the operator ticket view shows the reporting customer\'s user field values', function () { + seedStatusesAndPriorities(); + + $operator = operatorUser('op-view@example.com'); + $client = User::query()->create(['name' => 'Reporter Kowalski', 'email' => 'reporter@example.com', 'roles' => ['client']]); + + $field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'sort_order' => 1]); + $client->update(['custom_field_values' => [$field->id => 'Księgowość']]); + + $ticket = makeTicket(['customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->assertSee('Dział') + ->assertSee('Księgowość'); +}); + +test('the operator ticket view does not blow up for a reporter with no field values set', function () { + seedStatusesAndPriorities(); + + $operator = operatorUser('op-view2@example.com'); + UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'sort_order' => 1]); + + $ticket = makeTicket(); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->assertOk(); +}); + +test('the admin "Zobacz" button shows every detail about a user, including their field values', function () { + $admin = adminUser(); + $op = operatorUser('zobacz@example.com'); + + $team = Team::query()->create(['name' => 'Infrastruktura']); + $op->teams()->attach($team->id); + + $field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'sort_order' => 1]); + $op->update(['custom_field_values' => [$field->id => 'IT-42']]); + + Livewire::actingAs($admin)->test(Panel::class) + ->call('setTab', 'users') + ->call('viewUser', $op->id) + ->assertSee('zobacz@example.com') + ->assertSee('Infrastruktura') + ->assertSee('Dział') + ->assertSee('IT-42') + ->call('editUser', $op->id) + ->assertSet('viewingUserId', null) + ->assertSet('userForm.id', $op->id); +}); diff --git a/src/tests/Feature/UsersApiTest.php b/src/tests/Feature/UsersApiTest.php new file mode 100644 index 0000000..ea9b92a --- /dev/null +++ b/src/tests/Feature/UsersApiTest.php @@ -0,0 +1,66 @@ +create(['name' => 'Anna', 'email' => 'anna@example.com', 'roles' => ['client']]); + User::query()->create(['name' => 'Marek', 'email' => 'marek@example.com', 'roles' => ['operator']]); + + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['users:read']); + + $response = $this->getJson('/api/v1/users')->assertOk()->assertJsonCount(2, 'data'); + + $response->assertJsonMissingPath('data.0.password'); + $response->assertJsonMissingPath('data.0.remember_token'); +}); + +test('index filters by role', function () { + User::query()->create(['name' => 'Anna', 'email' => 'anna@example.com', 'roles' => ['client']]); + User::query()->create(['name' => 'Marek', 'email' => 'marek@example.com', 'roles' => ['operator']]); + + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['users:read']); + + $this->getJson('/api/v1/users?role=operator') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.name', 'Marek'); +}); + +test('show returns a single user', function () { + $user = User::query()->create(['name' => 'Anna', 'email' => 'anna@example.com', 'roles' => ['client']]); + + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['users:read']); + + $this->getJson("/api/v1/users/{$user->id}") + ->assertOk() + ->assertJsonPath('data.email', 'anna@example.com'); +}); + +test('show returns the user\'s custom field values', function () { + $field = UserField::query()->create(['label' => 'Dział', 'type' => 'text', 'sort_order' => 0]); + $user = User::query()->create([ + 'name' => 'Anna', 'email' => 'anna@example.com', 'roles' => ['client'], + 'custom_field_values' => [$field->id => 'Księgowość'], + ]); + + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['users:read']); + + $this->getJson("/api/v1/users/{$user->id}") + ->assertOk() + ->assertJsonPath('data.custom_fields.0.label', 'Dział') + ->assertJsonPath('data.custom_fields.0.value', 'Księgowość'); +}); + +test('users require the users:read ability', function () { + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['tickets:read']); + + $this->getJson('/api/v1/users')->assertForbidden(); +}); diff --git a/src/tests/Pest.php b/src/tests/Pest.php new file mode 100644 index 0000000..941e024 --- /dev/null +++ b/src/tests/Pest.php @@ -0,0 +1,50 @@ +extend(TestCase::class) + ->use(RefreshDatabase::class) + ->in('Feature'); + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| When you're writing tests, you often need to check that values meet certain conditions. The +| "expect()" function gives you access to a set of "expectations" methods that you can use +| to assert different things. Of course, you may extend the Expectation API at any time. +| +*/ + +expect()->extend('toBeOne', function () { + return $this->toBe(1); +}); + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| While Pest is very powerful out-of-the-box, you may have some testing code specific to your +| project that you don't want to repeat in every file. Here you can also expose helpers as +| global functions to help you to reduce the number of lines of code in your test files. +| +*/ + +function something() +{ + // .. +} diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php new file mode 100644 index 0000000..6afd668 --- /dev/null +++ b/src/tests/TestCase.php @@ -0,0 +1,18 @@ +toBeTrue(); +}); diff --git a/src/vite.config.js b/src/vite.config.js new file mode 100644 index 0000000..1fd66d5 --- /dev/null +++ b/src/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import { bunny } from 'laravel-vite-plugin/fonts'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + fonts: [ + bunny('Instrument Sans', { + weights: [400, 500, 600], + }), + ], + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +}); diff --git a/wiki/admin/README.md b/wiki/admin/README.md new file mode 100644 index 0000000..6605d63 --- /dev/null +++ b/wiki/admin/README.md @@ -0,0 +1,115 @@ +# Przewodnik — Administrator + +Panel administratora (`/admin`) to jedno miejsce do konfiguracji całego systemu: +struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), użytkownicy i +zespoły, treści (szablony, szybkie akcje, e-maile), wygląd/branding oraz +integracje (LDAP, SMTP, API). + +Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do +panelu Administratora przez menu profilu (prawy górny róg). + +## Kategorie i pola dodatkowe + +- **Kategorie / podkategorie** — nazwa + opis (widoczny klientowi przy wyborze). + Podkategoria może mieć **domyślny priorytet**, nadawany automatycznie nowym + zgłoszeniom w tej podkategorii. +- **Pola dodatkowe** (custom fields) — typ (tekst / tekst długi / lista wyboru / + checkbox / data / liczba), czy wymagane, opcje (dla listy wyboru). Każde pole + przypisuje się do jednej lub wielu podkategorii z określoną kolejnością + wyświetlania — te pola pojawiają się klientowi w formularzu zgłoszenia i + operatorowi w widoku zgłoszenia. + +## Użytkownicy + +- Lista użytkowników z rolami (**Klient / Operator / Administrator** — konto może + mieć więcej niż jedną). Rola nadaje dostęp do odpowiedniego panelu. +- **Pola użytkownika** — analogicznie do pól zgłoszenia, ale dla profilu + użytkownika (np. Stanowisko, Dział, Firma, telefon) — mogą być mapowane na + atrybut LDAP (`ldap_attribute`), żeby wypełniały się automatycznie przy + synchronizacji z katalogiem. +- **Synchronizuj z LDAP** — ręczne wymuszenie ponownej synchronizacji kont z + katalogu (poza standardowym sync-on-login). +- Konta lokalne (utworzone tu, z hasłem) logują się przez fallback e-mail + + hasło lokalne, gdy dopasowanie po LDAP-owym atrybucie loginu się nie powiedzie + — tak działa domyślne konto awaryjne `admin@example.com`. + +## Zespoły + +Każdy zespół ma listę **członków** (operatorów) i listę **podkategorii**, za +które odpowiada. Gdy w Konfiguracji włączone jest „automatyczne przypisywanie wg +kategorii”, nowe zgłoszenie w danej podkategorii trafia od razu do właściwego +zespołu. Operator spoza zespołu nie widzi jego kolejki (poza zgłoszeniami bez +zespołu i przypisanymi mu bezpośrednio). + +## Statusy, priorytety i SLA + +- **Statusy** mają trzy stałe etapy (`stage`): `new`, `open`, `closed`. Statusy + „Nowe”, „Otwarty” i „Zamknięte” są zablokowane (`locked`) i nie można ich + usunąć — pozostałe (W trakcie, Oczekuje na klienta, itd.) są w pełni + edytowalne/usuwalne dowolnie w ramach etapu „open”. Każdy status ma nazwę, + kolor i kolejność wyświetlania. +- **Priorytety** — nazwa, kolor, kolejność. +- **SLA** — dla każdego priorytetu: czas do pierwszej odpowiedzi (`response_mins`) + i czas do rozwiązania (`resolution_mins`), w minutach. Priorytet z czasem + rozwiązania = 0 nigdy nie jest liczony jako naruszenie SLA (np. priorytet + „Brak”). Naruszenia sprawdza cykliczne zadanie co 15 minut + (`tickets:check-sla-breaches`) i może powiadomić operatora. + +## Szybkie akcje odpowiedzi + +Przyciski w widoku zgłoszenia operatora, które **wysyłają odpowiedź i od razu +zmieniają status** (np. „Wyślij i zamknij”) — konfigurowalne: etykieta, docelowy +status, kolejność. Status jest tu „miękkim” odniesieniem (po kluczu) — usunięcie +statusu w Admin nie usuwa powiązanej z nim szybkiej akcji, po prostu przestaje +zmieniać status. + +## Szablony odpowiedzi + +Gotowe teksty (zwykły tekst, bez HTML — pole odpowiedzi w kolejce to zwykły +`