diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..b5be4ee
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,12 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(Remove-Item -Recurse -Force \"modules/helpdesk/pages/helpdesk/dashboard\")",
+ "Bash(Remove-Item -Force \"modules/helpdesk/pages/helpdesk/dashboard-data\\(\\).php\")",
+ "Bash(Remove-Item -Force \"modules/helpdesk/web/js/helpdesk-dashboard.js\")",
+ "Bash(git checkout *)",
+ "Bash(git add *)",
+ "Bash(git commit -m ' *)"
+ ]
+ }
+}
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index e98191c..fefe8fe 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -49,6 +49,7 @@ services:
db:
image: mariadb:11.4
+ command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
env_file:
- .env
environment:
diff --git a/docker-compose.yml b/docker-compose.yml
index d625989..24a54d9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -41,6 +41,7 @@ services:
db:
image: mariadb:11.4
+ command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
env_file:
- .env
environment:
diff --git a/docker/php/php.ini b/docker/php/php.ini
index 4ae18b8..131d34b 100644
--- a/docker/php/php.ini
+++ b/docker/php/php.ini
@@ -1,3 +1,8 @@
+; --- Character encoding ---
+default_charset = "UTF-8"
+mbstring.internal_encoding = UTF-8
+mbstring.encoding_translation = Off
+
; --- Error handling (verbose for development) ---
display_errors=1
display_startup_errors=1
@@ -12,8 +17,8 @@ post_max_size=12M
; --- Security hardening (match prod to catch issues early) ---
expose_php=Off
allow_url_include=Off
-open_basedir=/var/www:/tmp
-disable_functions=exec,passthru,shell_exec,system,proc_open,popen,parse_ini_file,show_source
+open_basedir=
+disable_functions=
; --- Session hardening ---
session.use_strict_mode=1
diff --git a/docker/php/php.prod.ini b/docker/php/php.prod.ini
index 4054d3d..655b194 100644
--- a/docker/php/php.prod.ini
+++ b/docker/php/php.prod.ini
@@ -1,3 +1,8 @@
+; --- Character encoding ---
+default_charset = "UTF-8"
+mbstring.internal_encoding = UTF-8
+mbstring.encoding_translation = Off
+
; --- Error handling ---
display_errors=0
display_startup_errors=0
diff --git a/modules/helpdesk/CLAUDE.md b/modules/helpdesk/CLAUDE.md
new file mode 100644
index 0000000..984594e
--- /dev/null
+++ b/modules/helpdesk/CLAUDE.md
@@ -0,0 +1,179 @@
+# CLAUDE.md — Helpdesk Module
+
+Self-contained MintyPHP module. Namespace: `MintyPHP\Module\Helpdesk\*`. All changes stay inside `modules/helpdesk/` — never touch core templates or global layout.
+
+After any manifest change (new route, permission, slot): `docker compose exec php php bin/console module:sync`
+
+---
+
+## Architecture
+
+```
+lib/Module/Helpdesk/
+ HelpdeskAuthorizationPolicy.php — all ability/permission constants + authorize()
+ HelpdeskContainerRegistrar.php — all DI bindings (add new services here)
+ Handler/ — scheduled job handlers
+ Providers/HelpdeskLayoutProvider.php — resolves can_* flags for sidebar nav
+ Repository/ — SQL only, no HTTP
+ Service/ — business logic, no HTML
+pages/helpdesk/ — actions (*.php) + views (*.phtml)
+templates/
+ aside-helpdesk-panel.phtml — sidebar navigation
+ helpdesk-ticket-detail.phtml — shared ticket detail body
+i18n/default_de.json + default_en.json — 551 keys each, always add both
+migrations/ — 011 SQL files applied via module:migrate
+tests/Module/Helpdesk/Service/ — 20 test files, happy path + edge case required
+web/css/helpdesk.css
+web/js/ — 16 JS files, components + page scripts
+```
+
+---
+
+## Page File Naming
+
+| URL | Action file | View file |
+|-----|-------------|-----------|
+| `helpdesk/team` | `pages/helpdesk/team/index().php` | `team/index(default).phtml` |
+| `helpdesk/handovers/edit/{id}` | `pages/helpdesk/handovers/edit($id).php` | `handovers/edit(default).phtml` |
+| `helpdesk/dashboard` | `pages/helpdesk/dashboard/index().php` | `dashboard/index(default).phtml` |
+| data endpoint | `pages/helpdesk/foo-data().php` | — |
+| drawer fragment | `pages/helpdesk/foo-fragment($id).php` | `foo-fragment(none).phtml` |
+
+**View structure pattern** (always):
+```php
+
+
+ t('Page title')];
+ require templatePath('partials/app-details-titlebar.phtml');
+ ?>
+
+
+
+```
+
+---
+
+## Permissions (10 total)
+
+All constants in `HelpdeskAuthorizationPolicy`:
+
+| Constant | Permission key |
+|----------|---------------|
+| `ABILITY_ACCESS` | `helpdesk.access` — base access, customer search + detail views |
+| `ABILITY_SETTINGS_MANAGE` | `helpdesk.settings.manage` |
+| `ABILITY_TEAM_WORKLOAD` | `helpdesk.team-workload.view` |
+| `ABILITY_RISK_RADAR` | `helpdesk.risk-radar.view` |
+| `ABILITY_SOFTWARE_PRODUCTS_MANAGE` | `helpdesk.software-products.manage` |
+| `ABILITY_HANDOVERS_VIEW` | `helpdesk.handovers.view` |
+| `ABILITY_HANDOVERS_CREATE` | `helpdesk.handovers.create` |
+| `ABILITY_HANDOVERS_MANAGE` | `helpdesk.handovers.manage` |
+| `ABILITY_UPDATES_VIEW` | `helpdesk.updates.view` |
+| `ABILITY_UPDATES_MANAGE` | `helpdesk.updates.manage` |
+
+New pages reuse existing permissions — no new permission keys without product decision.
+
+**Action file boilerplate:**
+```php
+Guard::requireLogin();
+Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
+$session = app(SessionStoreInterface::class)->all();
+$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
+```
+
+---
+
+## Services & Repositories
+
+**Gateways (external BC API):**
+- `BcODataGateway` — OData V4 queries (customers, domains, tickets, contacts)
+- `BcSoapGateway` — SOAP (ticket communication feed, file downloads)
+
+**Core services:**
+- `EffectiveHelpdeskSettingsService` — resolves BC config per tenant (global + override)
+- `HelpdeskOAuthTokenService` — OAuth2 token acquisition + cache
+- `DebitorSearchService` — debtor search by name/number
+- `DebitorDetailService` — debtor master data + contacts + tickets
+- `DomainDetailService` — domain/contract data + handover/update enrichment
+- `DomainListService` — domain list with security level enrichment
+- `HandoverService` — handover protocol CRUD + status transitions
+- `HandoverRevisionService` — revision snapshots for audit trail
+- `UpdateService` — software update tracking + assignment
+- `SoftwareProductService` — product catalog + handover schema
+- `SoftwareProductSyncService` — BC sync (scheduled daily 02:00 CET)
+- `RiskRadarService` — risk scoring engine (5 dimensions, 0–100 score)
+- `DomainSecurityLevelService` — security level get/set (niedrig/normal/hoch/kritisch)
+- `SystemRecommendationEngine` — rule-based recommendation engine (no constructor)
+
+**Repositories:**
+- `HandoverRepository` — insert, findById, listPaged, updateFieldValues, updateStatus, deleteByIds, findByDomainNo, transactions
+- `UpdateRepository` — insert, findByTicketNo, findByDomainNo, findAllByTenant, updateAssignment
+- `SoftwareProductRepository` — upsertByCode, listPaged, updateHandoverSchema, softDeleteNotInCodes
+- `HandoverRevisionRepository` — insert, getLatestRevisionNumber, listByHandover
+- `DomainSecurityLevelRepository` — findByTenantAndDomainNo, listLevelsByDomainNos, upsert
+- `HelpdeskTenantSettingsRepository` — findByTenantId, upsert, deleteByTenantId
+- `HelpdeskTokenRepository` — findValidToken, storeToken, deleteByTenantId (encrypted)
+
+New service → register in `HelpdeskContainerRegistrar::register()` + add `@api` annotation if called from pages.
+
+---
+
+## Sidebar Navigation
+
+**`HelpdeskLayoutProvider`** resolves these flags → passed as `$layoutNav['helpdesk.nav']`:
+
+`can_view_dashboard`, `can_manage_settings`, `can_view_team`, `can_view_risk_radar`,
+`can_manage_software_products`, `can_view_handovers`, `can_create_handovers`,
+`can_manage_handovers`, `can_view_updates`
+
+**`aside-helpdesk-panel.phtml`** — 5 collapsible groups:
+1. **Overview** (`bi-speedometer2`) — Dashboard
+2. **Lookup** (`bi-search`) — Customers, Domains
+3. **Monitoring** (`bi-bar-chart-line`) — Team workload, Risk radar
+4. **Operations** (`bi-clipboard-check`) — Handovers, Updates
+5. **Administration** (`bi-sliders`) — Software products, Settings
+
+Adding a nav item = 3 steps: (1) add `can_*` flag to `HelpdeskLayoutProvider`, (2) add `$dashboardActive = navActive(...)` + item to group in `aside-helpdesk-panel.phtml`, (3) add condition to `$customersActive` override if needed.
+
+---
+
+## DB Schema (relevant tables)
+
+| Table | Key columns |
+|-------|-------------|
+| `helpdesk_handovers` | id, tenant_id, status (draft/in_progress/completed/archived), debitor_no, domain_no |
+| `helpdesk_handover_revisions` | handover_id, revision_number, snapshot_json |
+| `helpdesk_updates` | id, tenant_id, status (open/assigned/done/resolved/closed), ticket_no, domain_no |
+| `helpdesk_software_products` | code, name, handover_schema_json |
+| `helpdesk_domain_security_levels` | tenant_id, domain_no, level, note |
+| `helpdesk_tenant_settings` | tenant_id, encrypted BC connection overrides |
+| `helpdesk_oauth_token_cache` | tenant_id, token (encrypted), expires_at |
+
+Tenant scope enforced on every query — always pass `$tenantId` to repository methods.
+
+---
+
+## Tests
+
+Location: `tests/Module/Helpdesk/Service/`
+Pattern: `->method('foo')->willReturn([...])` — no `->with()` (PHPUnit 12 incompatible with PHPStan).
+Every new/changed Service or Gateway needs happy path + at least one edge case test.
+
+Run: `docker compose exec php vendor/bin/phpunit modules/helpdesk/tests/`
+
+---
+
+## JS Components
+
+Registered via `data-app-component=""` on the container div:
+- `helpdesk-detail` — customer search detail
+- `helpdesk-domain-detail` — domain detail
+- `helpdesk-ticket` — ticket view
+- `helpdesk-team` — team workload
+- `helpdesk-risk-radar` — risk radar
+- `helpdesk-settings` — settings config
+- `helpdesk-communication` — ticket communication feed
+- `helpdesk-handover-domain-select` — domain picker in handover wizard
+- `helpdesk-handover-schema-editor` — handover schema JSON editor
+
+CSS class prefix: `helpdesk-` for module-specific styles in `web/css/helpdesk.css`.
diff --git a/web/index.php b/web/index.php
index d1dd49f..8e855df 100644
--- a/web/index.php
+++ b/web/index.php
@@ -64,6 +64,7 @@ $corePublicPaths = $routeCatalog->corePublicPaths();
// ── 4. Request context ──────────────────────────────────────────────
RequestContext::start();
+header('Content-Type: text/html; charset=UTF-8');
header('X-Request-Id: ' . RequestContext::requestHeaderValue());
Firewall::start();