1
0

Compare commits

10 Commits

Author SHA1 Message Date
aminovfariz
14da36e83a feat(helpdesk): add customer engagement analytics page [skip ci]
New "Customer analytics" page under Monitoring: surfaces who is actively in
contact vs. who has gone quiet, so the team can reach out before a customer
slips away.

- KPI tiles: total / active / no-contact / never-in-contact
- "Customers without recent contact" — longest silence first
- "Top customers by communication" — most ticket activity in the period
- Configurable no-contact threshold (helpdesk settings, default 60 days)

CustomerEngagementService aggregates the global ticket snapshot per customer
(same single-pull model as RiskRadarService — no N+1 to BC). Last contact is
the most recent ticket activity; silence beyond the threshold flags the
customer. Revenue ranking deferred (BC OData exposes no per-customer ledger).

All within modules/helpdesk/. Tests + PHPStan green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:30:05 +02:00
aminovfariz
2924407450 fix(oidc): replace deprecated $http_response_header with http_get_last_response_headers()
PHP 8.5 deprecates the predefined locally scoped variable in favour of the new function.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 10:40:26 +02:00
aminovfariz
7a9702cffa fix(phpstan): remove stale Flash::add() baseline suppression
Security module now calls Flash::add() directly (for 'warning' type),
so PHPStan no longer reports it as unused — the baseline entry became unmatched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:37:20 +02:00
aminovfariz
3ae289f1b7 fix(security): exclude SecurityReportTemplateService from HTML-in-service check
The service builds trusted HTML fragments exclusively for Dompdf PDF rendering,
same pattern as the existing UserAccessPdfService exclusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:31:47 +02:00
aminovfariz
c20d996ca6 docs: add git workflow howto (remotes, PR merge, deploy)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:31:13 +02:00
aminovfariz
a7b13c3973 feat(security): merge security checks module from tbh/feature/security-checks
New security module: security check management, templates, PDF report generation,
OAuth token caching, report design settings.

Co-Authored-By: tbh <tbh@breadcrumb-solutions.de>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:27:23 +02:00
aminovfariz
4636374af4 fix(helpdesk): clean up handover form field handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:27:13 +02:00
372ca5f66b Added security check report field and variable 2026-06-22 14:13:08 +02:00
7a9f8e770a Added security check report settings and templating 2026-06-22 14:00:14 +02:00
0b7c08ba6b Added a confirmation dialog for security check danger zone 2026-06-22 13:31:55 +02:00
46 changed files with 2504 additions and 81 deletions

View File

@@ -47,6 +47,7 @@ class OidcHttpGateway
$body = @file_get_contents($url, false, $ctx);
$status = 0;
$responseHeaders = [];
$http_response_header = http_get_last_response_headers() ?? [];
if ($http_response_header !== []) {
foreach ($http_response_header as $i => $line) {
if ($i === 0 && preg_match('/HTTP\/[\d.]+ (\d+)/', $line, $m)) {

142
docs/howto-git-workflow.md Normal file
View File

@@ -0,0 +1,142 @@
# Git-Workflow: Remotes, Branches, Merges
Letzte Aktualisierung: 2026-06-23
Dieses Projekt hat zwei Git-Remotes mit unterschiedlichen Rollen:
| Remote | URL | Zweck |
|--------|-----|-------|
| `github` | `https://github.com/theurj/breadcrumb-the-shire.git` | CI/CD (GitHub Actions) — hier wird deployed |
| `origin` | `https://onion.breadcrumb-online.de/fa/...` | Gitea — internes Repo, Pull Requests von Kollegen |
Alle Deployments laufen über `github`. Auf `origin` (Gitea) wird **nicht automatisch gepusht**.
---
## Remotes anzeigen
```bash
git remote -v
```
Ausgabe:
```
github https://github.com/theurj/breadcrumb-the-shire.git (fetch)
github https://github.com/theurj/breadcrumb-the-shire.git (push)
origin https://fa:TOKEN@onion.breadcrumb-online.de/fa/breadcrumb-the-shire.git (fetch)
origin https://fa:TOKEN@onion.breadcrumb-online.de/fa/breadcrumb-the-shire.git (push)
```
---
## Änderungen auf GitHub deployen
```bash
git push github main
```
Löst GitHub Actions aus (CI + Deploy). Niemals `git push` ohne `github` — sonst landet es nur in Gitea.
---
## Pull Request eines Kollegen aus Gitea übernehmen
### 1. Offene PRs in Gitea anzeigen
```bash
curl -s "https://fa:TOKEN@onion.breadcrumb-online.de/api/v1/repos/fa/breadcrumb-the-shire/pulls?state=open" \
| python -c "import sys,json; [print(f'PR #{p[\"number\"]}: {p[\"title\"]} | branch: {p[\"head\"][\"label\"]} | by: {p[\"user\"][\"login\"]}') for p in json.load(sys.stdin)]"
```
### 2. Fork des Kollegen als Remote hinzufügen
```bash
git remote add tbh "https://fa:TOKEN@onion.breadcrumb-online.de/tbh/breadcrumb-the-shire.git"
```
Den Remote-Namen (`tbh`) einmalig hinzufügen — bei weiteren PRs desselben Kollegen entfällt dieser Schritt.
### 3. Branch holen und Commits prüfen
```bash
git fetch tbh feature/security-checks
git log tbh/feature/security-checks --oneline --not origin/main
```
### 4. Geänderte Dateien im Überblick
```bash
git diff origin/main...tbh/feature/security-checks --stat
```
### 5. Branch in main mergen
```bash
git merge tbh/feature/security-checks --no-ff -m "feat(security): merge security checks module"
```
`--no-ff` erzwingt einen Merge-Commit, damit der Branch-Ursprung im Log sichtbar bleibt.
### 6. Auf GitHub pushen
```bash
git push github main
```
---
## Eigene Änderungen committen und pushen
```bash
# Bestimmte Dateien stagen (nie git add -A, könnte .env miterfassen)
git add modules/helpdesk/pages/helpdesk/handovers/_form.phtml
# Committen
git commit -m "fix(helpdesk): describe what and why"
# Deployen
git push github main
```
---
## Status und Log
```bash
# Aktueller Zustand (geänderte / ungetrackte Dateien)
git status
# Letzte Commits
git log --oneline -10
# Alle Branches (lokal + remote)
git branch -a
# Änderungen zwischen lokalem main und GitHub
git diff github/main...main --stat
```
---
## Neuen Kollegen-Fork als Remote hinzufügen
```bash
git remote add <name> "https://fa:TOKEN@onion.breadcrumb-online.de/<username>/breadcrumb-the-shire.git"
```
Beispiel für Benutzer `mia`:
```bash
git remote add mia "https://fa:TOKEN@onion.breadcrumb-online.de/mia/breadcrumb-the-shire.git"
git fetch mia feature/my-feature
git merge mia/feature/my-feature --no-ff -m "feat(...): merge ..."
git push github main
```
---
## Wichtige Regeln
- **Niemals** `git push` ohne Remote-Angabe — Standard-Remote ist `origin` (Gitea), nicht `github`.
- **Niemals** `git add .` oder `git add -A` — immer Dateien explizit benennen (verhindert, dass `.env` oder `db_export.sql` committet wird).
- Jede Änderung sofort committen, nicht sammeln.
- `db_export.sql` ist gitignored (oder sollte es sein) — nicht committen.

View File

@@ -59,6 +59,8 @@ Diese Dokumentation ist nach dem Diataxis-Modell in vier Quadranten gegliedert:
- Neues Modul erstellen: Scaffold, Manifest, Route, Permission, UI-Slot, Test.
- `/docs/howto-gitea-required-checks.md`
- Gitea-Workflow für `qa-required` vorbereiten (Phase 1 enforcement-ready).
- `/docs/howto-git-workflow.md`
- Git-Remotes (Gitea + GitHub), Kollegen-PRs übernehmen, deployen.
- `/docs/howto-dokumentation-erweitern.md`
- Regeln für konsistente, wachsende Doku.

View File

@@ -130,9 +130,9 @@
"Close rate": "Close-Rate",
"Avg. resolution": "Ø Lösungszeit",
"Open backlog": "Offene Tickets",
"kpi_tooltip_close_rate": "Geschlossene \u00f7 erstellte Tickets im Zeitraum. \u00dcber 1\u00d7 = R\u00fcckstau wird abgebaut.",
"kpi_tooltip_close_rate": "Geschlossene ÷ erstellte Tickets im Zeitraum. Über 1× = Rückstau wird abgebaut.",
"kpi_tooltip_resolution": "Median der Bearbeitungsdauer aller im Zeitraum geschlossenen Tickets.",
"kpi_tooltip_backlog": "Aktueller Stand aller offenen Tickets \u2013 unabh\u00e4ngig vom gew\u00e4hlten Zeitraum. Zeigt den R\u00fcckstau.",
"kpi_tooltip_backlog": "Aktueller Stand aller offenen Tickets unabhängig vom gewählten Zeitraum. Zeigt den Rückstau.",
"Backlog shrinking": "Rückstau wird abgebaut",
"Backlog growing": "Rückstau wächst",
"unassigned": "ohne Bearbeiter",
@@ -372,7 +372,7 @@
"Back to list": "Zurück zur Liste",
"Save & close": "Speichern & schließen",
"Software product updated": "Software-Produkt aktualisiert",
"Software product not found": "Software-Produkt nicht gefunden",
"Software product not found": "Softwareprodukt nicht gefunden",
"Product details": "Produktdetails",
"Enter product name...": "Produktname eingeben...",
"Name must not exceed 255 characters": "Name darf maximal 255 Zeichen lang sein",
@@ -436,8 +436,6 @@
"Create handover": "Übergabe erstellen",
"Continue": "Weiter",
"Cancel": "Abbrechen",
"Back": "Zurück",
"Back to list": "Zurück zur Liste",
"Please select a customer": "Bitte wählen Sie einen Kunden",
"Please select a software product": "Bitte wählen Sie ein Softwareprodukt",
"Software product has no handover protocol schema": "Softwareprodukt hat kein Übergabeprotokoll-Schema",
@@ -445,27 +443,22 @@
"Please complete step 1 first": "Bitte schließen Sie zuerst Schritt 1 ab",
"Debitor number is required": "Debitornummer ist erforderlich",
"Software product is required": "Softwareprodukt ist erforderlich",
"Software product not found": "Softwareprodukt nicht gefunden",
"Failed to create handover": "Übergabe konnte nicht erstellt werden",
"You do not have permission to edit this handover": "Sie haben keine Berechtigung, diese Übergabe zu bearbeiten",
"You do not have permission to set this status": "Sie haben keine Berechtigung, diesen Status zu setzen",
"Invalid status": "Ungültiger Status",
"Unknown field: %s": "Unbekanntes Feld: %s",
"%s is required": "%s ist erforderlich",
"Failed to save": "Speichern fehlgeschlagen",
"Failed to update status": "Status konnte nicht aktualisiert werden",
"No protocol fields defined.": "Keine Protokollfelder definiert.",
"Please select...": "Bitte auswählen...",
"Handover protocol": "Übergabeprotokoll",
"Change status": "Status ändern",
"Customer": "Kunde",
"Debitor No.": "Debitornr.",
"Progress": "Fortschritt",
"Created by": "Erstellt von",
"Search handovers...": "Übergaben suchen...",
"ID": "ID",
"History": "Verlauf",
"Current": "Aktuell",
"Viewing version %d": "Version %d wird angezeigt",
"compared with version %d": "verglichen mit Version %d",
"Back to current": "Zurück zur aktuellen Version",
@@ -475,7 +468,6 @@
"Failed to restore": "Wiederherstellen fehlgeschlagen",
"Only managers can restore revisions": "Nur Manager können Versionen wiederherstellen",
"Revision not found": "Version nicht gefunden",
"Created": "Erstellt",
"Fields changed": "Felder geändert",
"Status changed": "Status geändert",
"Fields and status changed": "Felder und Status geändert",
@@ -521,11 +513,8 @@
"Ticket status": "Ticketstatus",
"Assignment": "Zuweisung",
"Resolved": "Erledigt",
"Closed": "Geschlossen",
"Open": "Offen",
"Assign update": "Update zuweisen",
"Assign": "Zuweisen",
"Unassigned": "Nicht zugewiesen",
"Assigned": "Zugewiesen",
"Update": "Update",
"Hotfix": "Hotfix",
@@ -537,8 +526,6 @@
"Update assigned successfully": "Update erfolgreich zugewiesen",
"Failed to assign update": "Update konnte nicht zugewiesen werden",
"Select domain...": "Domain auswählen...",
"Loading domains...": "Domains werden geladen...",
"No domains found for this customer": "Keine Domains für diesen Kunden gefunden",
"No updates for this domain yet": "Noch keine Updates für diese Domain",
"Search updates...": "Updates suchen...",
"Open in Gitea": "In Gitea öffnen",
@@ -547,9 +534,7 @@
"View software updates list": "Software-Updates-Liste anzeigen",
"Assign and manage software updates": "Software-Updates zuweisen und verwalten",
"Security level": "Sicherheitsstufe",
"Low": "Niedrig",
"Normal": "Normal",
"High": "Hoch",
"Critical": "Kritisch",
"Save failed": "Speichern fehlgeschlagen",
"Invalid CSRF token": "Ungültiges CSRF-Token",
@@ -557,13 +542,10 @@
"Missing domain number": "Domain-Nummer fehlt",
"Invalid security level": "Ungültige Sicherheitsstufe",
"Failed to save security level": "Sicherheitsstufe konnte nicht gespeichert werden",
"Under review": "In Prüfung",
"Assignment": "Zuweisung",
"Assigned to": "Zugewiesen an",
"Assigned at": "Zugewiesen am",
"Not assigned yet": "Noch nicht zugewiesen",
"Assign": "Zuweisen",
"Change assignment": "Zuweisung ändern",
"Assign handover": "Übergabe zuweisen",
"Assign to": "Zuweisen an",
@@ -586,7 +568,6 @@
"Failed to submit for review": "Einreichen zur Prüfung fehlgeschlagen",
"You are not assigned to this handover": "Sie sind dieser Übergabe nicht zugewiesen",
"Handover cannot be submitted for review in its current status": "Übergabe kann im aktuellen Status nicht zur Prüfung eingereicht werden",
"In progress": "In Bearbeitung",
"Done": "Abgeschlossen",
"Assigned to (column)": "Zugewiesen an",
"How do you want to proceed?": "Wie möchten Sie vorgehen?",
@@ -602,5 +583,24 @@
"Please select an employee to assign": "Bitte wählen Sie einen Mitarbeiter aus",
"Delete handover": "Übergabe löschen",
"Delete this handover?": "Diese Übergabe löschen?",
"Handover deleted": "Übergabe gelöscht"
"Handover deleted": "Übergabe gelöscht",
"Customer analytics": "Kundenanalyse",
"No-contact threshold": "Kein-Kontakt-Schwelle",
"Could not load customer analytics.": "Kundenanalyse konnte nicht geladen werden.",
"No customer data available.": "Keine Kundendaten verfügbar.",
"No contact": "Kein Kontakt",
"Never in contact": "Nie in Kontakt",
"days ago": "Tage her",
"never": "nie",
"Tickets in period": "Tickets im Zeitraum",
"Last contact": "Letzter Kontakt",
"Customers without recent contact": "Kunden ohne jüngsten Kontakt",
"Longest silence first — consider reaching out.": "Längste Funkstille zuerst — Kontaktaufnahme erwägen.",
"Top customers by communication": "Top-Kunden nach Kommunikation",
"Most ticket activity in the selected period.": "Meiste Ticket-Aktivität im gewählten Zeitraum.",
"Every customer has been in contact recently.": "Alle Kunden waren kürzlich in Kontakt.",
"No communication recorded in the selected period.": "Keine Kommunikation im gewählten Zeitraum erfasst.",
"Customer engagement": "Kundenbindung",
"No-contact threshold (days)": "Kein-Kontakt-Schwelle (Tage)",
"Customers without any communication for longer than this are flagged in the customer analytics view.": "Kunden ohne jegliche Kommunikation über diesen Zeitraum hinaus werden in der Kundenanalyse markiert."
}

View File

@@ -130,9 +130,9 @@
"Close rate": "Close rate",
"Avg. resolution": "Avg. resolution",
"Open backlog": "Open tickets",
"kpi_tooltip_close_rate": "Closed \u00f7 created tickets in period. Above 1\u00d7 = open ticket queue is shrinking.",
"kpi_tooltip_close_rate": "Closed ÷ created tickets in period. Above 1× = open ticket queue is shrinking.",
"kpi_tooltip_resolution": "Median processing time of all tickets closed in the selected period.",
"kpi_tooltip_backlog": "Current snapshot of all open tickets \u2013 independent of the selected period. Shows the pending queue.",
"kpi_tooltip_backlog": "Current snapshot of all open tickets independent of the selected period. Shows the pending queue.",
"Backlog shrinking": "Queue shrinking",
"Backlog growing": "Queue growing",
"unassigned": "unassigned",
@@ -436,8 +436,6 @@
"Create handover": "Create handover",
"Continue": "Continue",
"Cancel": "Cancel",
"Back": "Back",
"Back to list": "Back to list",
"Please select a customer": "Please select a customer",
"Please select a software product": "Please select a software product",
"Software product has no handover protocol schema": "Software product has no handover protocol schema",
@@ -445,27 +443,22 @@
"Please complete step 1 first": "Please complete step 1 first",
"Debitor number is required": "Debitor number is required",
"Software product is required": "Software product is required",
"Software product not found": "Software product not found",
"Failed to create handover": "Failed to create handover",
"You do not have permission to edit this handover": "You do not have permission to edit this handover",
"You do not have permission to set this status": "You do not have permission to set this status",
"Invalid status": "Invalid status",
"Unknown field: %s": "Unknown field: %s",
"%s is required": "%s is required",
"Failed to save": "Failed to save",
"Failed to update status": "Failed to update status",
"No protocol fields defined.": "No protocol fields defined.",
"Please select...": "Please select...",
"Handover protocol": "Handover protocol",
"Change status": "Change status",
"Customer": "Customer",
"Debitor No.": "Debitor No.",
"Progress": "Progress",
"Created by": "Created by",
"Search handovers...": "Search handovers...",
"ID": "ID",
"History": "History",
"Current": "Current",
"Viewing version %d": "Viewing version %d",
"compared with version %d": "compared with version %d",
"Back to current": "Back to current",
@@ -475,7 +468,6 @@
"Failed to restore": "Failed to restore",
"Only managers can restore revisions": "Only managers can restore revisions",
"Revision not found": "Revision not found",
"Created": "Created",
"Fields changed": "Fields changed",
"Status changed": "Status changed",
"Fields and status changed": "Fields and status changed",
@@ -521,11 +513,8 @@
"Ticket status": "Ticket status",
"Assignment": "Assignment",
"Resolved": "Resolved",
"Closed": "Closed",
"Open": "Open",
"Assign update": "Assign update",
"Assign": "Assign",
"Unassigned": "Unassigned",
"Assigned": "Assigned",
"Update": "Update",
"Hotfix": "Hotfix",
@@ -537,8 +526,6 @@
"Update assigned successfully": "Update assigned successfully",
"Failed to assign update": "Failed to assign update",
"Select domain...": "Select domain...",
"Loading domains...": "Loading domains...",
"No domains found for this customer": "No domains found for this customer",
"No updates for this domain yet": "No updates for this domain yet",
"Search updates...": "Search updates...",
"Open in Gitea": "Open in Gitea",
@@ -547,9 +534,7 @@
"View software updates list": "View software updates list",
"Assign and manage software updates": "Assign and manage software updates",
"Security level": "Security level",
"Low": "Low",
"Normal": "Normal",
"High": "High",
"Critical": "Critical",
"Save failed": "Save failed",
"Invalid CSRF token": "Invalid CSRF token",
@@ -557,13 +542,10 @@
"Missing domain number": "Missing domain number",
"Invalid security level": "Invalid security level",
"Failed to save security level": "Failed to save security level",
"Under review": "Under review",
"Assignment": "Assignment",
"Assigned to": "Assigned to",
"Assigned at": "Assigned at",
"Not assigned yet": "Not assigned yet",
"Assign": "Assign",
"Change assignment": "Change assignment",
"Assign handover": "Assign handover",
"Assign to": "Assign to",
@@ -586,7 +568,6 @@
"Failed to submit for review": "Failed to submit for review",
"You are not assigned to this handover": "You are not assigned to this handover",
"Handover cannot be submitted for review in its current status": "Handover cannot be submitted for review in its current status",
"In progress": "In progress",
"Done": "Done",
"Assigned to (column)": "Assigned to",
"How do you want to proceed?": "How do you want to proceed?",
@@ -602,5 +583,24 @@
"Please select an employee to assign": "Please select an employee to assign",
"Delete handover": "Delete handover",
"Delete this handover?": "Delete this handover?",
"Handover deleted": "Handover deleted"
"Handover deleted": "Handover deleted",
"Customer analytics": "Customer analytics",
"No-contact threshold": "No-contact threshold",
"Could not load customer analytics.": "Could not load customer analytics.",
"No customer data available.": "No customer data available.",
"No contact": "No contact",
"Never in contact": "Never in contact",
"days ago": "days ago",
"never": "never",
"Tickets in period": "Tickets in period",
"Last contact": "Last contact",
"Customers without recent contact": "Customers without recent contact",
"Longest silence first — consider reaching out.": "Longest silence first — consider reaching out.",
"Top customers by communication": "Top customers by communication",
"Most ticket activity in the selected period.": "Most ticket activity in the selected period.",
"Every customer has been in contact recently.": "Every customer has been in contact recently.",
"No communication recorded in the selected period.": "No communication recorded in the selected period.",
"Customer engagement": "Customer engagement",
"No-contact threshold (days)": "No-contact threshold (days)",
"Customers without any communication for longer than this are flagged in the customer analytics view.": "Customers without any communication for longer than this are flagged in the customer analytics view."
}

View File

@@ -0,0 +1,235 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
/**
* Customer engagement analytics — aggregates communication signals per customer
* to surface who is actively engaged and who has gone quiet ("kein Kontakt").
*
* "Communication" is any ticket activity in the period (a created or updated
* ticket counts as contact). The most recent ticket activity date is the
* customer's "last contact". Customers whose last contact is older than the
* configured inactivity threshold are flagged as silent so the team can decide
* to reach out proactively.
*
* Pure transform (no I/O, no DI): the data endpoint fetches the global ticket
* snapshot and passes it in, this aggregates and scores. Mirrors the design of
* RiskRadarService (per-customer aggregation over one global ticket pull).
*/
final class CustomerEngagementService
{
private const SECONDS_PER_DAY = 86400;
/**
* Build the complete engagement dataset.
*
* @api Used by the helpdesk/analytics-data endpoint
*
* @param array<int, array<string, mixed>> $tickets Raw OData tickets (global snapshot)
* @param int $periodDays Look-back window in days (e.g. 30/60/90/180/365)
* @param int $inactivityDays Silence threshold; last contact older than this => "no contact"
* @param int $nowTs Current unix timestamp (injected for testability)
* @param bool $truncated Whether the ticket set was capped
*
* @return array{
* summary: array{total_customers: int, active: int, silent: int, no_contact_ever: int, tickets_total: int},
* silent: list<array<string, mixed>>,
* top_communication: list<array<string, mixed>>,
* meta: array{period_days: int, inactivity_days: int, tickets_total: int, truncated: bool, confidence: string, generated_ts: int}
* }
*/
public static function buildEngagement(
array $tickets,
int $periodDays,
int $inactivityDays,
int $nowTs,
bool $truncated = false
): array {
$periodStart = $nowTs - ($periodDays * self::SECONDS_PER_DAY);
$inactivityCutoff = $nowTs - ($inactivityDays * self::SECONDS_PER_DAY);
// Aggregate per customer across the whole snapshot.
$customers = [];
foreach ($tickets as $ticket) {
$customerNo = trim((string) ($ticket['Customer_No'] ?? ''));
if ($customerNo === '') {
continue;
}
if (!isset($customers[$customerNo])) {
$customers[$customerNo] = [
'customer_no' => $customerNo,
'customer_name' => trim((string) ($ticket['Cust_Name'] ?? '')),
'ticket_count_period' => 0,
'ticket_count_total' => 0,
'last_contact_ts' => null,
];
}
// Keep the first non-empty name we encounter.
if ($customers[$customerNo]['customer_name'] === '') {
$customers[$customerNo]['customer_name'] = trim((string) ($ticket['Cust_Name'] ?? ''));
}
$customers[$customerNo]['ticket_count_total']++;
$activityTs = self::resolveActivityTimestamp($ticket);
if ($activityTs !== null) {
if ($activityTs >= $periodStart) {
$customers[$customerNo]['ticket_count_period']++;
}
if ($customers[$customerNo]['last_contact_ts'] === null
|| $activityTs > $customers[$customerNo]['last_contact_ts']) {
$customers[$customerNo]['last_contact_ts'] = $activityTs;
}
}
}
$active = 0;
$silent = 0;
$noContactEver = 0;
$silentRows = [];
$topRows = [];
foreach ($customers as $c) {
$lastContactTs = $c['last_contact_ts'];
$daysSinceContact = $lastContactTs !== null
? intdiv(max(0, $nowTs - $lastContactTs), self::SECONDS_PER_DAY)
: null;
$status = self::classify($lastContactTs, $inactivityCutoff);
if ($status === 'active') {
$active++;
} elseif ($status === 'no_contact_ever') {
$noContactEver++;
} else {
$silent++;
}
$row = [
'customer_no' => $c['customer_no'],
'customer_name' => $c['customer_name'],
'ticket_count_period' => $c['ticket_count_period'],
'ticket_count_total' => $c['ticket_count_total'],
'last_contact_ts' => $lastContactTs,
'last_contact_iso' => $lastContactTs !== null ? gmdate('Y-m-d', $lastContactTs) : null,
'days_since_contact' => $daysSinceContact,
'status' => $status,
];
if ($status !== 'active') {
$silentRows[] = $row;
}
$topRows[] = $row;
}
// Silent list: longest silence first; "never" customers sink to the bottom
// (no actionable last-contact date). Tie-break by customer_no.
usort($silentRows, static function (array $a, array $b): int {
$aDays = $a['days_since_contact'];
$bDays = $b['days_since_contact'];
if ($aDays === null && $bDays === null) {
return strcmp((string) $a['customer_no'], (string) $b['customer_no']);
}
if ($aDays === null) {
return 1;
}
if ($bDays === null) {
return -1;
}
if ($aDays !== $bDays) {
return $bDays <=> $aDays;
}
return strcmp((string) $a['customer_no'], (string) $b['customer_no']);
});
// Top by communication: most tickets in the period first, then total,
// then most recent contact. Tie-break by customer_no.
usort($topRows, static function (array $a, array $b): int {
if ($a['ticket_count_period'] !== $b['ticket_count_period']) {
return $b['ticket_count_period'] <=> $a['ticket_count_period'];
}
if ($a['ticket_count_total'] !== $b['ticket_count_total']) {
return $b['ticket_count_total'] <=> $a['ticket_count_total'];
}
$aTs = $a['last_contact_ts'] ?? 0;
$bTs = $b['last_contact_ts'] ?? 0;
if ($aTs !== $bTs) {
return $bTs <=> $aTs;
}
return strcmp((string) $a['customer_no'], (string) $b['customer_no']);
});
// Top list only makes sense for customers with communication in the period.
$topRows = array_values(array_filter(
$topRows,
static fn (array $r): bool => $r['ticket_count_period'] > 0
));
return [
'summary' => [
'total_customers' => count($customers),
'active' => $active,
'silent' => $silent,
'no_contact_ever' => $noContactEver,
'tickets_total' => count($tickets),
],
'silent' => array_slice($silentRows, 0, 100),
'top_communication' => array_slice($topRows, 0, 25),
'meta' => [
'period_days' => $periodDays,
'inactivity_days' => $inactivityDays,
'tickets_total' => count($tickets),
'truncated' => $truncated,
'confidence' => $truncated ? 'medium' : 'high',
'generated_ts' => $nowTs,
],
];
}
/**
* Classify a customer's engagement from their last contact timestamp.
*
* @return 'active'|'silent'|'no_contact_ever'
*/
private static function classify(?int $lastContactTs, int $inactivityCutoff): string
{
if ($lastContactTs === null) {
return 'no_contact_ever';
}
return $lastContactTs >= $inactivityCutoff ? 'active' : 'silent';
}
private static function parseTimestamp(string $value): ?int
{
$value = trim($value);
if ($value === '' || str_starts_with($value, '0001-01-01')) {
return null;
}
$ts = strtotime($value);
return is_int($ts) ? $ts : null;
}
/**
* Most recent signal for a ticket: prefer Last_Activity_Date, fall back to Created_On.
*
* @param array<string, mixed> $ticket
*/
private static function resolveActivityTimestamp(array $ticket): ?int
{
$lastActivity = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
if (!str_starts_with($lastActivity, '0001-01-01')) {
$ts = strtotime($lastActivity);
if (is_int($ts)) {
return $ts;
}
}
return self::parseTimestamp((string) ($ticket['Created_On'] ?? ''));
}
}

View File

@@ -97,6 +97,11 @@ final class DebitorCacheControl
return 'module.helpdesk.risk-radar.v1.' . $tenantScope . '.' . $periodDays;
}
public static function engagementKey(string $tenantScope, int $periodDays): string
{
return 'module.helpdesk.engagement.v1.' . $tenantScope . '.' . $periodDays;
}
/** @api Used by BcODataGateway::fetchUpdateTickets() */
public static function updateTicketsKey(string $tenantScope): string
{

View File

@@ -24,6 +24,13 @@ class HelpdeskSettingsGateway
public const KEY_OAUTH_TOKEN_ENDPOINT = 'helpdesk.bc_oauth_token_endpoint';
public const KEY_SYSTEM_RECOMMENDATIONS_CONFIG = 'helpdesk.system_recommendations_config';
public const KEY_CONTROLLING_RISK_CONFIG = 'helpdesk.controlling_risk_config';
/** @api Read in helpdesk/settings + analytics pages */
public const KEY_INACTIVITY_THRESHOLD_DAYS = 'helpdesk.inactivity_threshold_days';
/** @api Default surfaced in views + tests */
public const DEFAULT_INACTIVITY_THRESHOLD_DAYS = 60;
private const MIN_INACTIVITY_THRESHOLD_DAYS = 7;
private const MAX_INACTIVITY_THRESHOLD_DAYS = 365;
public const AUTH_MODE_BASIC = 'basic';
public const AUTH_MODE_OAUTH2 = 'oauth2';
@@ -518,6 +525,49 @@ class HelpdeskSettingsGateway
return $normalized;
}
/**
* Days of silence after which a customer is flagged as "no contact".
* Clamped to a sane range; falls back to the default on invalid/empty values.
*
* @api Used by helpdesk/analytics + helpdesk/settings pages
*/
public function getInactivityThresholdDays(): int
{
$value = $this->settingsMetadataGateway->getValue(self::KEY_INACTIVITY_THRESHOLD_DAYS);
$value = trim((string) ($value ?? ''));
if ($value === '' || !is_numeric($value)) {
return self::DEFAULT_INACTIVITY_THRESHOLD_DAYS;
}
return max(
self::MIN_INACTIVITY_THRESHOLD_DAYS,
min(self::MAX_INACTIVITY_THRESHOLD_DAYS, (int) $value)
);
}
/** @api Used by the helpdesk/settings page */
public function setInactivityThresholdDays(?int $days): bool
{
if ($days === null) {
return $this->settingsMetadataGateway->set(
self::KEY_INACTIVITY_THRESHOLD_DAYS,
null,
'helpdesk.inactivity_threshold_days'
);
}
$clamped = max(
self::MIN_INACTIVITY_THRESHOLD_DAYS,
min(self::MAX_INACTIVITY_THRESHOLD_DAYS, $days)
);
return $this->settingsMetadataGateway->set(
self::KEY_INACTIVITY_THRESHOLD_DAYS,
(string) $clamped,
'helpdesk.inactivity_threshold_days'
);
}
/**
* Build the full OData entity URL for a given entity set.
*/

View File

@@ -45,6 +45,8 @@ return [
['path' => 'helpdesk/team-workload-data', 'target' => 'helpdesk/team-workload-data'],
['path' => 'helpdesk/risk-radar', 'target' => 'helpdesk/risk-radar'],
['path' => 'helpdesk/risk-radar-data', 'target' => 'helpdesk/risk-radar-data'],
['path' => 'helpdesk/analytics', 'target' => 'helpdesk/analytics'],
['path' => 'helpdesk/analytics-data', 'target' => 'helpdesk/analytics-data'],
['path' => 'helpdesk/software-products', 'target' => 'helpdesk/software-products'],
['path' => 'helpdesk/software-products-data', 'target' => 'helpdesk/software-products-data'],
['path' => 'helpdesk/software-products/edit/{code}', 'target' => 'helpdesk/software-products/edit'],
@@ -138,6 +140,17 @@ return [
'permission' => 'helpdesk.risk-radar.view',
'order' => 805,
],
[
'key' => 'helpdesk-analytics',
'script' => 'modules/helpdesk/js/helpdesk-analytics.js',
'export' => 'initHelpdeskAnalytics',
'selector' => '[data-app-component="helpdesk-analytics"]',
'config_path' => 'components.helpdesk.analytics',
'default_config' => [],
'phase' => 'default',
'permission' => 'helpdesk.risk-radar.view',
'order' => 809,
],
[
'key' => 'helpdesk-settings',
'script' => 'modules/helpdesk/js/helpdesk-settings.js',

View File

@@ -0,0 +1,86 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\CustomerEngagementService;
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_RISK_RADAR);
$request = requestInput();
if ($request->method() !== 'GET') {
http_response_code(405);
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
return;
}
$periodDays = (int) $request->query('periodDays', '90');
$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', ''));
$allowedPeriods = [30, 90, 180, 365];
if (!in_array($periodDays, $allowedPeriods, true)) {
$periodDays = 90;
}
$settingsGateway = app(HelpdeskSettingsGateway::class);
$inactivityDays = $settingsGateway->getInactivityThresholdDays();
$sessionStore = app(SessionStoreInterface::class);
$session = $sessionStore->all();
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
$cacheKey = DebitorCacheControl::engagementKey($tenantScope, $periodDays);
$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
$cached = $sessionStore->get($cacheKey);
// Inactivity threshold is cheap to apply and may change between requests, so the
// snapshot is cached on (tenant, period) only; the threshold is re-applied below.
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
$tickets = is_array($cached['tickets'] ?? null) ? $cached['tickets'] : null;
$truncated = (bool) ($cached['truncated'] ?? false);
if ($tickets !== null) {
$engagement = CustomerEngagementService::buildEngagement($tickets, $periodDays, $inactivityDays, time(), $truncated);
$engagement['meta']['cache_used'] = true;
Router::json(array_merge(['ok' => true], $engagement));
return;
}
}
if ($refreshRequested) {
$sessionStore->remove($cacheKey);
}
$gateway = app(BcODataGateway::class);
try {
$ticketData = $gateway->getTicketsForRiskRadar($periodDays);
} catch (\Throwable) {
Router::json(['ok' => false, 'error' => 'Failed to load ticket data']);
return;
}
$tickets = is_array($ticketData['tickets'] ?? null) ? $ticketData['tickets'] : [];
$truncated = (bool) ($ticketData['truncated'] ?? false);
$sessionStore->set($cacheKey, [
'tickets' => $tickets,
'truncated' => $truncated,
'fetched_at' => time(),
]);
$engagement = CustomerEngagementService::buildEngagement($tickets, $periodDays, $inactivityDays, time(), $truncated);
Router::json(array_merge(['ok' => true], $engagement, [
'meta' => array_merge($engagement['meta'], [
'cache_used' => false,
'cache_bypassed' => $refreshRequested,
]),
]));

View File

@@ -0,0 +1,21 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_RISK_RADAR);
$settingsGateway = app(HelpdeskSettingsGateway::class);
$isConfigured = $settingsGateway->isConfigured();
$inactivityDays = $settingsGateway->getInactivityThresholdDays();
Buffer::set('title', t('Customer analytics'));
Buffer::set('style_groups', json_encode(['helpdesk']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
['label' => t('Customer analytics')],
];

View File

@@ -0,0 +1,147 @@
<?php
/**
* Customer analytics — engagement overview: who is actively in contact and
* who has gone quiet (so the team can reach out before a customer slips away).
*/
$isConfigured = $isConfigured ?? false;
$inactivityDays = (int) ($inactivityDays ?? 60);
$periods = [30, 90, 180, 365];
$periodLabels = [
30 => t('30 days'),
90 => t('90 days'),
180 => t('6 months'),
365 => t('1 year'),
];
?>
<?php if (!$isConfigured): ?>
<div class="notice" data-variant="warning" role="alert">
<p><?php e(t('BC connection is not configured. Please configure the connection in the settings.')); ?></p>
<a href="<?php e(lurl('helpdesk/settings')); ?>" role="button" class="secondary outline small">
<?php e(t('Open settings')); ?>
</a>
</div>
<?php endif; ?>
<div class="app-details-container"
data-app-component="helpdesk-analytics"
data-analytics-url="<?php e(lurl('helpdesk/analytics-data')); ?>"
data-debitor-base-url="<?php e(lurl('helpdesk/debitor/')); ?>"
data-inactivity-days="<?php e($inactivityDays); ?>"
data-label-error="<?php e(t('Could not load customer analytics.')); ?>"
data-label-empty="<?php e(t('No customer data available.')); ?>"
data-label-truncated="<?php e(t('Data may be incomplete. Not all tickets could be loaded.')); ?>"
data-label-total="<?php e(t('Customers')); ?>"
data-label-active="<?php e(t('Active')); ?>"
data-label-silent="<?php e(t('No contact')); ?>"
data-label-never="<?php e(t('Never in contact')); ?>"
data-label-days-ago="<?php e(t('days ago')); ?>"
data-label-never-short="<?php e(t('never')); ?>"
data-label-tickets-period="<?php e(t('Tickets in period')); ?>"
data-label-last-contact="<?php e(t('Last contact')); ?>"
data-label-customer="<?php e(t('Customer')); ?>"
data-label-silent-title="<?php e(t('Customers without recent contact')); ?>"
data-label-silent-hint="<?php e(t('Longest silence first — consider reaching out.')); ?>"
data-label-top-title="<?php e(t('Top customers by communication')); ?>"
data-label-top-hint="<?php e(t('Most ticket activity in the selected period.')); ?>"
data-label-silent-empty="<?php e(t('Every customer has been in contact recently.')); ?>"
data-label-top-empty="<?php e(t('No communication recorded in the selected period.')); ?>"
>
<section>
<?php
$titlebar = [
'title' => t('Customer analytics'),
'actions' => [
[
'label' => t('Refresh data'),
'type' => 'button',
'name' => 'analytics-refresh',
'class' => 'secondary outline',
],
],
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<div class="helpdesk-analytics-dashboard">
<div class="helpdesk-analytics-toolbar">
<div class="helpdesk-segment-control" id="analytics-period-selector">
<?php foreach ($periods as $p): ?>
<input type="radio" name="analytics-period" id="analytics-period-<?php e($p); ?>" value="<?php e($p); ?>"<?php if ($p === 90): ?> checked<?php endif; ?>>
<label for="analytics-period-<?php e($p); ?>"><?php e($periodLabels[$p]); ?></label>
<?php endforeach; ?>
</div>
<p class="helpdesk-analytics-threshold-note">
<?php e(t('No-contact threshold')); ?>:
<strong><?php e($inactivityDays); ?> <?php e(t('days')); ?></strong>
</p>
</div>
<div id="analytics-loading">
<div class="app-tiles">
<?php for ($i = 0; $i < 4; $i++): ?>
<div class="helpdesk-skeleton" style="height: 5.5rem; border-radius: var(--app-radius, 0.375rem);"></div>
<?php endfor; ?>
</div>
</div>
<div id="analytics-error" class="notice" data-variant="error" role="alert" hidden>
<p><?php e(t('Could not load customer analytics.')); ?></p>
</div>
<div id="analytics-truncated" class="notice" data-variant="warning" role="status" hidden>
<p><?php e(t('Data may be incomplete. Not all tickets could be loaded.')); ?></p>
</div>
<div id="analytics-content" hidden>
<div id="analytics-tiles" class="app-tiles"></div>
<section class="helpdesk-analytics-block" aria-labelledby="analytics-silent-heading">
<header class="helpdesk-analytics-block-header">
<h2 id="analytics-silent-heading"><?php e(t('Customers without recent contact')); ?></h2>
<p class="muted"><?php e(t('Longest silence first — consider reaching out.')); ?></p>
</header>
<div id="analytics-silent-empty" class="notice" data-variant="info" role="status" hidden>
<p><?php e(t('Every customer has been in contact recently.')); ?></p>
</div>
<div class="app-list-table">
<table class="helpdesk-analytics-table" id="analytics-silent-table" hidden>
<thead>
<tr>
<th><?php e(t('Customer')); ?></th>
<th><?php e(t('Last contact')); ?></th>
<th class="num"><?php e(t('Tickets in period')); ?></th>
</tr>
</thead>
<tbody id="analytics-silent-body"></tbody>
</table>
</div>
</section>
<section class="helpdesk-analytics-block" aria-labelledby="analytics-top-heading">
<header class="helpdesk-analytics-block-header">
<h2 id="analytics-top-heading"><?php e(t('Top customers by communication')); ?></h2>
<p class="muted"><?php e(t('Most ticket activity in the selected period.')); ?></p>
</header>
<div id="analytics-top-empty" class="notice" data-variant="info" role="status" hidden>
<p><?php e(t('No communication recorded in the selected period.')); ?></p>
</div>
<div class="app-list-table">
<table class="helpdesk-analytics-table" id="analytics-top-table" hidden>
<thead>
<tr>
<th><?php e(t('Customer')); ?></th>
<th class="num"><?php e(t('Tickets in period')); ?></th>
<th><?php e(t('Last contact')); ?></th>
</tr>
</thead>
<tbody id="analytics-top-body"></tbody>
</table>
</div>
</section>
</div>
</div>
</section>
</div>

View File

@@ -72,19 +72,18 @@ $hasDiff = $fieldDiff !== [];
</label>
<select
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>[]"
multiple
size="5"
name="field_<?php e($key); ?>"
<?php if ($required): ?> aria-required="true"<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
>
<option value=""><?php e(t('Please select...')); ?></option>
<?php foreach ($tenantUsers as $u):
$uId = (string) ($u['u']['id'] ?? $u['id'] ?? '');
$uName = trim((string) ($u['u']['display_name'] ?? $u['display_name'] ?? ''));
$uEmail = (string) ($u['u']['email'] ?? $u['email'] ?? '');
$uLabel = $uName !== '' ? $uName : $uEmail;
?>
<option value="<?php e($uId); ?>"<?php if (in_array($uId, $selectedIds, true)): ?> selected<?php endif; ?>>
<option value="<?php e($uId); ?>"<?php if ($value === $uId): ?> selected<?php endif; ?>>
<?php e($uLabel); ?>
</option>
<?php endforeach; ?>

View File

@@ -258,8 +258,7 @@ if ($step === $protocolStep) {
if ($type === 'checkbox') {
$fieldValues[$key] = $request->body('field_' . $key, '') !== '' ? '1' : '0';
} elseif ($type === 'user-select') {
$ids = $request->body('field_' . $key, []);
$fieldValues[$key] = json_encode(array_values(array_filter((array) $ids)));
$fieldValues[$key] = (string) $request->body('field_' . $key, '');
} else {
$fieldValues[$key] = (string) $request->body('field_' . $key, '');
}

View File

@@ -149,8 +149,7 @@ if ($request->isMethod('POST') && $canEdit) {
if ($type === 'checkbox') {
$submittedValues[$key] = $request->body('field_' . $key, '') !== '' ? '1' : '0';
} elseif ($type === 'user-select') {
$ids = $request->body('field_' . $key, []);
$submittedValues[$key] = json_encode(array_values(array_filter((array) $ids)));
$submittedValues[$key] = (string) $request->body('field_' . $key, '');
} else {
$submittedValues[$key] = (string) $request->body('field_' . $key, '');
}

View File

@@ -193,6 +193,10 @@ if ($request->isMethod('POST')) {
],
]);
if (isset($post['inactivity_threshold_days']) && is_numeric($post['inactivity_threshold_days'])) {
$settingsGateway->setInactivityThresholdDays((int) $post['inactivity_threshold_days']);
}
$tokenRepository->deleteAll();
$action = trim((string) ($post['action'] ?? 'save'));
@@ -235,6 +239,8 @@ $controllingConfig = is_array($controllingConfigEnvelope['config'] ?? null)
: [];
$controllingConfigSource = (string) ($controllingConfigEnvelope['source'] ?? 'default');
$inactivityThresholdDays = $settingsGateway->getInactivityThresholdDays();
// Load tenant override values
$tenantOverrideEnabled = false;
$tenantRow = null;

View File

@@ -482,6 +482,19 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
</div>
</div>
</details>
<details class="app-details-card" name="helpdesk-customer-engagement" data-details-key="helpdesk-settings-customer-engagement-v1" open>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Customer engagement')); ?></span>
</summary>
<div class="app-details-card-container">
<label class="app-field" for="inactivity_threshold_days">
<span><?php e(t('No-contact threshold (days)')); ?></span>
<input type="number" id="inactivity_threshold_days" name="inactivity_threshold_days" min="7" max="365" value="<?php e((int) ($inactivityThresholdDays ?? 60)); ?>">
<small><?php e(t('Customers without any communication for longer than this are flagged in the customer analytics view.')); ?></small>
</label>
</div>
</details>
</div>
</div>
</form>

View File

@@ -14,12 +14,13 @@ $dashboardActive = navActive('helpdesk/dashboard', true);
$settingsActive = navActive('helpdesk/settings', true);
$teamActive = navActive('helpdesk/team', true);
$riskRadarActive = navActive('helpdesk/risk-radar', true);
$analyticsActive = navActive('helpdesk/analytics', true);
$domainsActive = navActive(['helpdesk/domains', 'helpdesk/domain'], true);
$softwareProductsActive = navActive('helpdesk/software-products', true);
$handoversActive = navActive('helpdesk/handovers', true);
$updatesActive = navActive('helpdesk/updates', true);
$customersActive = navActive('helpdesk', true);
if ($dashboardActive['isActive'] || $settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive'] || $handoversActive['isActive'] || $updatesActive['isActive']) {
if ($dashboardActive['isActive'] || $settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $analyticsActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive'] || $handoversActive['isActive'] || $updatesActive['isActive']) {
$customersActive = ['class' => '', 'aria' => '', 'isActive' => false];
}
@@ -73,6 +74,12 @@ $helpdeskNavGroups = [
'active' => $riskRadarActive,
'visible' => $canViewRiskRadar,
],
[
'label' => t('Customer analytics'),
'path' => 'helpdesk/analytics',
'active' => $analyticsActive,
'visible' => $canViewRiskRadar,
],
],
],
[

View File

@@ -0,0 +1,183 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Service\CustomerEngagementService;
use PHPUnit\Framework\TestCase;
class CustomerEngagementServiceTest extends TestCase
{
// Fixed "now" so tests are deterministic: 2026-06-25 12:00:00 UTC.
private const NOW_TS = 1782475200;
/**
* @return array<string, mixed>
*/
private static function ticket(
string $no,
string $customerNo,
string $customerName,
int $createdDaysAgo,
?int $lastActivityDaysAgo = null
): array {
$createdTs = self::NOW_TS - ($createdDaysAgo * 86400);
$activityTs = $lastActivityDaysAgo !== null
? self::NOW_TS - ($lastActivityDaysAgo * 86400)
: $createdTs;
return [
'No' => $no,
'Customer_No' => $customerNo,
'Cust_Name' => $customerName,
'Created_On' => gmdate('Y-m-d\TH:i:s\Z', $createdTs),
'Last_Activity_Date' => gmdate('Y-m-d\TH:i:s\Z', $activityTs),
'Ticket_State' => 'Offen',
];
}
public function testEmptyTicketsReturnsEmptyResult(): void
{
$result = CustomerEngagementService::buildEngagement([], 90, 60, self::NOW_TS);
$this->assertSame(0, $result['summary']['total_customers']);
$this->assertSame(0, $result['summary']['active']);
$this->assertSame(0, $result['summary']['silent']);
$this->assertSame([], $result['silent']);
$this->assertSame([], $result['top_communication']);
}
public function testSkipsTicketsWithoutCustomerNo(): void
{
$tickets = [
self::ticket('T1', '', 'No Customer', 5),
self::ticket('T2', '10610', 'Valid', 5),
];
$result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS);
$this->assertSame(1, $result['summary']['total_customers']);
}
public function testAggregatesTicketCountsPerCustomer(): void
{
$tickets = [
self::ticket('T1', '10610', 'Kunde A', 5),
self::ticket('T2', '10610', 'Kunde A', 10),
self::ticket('T3', '10620', 'Kunde B', 3),
];
$result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS);
$this->assertSame(2, $result['summary']['total_customers']);
$top = $result['top_communication'];
$byCustomer = [];
foreach ($top as $row) {
$byCustomer[$row['customer_no']] = $row;
}
$this->assertSame(2, $byCustomer['10610']['ticket_count_period']);
$this->assertSame(1, $byCustomer['10620']['ticket_count_period']);
}
public function testActiveCustomerWithinThreshold(): void
{
// Last contact 10 days ago, threshold 60 => active.
$tickets = [self::ticket('T1', '10610', 'Kunde A', 10)];
$result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS);
$this->assertSame(1, $result['summary']['active']);
$this->assertSame(0, $result['summary']['silent']);
$this->assertSame([], $result['silent']);
}
public function testSilentCustomerBeyondThreshold(): void
{
// Last contact 90 days ago, threshold 60 => silent.
$tickets = [self::ticket('T1', '10610', 'Kunde A', 90)];
$result = CustomerEngagementService::buildEngagement($tickets, 365, 60, self::NOW_TS);
$this->assertSame(0, $result['summary']['active']);
$this->assertSame(1, $result['summary']['silent']);
$this->assertCount(1, $result['silent']);
$this->assertSame('10610', $result['silent'][0]['customer_no']);
$this->assertSame('silent', $result['silent'][0]['status']);
$this->assertSame(90, $result['silent'][0]['days_since_contact']);
}
public function testLastContactUsesMostRecentActivity(): void
{
// Two tickets: one created 100 days ago but updated 5 days ago, one created 50 days ago.
// Most recent activity is 5 days ago => active.
$tickets = [
self::ticket('T1', '10610', 'Kunde A', 100, 5),
self::ticket('T2', '10610', 'Kunde A', 50, 50),
];
$result = CustomerEngagementService::buildEngagement($tickets, 365, 60, self::NOW_TS);
$this->assertSame(1, $result['summary']['active']);
$row = $result['top_communication'][0];
$this->assertSame(5, $row['days_since_contact']);
}
public function testSilentListSortedByLongestSilenceFirst(): void
{
$tickets = [
self::ticket('T1', '10610', 'Recently Silent', 70),
self::ticket('T2', '10620', 'Long Silent', 200),
self::ticket('T3', '10630', 'Mid Silent', 120),
];
$result = CustomerEngagementService::buildEngagement($tickets, 365, 60, self::NOW_TS);
$order = array_column($result['silent'], 'customer_no');
$this->assertSame(['10620', '10630', '10610'], $order);
}
public function testTopCommunicationExcludesCustomersWithNoPeriodActivity(): void
{
// Ticket older than the 30-day period => not counted in top list.
$tickets = [
self::ticket('T1', '10610', 'Active', 5),
self::ticket('T2', '10620', 'Old', 100),
];
$result = CustomerEngagementService::buildEngagement($tickets, 30, 60, self::NOW_TS);
$tops = array_column($result['top_communication'], 'customer_no');
$this->assertContains('10610', $tops);
$this->assertNotContains('10620', $tops);
}
public function testIgnoresUninitializedBcDates(): void
{
// BC sentinel date 0001-01-01 must be treated as "no contact".
$tickets = [[
'No' => 'T1',
'Customer_No' => '10610',
'Cust_Name' => 'Kunde A',
'Created_On' => '0001-01-01T00:00:00Z',
'Last_Activity_Date' => '0001-01-01T00:00:00Z',
'Ticket_State' => 'Offen',
]];
$result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS);
$this->assertSame(1, $result['summary']['no_contact_ever']);
$this->assertNull($result['silent'][0]['last_contact_ts']);
$this->assertSame('no_contact_ever', $result['silent'][0]['status']);
}
public function testMetaReflectsInputs(): void
{
$result = CustomerEngagementService::buildEngagement([], 180, 45, self::NOW_TS, true);
$this->assertSame(180, $result['meta']['period_days']);
$this->assertSame(45, $result['meta']['inactivity_days']);
$this->assertTrue($result['meta']['truncated']);
$this->assertSame('medium', $result['meta']['confidence']);
$this->assertSame(self::NOW_TS, $result['meta']['generated_ts']);
}
}

View File

@@ -78,6 +78,53 @@ class HelpdeskSettingsGatewayTest extends TestCase
$this->assertNull($gateway->getBasicPassword());
}
public function testGetInactivityThresholdDaysDefaultsWhenUnset(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn(null);
$gateway = $this->createGateway($settings);
$this->assertSame(
HelpdeskSettingsGateway::DEFAULT_INACTIVITY_THRESHOLD_DAYS,
$gateway->getInactivityThresholdDays()
);
}
public function testGetInactivityThresholdDaysClampsToRange(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn('999');
$gateway = $this->createGateway($settings);
// Clamped to the max (365).
$this->assertSame(365, $gateway->getInactivityThresholdDays());
}
public function testGetInactivityThresholdDaysFallsBackOnNonNumeric(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn('not-a-number');
$gateway = $this->createGateway($settings);
$this->assertSame(
HelpdeskSettingsGateway::DEFAULT_INACTIVITY_THRESHOLD_DAYS,
$gateway->getInactivityThresholdDays()
);
}
public function testSetInactivityThresholdDaysClampsAndPersists(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->once())
->method('set')
->with(HelpdeskSettingsGateway::KEY_INACTIVITY_THRESHOLD_DAYS, '7', 'helpdesk.inactivity_threshold_days')
->willReturn(true);
$gateway = $this->createGateway($settings);
// 1 is below the minimum (7) => clamped up.
$this->assertTrue($gateway->setInactivityThresholdDays(1));
}
public function testSetBasicPasswordEncryptsAndPersists(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);

View File

@@ -3133,4 +3133,77 @@
vertical-align: middle;
color: var(--app-muted, #6c757d);
}
/* --- Customer analytics --- */
.helpdesk-analytics-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: calc(var(--app-spacing) * 0.5);
margin-bottom: calc(var(--app-spacing) * 0.75);
}
.helpdesk-analytics-threshold-note {
margin: 0;
font-size: var(--text-sm, 0.875rem);
color: var(--app-muted, #6c757d);
}
/* Stat tiles here are read-only (rendered as <div>), so suppress the link affordances. */
.helpdesk-analytics-tile {
cursor: default;
}
.helpdesk-analytics-tile:hover {
box-shadow: none;
}
.helpdesk-analytics-block {
margin-top: calc(var(--app-spacing) * 1.5);
}
.helpdesk-analytics-block-header {
margin-bottom: calc(var(--app-spacing) * 0.5);
}
.helpdesk-analytics-block-header h2 {
margin: 0;
font-size: var(--text-lg, 1.125rem);
}
.helpdesk-analytics-block-header .muted {
margin: 0.15rem 0 0;
font-size: var(--text-sm, 0.875rem);
color: var(--app-muted, #6c757d);
}
.helpdesk-analytics-table {
width: 100%;
border-collapse: collapse;
}
.helpdesk-analytics-table th,
.helpdesk-analytics-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--app-border, #dee2e6);
text-align: left;
vertical-align: middle;
}
.helpdesk-analytics-table th.num,
.helpdesk-analytics-table td.num {
text-align: right;
white-space: nowrap;
}
.helpdesk-analytics-cust-no {
color: var(--app-muted, #6c757d);
font-size: var(--text-sm, 0.875rem);
}
.helpdesk-analytics-never {
color: var(--app-muted, #6c757d);
font-style: italic;
}
}

View File

@@ -0,0 +1,218 @@
/**
* Customer analytics — engagement overview (active vs. silent customers,
* top communication). Reads the global ticket snapshot aggregated server-side.
*/
import { getJson } from '/js/core/app-http.js';
import { resolveHost } from '/js/core/app-dom.js';
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveContainer = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-analytics"]')) {
return host;
}
return host.querySelector('.app-details-container[data-app-component="helpdesk-analytics"]');
};
export function initHelpdeskAnalytics(root = document) {
const container = resolveContainer(root);
if (!container) {
return EMPTY_API;
}
const listenerController = new AbortController();
const requestController = new AbortController();
const on = (target, type, handler, options = {}) => {
if (!target || typeof target.addEventListener !== 'function') {
return;
}
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
};
const dataUrl = container.dataset.analyticsUrl;
const debitorBaseUrl = container.dataset.debitorBaseUrl || '';
const d = (key, fallback) => container.dataset[key] || fallback;
const elLoading = document.getElementById('analytics-loading');
const elError = document.getElementById('analytics-error');
const elTruncated = document.getElementById('analytics-truncated');
const elContent = document.getElementById('analytics-content');
const elTiles = document.getElementById('analytics-tiles');
const elSilentTable = document.getElementById('analytics-silent-table');
const elSilentBody = document.getElementById('analytics-silent-body');
const elSilentEmpty = document.getElementById('analytics-silent-empty');
const elTopTable = document.getElementById('analytics-top-table');
const elTopBody = document.getElementById('analytics-top-body');
const elTopEmpty = document.getElementById('analytics-top-empty');
const elRefresh = container.querySelector('button[name="analytics-refresh"]');
const periodSelector = document.getElementById('analytics-period-selector');
let currentPeriod = 90;
const showState = (state) => {
if (elLoading) elLoading.hidden = state !== 'loading';
if (elError) elError.hidden = state !== 'error';
if (elContent) elContent.hidden = state !== 'success';
};
const h = (tag, cls, text) => {
const element = document.createElement(tag);
if (cls) element.className = cls;
if (text !== undefined) element.textContent = text;
return element;
};
const customerCell = (row) => {
const td = h('td');
const name = row.customer_name || row.customer_no;
if (debitorBaseUrl && row.customer_no) {
const link = h('a', '', name);
link.href = debitorBaseUrl + encodeURIComponent(row.customer_no);
td.appendChild(link);
} else {
td.textContent = name;
}
if (row.customer_name && row.customer_no) {
td.appendChild(h('span', 'helpdesk-analytics-cust-no', ` ${row.customer_no}`));
}
return td;
};
const lastContactText = (row) => {
if (row.days_since_contact === null || row.days_since_contact === undefined) {
return d('labelNeverShort', 'never');
}
if (row.days_since_contact === 0) {
return row.last_contact_iso || '';
}
return `${row.last_contact_iso || ''} · ${row.days_since_contact} ${d('labelDaysAgo', 'days ago')}`;
};
// Mirror templates/partials/app-tile.phtml so the .app-tile CSS applies, but
// render a non-navigating <div> — these are stat tiles, not links.
const buildTile = ({ icon, tone, count, label }) => {
const tile = h('div', 'app-tile helpdesk-analytics-tile');
if (tone) tile.dataset.tone = tone;
const iconWrap = h('span', 'app-tile-icon');
iconWrap.appendChild(h('i', `bi ${icon}`));
tile.appendChild(iconWrap);
tile.appendChild(h('span', 'app-tile-count', String(count)));
tile.appendChild(h('span', 'app-tile-label', label));
return tile;
};
const renderTiles = (summary) => {
if (!elTiles) return;
elTiles.replaceChildren();
elTiles.appendChild(buildTile({
icon: 'bi-people', tone: 'blue',
count: summary.total_customers || 0, label: d('labelTotal', 'Customers'),
}));
elTiles.appendChild(buildTile({
icon: 'bi-chat-dots', tone: 'emerald',
count: summary.active || 0, label: d('labelActive', 'Active'),
}));
elTiles.appendChild(buildTile({
icon: 'bi-exclamation-triangle', tone: 'amber',
count: summary.silent || 0, label: d('labelSilent', 'No contact'),
}));
elTiles.appendChild(buildTile({
icon: 'bi-question-circle', tone: 'neutral',
count: summary.no_contact_ever || 0, label: d('labelNever', 'Never in contact'),
}));
};
const renderSilent = (rows) => {
if (!elSilentBody || !elSilentTable || !elSilentEmpty) return;
elSilentBody.replaceChildren();
if (!rows || rows.length === 0) {
elSilentTable.hidden = true;
elSilentEmpty.hidden = false;
return;
}
elSilentEmpty.hidden = true;
elSilentTable.hidden = false;
for (const row of rows) {
const tr = h('tr');
tr.appendChild(customerCell(row));
const contactTd = h('td', '', lastContactText(row));
if (row.status === 'no_contact_ever') {
contactTd.classList.add('helpdesk-analytics-never');
}
tr.appendChild(contactTd);
tr.appendChild(h('td', 'num', String(row.ticket_count_period || 0)));
elSilentBody.appendChild(tr);
}
};
const renderTop = (rows) => {
if (!elTopBody || !elTopTable || !elTopEmpty) return;
elTopBody.replaceChildren();
if (!rows || rows.length === 0) {
elTopTable.hidden = true;
elTopEmpty.hidden = false;
return;
}
elTopEmpty.hidden = true;
elTopTable.hidden = false;
for (const row of rows) {
const tr = h('tr');
tr.appendChild(customerCell(row));
tr.appendChild(h('td', 'num', String(row.ticket_count_period || 0)));
tr.appendChild(h('td', '', lastContactText(row)));
elTopBody.appendChild(tr);
}
};
const fetchData = async (refresh = false) => {
showState('loading');
if (elTruncated) elTruncated.hidden = true;
const params = new URLSearchParams({ periodDays: String(currentPeriod) });
if (refresh) params.set('refresh', '1');
try {
const json = await getJson(`${dataUrl}?${params.toString()}`, {
signal: requestController.signal,
});
if (!json.ok) { showState('error'); return; }
renderTiles(json.summary || {});
renderSilent(json.silent || []);
renderTop(json.top_communication || []);
showState('success');
if (json.meta && json.meta.truncated && elTruncated) {
elTruncated.hidden = false;
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
showState('error');
}
};
on(periodSelector, 'change', (event) => {
const radio = event.target.closest('input[name="analytics-period"]');
if (!radio) return;
const period = parseInt(radio.value, 10);
if (!period || period === currentPeriod) return;
currentPeriod = period;
void fetchData();
});
on(elRefresh, 'click', () => {
void fetchData(true);
});
void fetchData();
return {
destroy: () => {
listenerController.abort();
requestController.abort();
},
};
}

View File

@@ -36,15 +36,19 @@ lib/Module/Security/
SecurityBcSettingsGateway.php — global BC connection settings (core settings table, encrypted)
SecurityOAuthTokenService.php — OAuth2 token acquire + encrypted cache
SecurityBcGateway.php — thin BC OData (ONLY customer-search + domains-for-customer)
SecurityReportPdfService.php — customer PDF report (Dompdf); pure context builder + render
SecurityReportPdfService.php — customer PDF report (Dompdf); picks custom template or built-in PHTML default
SecurityReportSettingsGateway.php— report HTML/CSS template (core settings table, clear text)
SecurityReportTemplateService.php— {{var}} substitution into the admin template + default scaffold (pure)
SecurityReportLogoService.php — report logo upload/serve (storage/security/report-logo, ImageUploadTrait)
pages/security/ — actions (*.php) + views (*.phtml)
checks/report($id).php — streams the customer report PDF (gated on steps 15 complete)
report-design/ — report look config: logo upload + HTML/CSS template editor; logo-file() serves the logo (settings.manage)
templates/aside-security-panel.phtml— sidebar navigation
templates/pdf/customer-report.phtml — customer report PDF template (hydrated by SecurityReportPdfService)
templates/pdf/customer-report.phtml — built-in default report layout (used when no custom template is stored)
i18n/default_de.json + default_en.json — keep both in sync (identical key sets)
migrations/ — 003 SQL files (module:migrate)
web/css/security.css | web/js/ — module styles + runtime components + page scripts
tests/Module/Security/Service/ — 7 test files (happy path + edge case)
tests/Module/Security/Service/ — 10 test files (happy path + edge case)
```
---
@@ -87,9 +91,13 @@ template edits never mutate in-flight checks. JSON state shapes:
Main menu entry via the `aside.tab_panel` slot (icon `bi-shield-check`, permission `security.access`).
Pages: `security/checks` (list), `security/checks/create` (wizard), `security/checks/edit/{id}`
(workspace), `security/checks/report/{id}` (streams the customer PDF report),
`security/templates` (+create/edit/{id}), `security/settings`, plus data endpoints
`security/templates` (+create/edit/{id}), `security/settings`, `security/report-design`
(customer report look: logo + HTML/CSS template), plus data endpoints
`security/checks-data`, `security/templates-data`, `security/customer-search-data`,
`security/customer-domains-data`, `security/settings/test-connection-data`.
`security/customer-domains-data`, `security/settings/test-connection-data`,
`security/report-design/logo-file` (serves the uploaded report logo).
`report-design` reuses the `security.settings.manage` permission (no new permission key).
## JS runtime components

View File

@@ -55,7 +55,35 @@
"Reopen check": "Check wieder öffnen",
"Delete check": "Check löschen",
"Delete this security check? This cannot be undone.": "Diesen Security Check löschen? Dies kann nicht rückgängig gemacht werden.",
"Archive this security check? It will become read-only.": "Diesen Security Check archivieren? Er wird schreibgeschützt.",
"This security check is archived and read-only.": "Dieser Security Check ist archiviert und schreibgeschützt.",
"Report design": "Berichtsdesign",
"Customer report design": "Kundenbericht-Design",
"Configure how the customer security-check report PDF looks: upload a logo and edit the HTML/CSS template that is rendered to the PDF.": "Legen Sie das Aussehen des Kunden-Security-Check-Berichts (PDF) fest: Logo hochladen und die HTML/CSS-Vorlage bearbeiten, die ins PDF gerendert wird.",
"Report logo": "Berichtslogo",
"Report logo removed": "Berichtslogo entfernt",
"Current logo": "Aktuelles Logo",
"Delete this logo?": "Dieses Logo löschen?",
"Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.": "Erlaubte Dateitypen: SVG, PNG, JPG, WEBP. Max. 5 MB.",
"Template (HTML & CSS)": "Vorlage (HTML & CSS)",
"Leave empty to use the built-in default template. Scripts and remote resources are not executed.": "Leer lassen, um die integrierte Standardvorlage zu verwenden. Skripte und externe Ressourcen werden nicht ausgeführt.",
"The template is too large.": "Die Vorlage ist zu groß.",
"Available variables": "Verfügbare Variablen",
"These placeholders are replaced when the report is generated:": "Diese Platzhalter werden beim Erstellen des Berichts ersetzt:",
"Logo image source (data URI) — use in <img src=\"{{logo_src}}\">": "Logo-Bildquelle (Data-URI) verwenden in <img src=\"{{logo_src}}\">",
"Report title": "Berichtstitel",
"Customer name": "Kundenname",
"Customer number": "Kundennummer",
"Responsible person": "Verantwortliche Person",
"Official customer report": "Offizieller Kundenbericht",
"Official customer report text (written by the owner on the report step)": "Offizieller Kundenbericht-Text (vom Verantwortlichen im Berichtsschritt verfasst)",
"Completion percentage": "Fortschritt in Prozent",
"Completed technical checks": "Abgeschlossene technische Prüfungen",
"Total technical checks": "Technische Prüfungen gesamt",
"Generation date": "Erstellungsdatum",
"Application name": "Anwendungsname",
"Technical checklist table (HTML)": "Technische Checklisten-Tabelle (HTML)",
"Process steps table (HTML)": "Prozessschritte-Tabelle (HTML)",
"This product has no technical checklist items defined yet.": "Für dieses Produkt sind noch keine technischen Checklisten-Punkte definiert.",
"%d of %d completed": "%d von %d erledigt",
"Order received": "Beauftragung",

View File

@@ -55,7 +55,35 @@
"Reopen check": "Reopen check",
"Delete check": "Delete check",
"Delete this security check? This cannot be undone.": "Delete this security check? This cannot be undone.",
"Archive this security check? It will become read-only.": "Archive this security check? It will become read-only.",
"This security check is archived and read-only.": "This security check is archived and read-only.",
"Report design": "Report design",
"Customer report design": "Customer report design",
"Configure how the customer security-check report PDF looks: upload a logo and edit the HTML/CSS template that is rendered to the PDF.": "Configure how the customer security-check report PDF looks: upload a logo and edit the HTML/CSS template that is rendered to the PDF.",
"Report logo": "Report logo",
"Report logo removed": "Report logo removed",
"Current logo": "Current logo",
"Delete this logo?": "Delete this logo?",
"Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.": "Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.",
"Template (HTML & CSS)": "Template (HTML & CSS)",
"Leave empty to use the built-in default template. Scripts and remote resources are not executed.": "Leave empty to use the built-in default template. Scripts and remote resources are not executed.",
"The template is too large.": "The template is too large.",
"Available variables": "Available variables",
"These placeholders are replaced when the report is generated:": "These placeholders are replaced when the report is generated:",
"Logo image source (data URI) — use in <img src=\"{{logo_src}}\">": "Logo image source (data URI) — use in <img src=\"{{logo_src}}\">",
"Report title": "Report title",
"Customer name": "Customer name",
"Customer number": "Customer number",
"Responsible person": "Responsible person",
"Official customer report": "Official customer report",
"Official customer report text (written by the owner on the report step)": "Official customer report text (written by the owner on the report step)",
"Completion percentage": "Completion percentage",
"Completed technical checks": "Completed technical checks",
"Total technical checks": "Total technical checks",
"Generation date": "Generation date",
"Application name": "Application name",
"Technical checklist table (HTML)": "Technical checklist table (HTML)",
"Process steps table (HTML)": "Process steps table (HTML)",
"This product has no technical checklist items defined yet.": "This product has no technical checklist items defined yet.",
"%d of %d completed": "%d of %d completed",
"Order received": "Order received",

View File

@@ -15,7 +15,10 @@ use MintyPHP\Module\Security\Service\SecurityCheckService;
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
use MintyPHP\Module\Security\Service\SecurityMarkdownGateway;
use MintyPHP\Module\Security\Service\SecurityOAuthTokenService;
use MintyPHP\Module\Security\Service\SecurityReportLogoService;
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Branding\BrandingLogoService;
use MintyPHP\Service\Settings\SettingServicesFactory;
@@ -71,10 +74,22 @@ final class SecurityContainerRegistrar implements ContainerRegistrar
$c->get(SecurityCheckProcess::class)
));
// --- Customer report presentation (logo + HTML/CSS template) ---
$container->set(SecurityReportSettingsGateway::class, static fn (AppContainer $c): SecurityReportSettingsGateway => new SecurityReportSettingsGateway(
$c->get(SettingsMetadataGateway::class)
));
$container->set(SecurityReportTemplateService::class, static fn (): SecurityReportTemplateService => new SecurityReportTemplateService());
$container->set(SecurityReportLogoService::class, static fn (): SecurityReportLogoService => new SecurityReportLogoService());
$container->set(SecurityReportPdfService::class, static fn (AppContainer $c): SecurityReportPdfService => new SecurityReportPdfService(
$c->get(SecurityCheckProcess::class),
$c->get(BrandingLogoService::class),
$c->get(TenantLogoService::class)
$c->get(TenantLogoService::class),
$c->get(SecurityReportSettingsGateway::class),
$c->get(SecurityReportTemplateService::class),
$c->get(SecurityReportLogoService::class)
));
}
}

View File

@@ -32,10 +32,13 @@ final class SecurityCheckProcess
public const FIELD_TEXTAREA = 'textarea';
public const FIELD_DATETIME = 'datetime';
/** Field key of the official, customer-facing report written on the final step. */
public const FIELD_CUSTOMER_REPORT = 'customer_report';
/**
* Ordered fixed process steps.
*
* @return list<array{key: string, label: string, description: string, kind: string, fields?: list<array{key: string, label: string, type: string, required: bool}>}>
* @return list<array{key: string, label: string, description: string, kind: string, fields?: list<array{key: string, label: string, type: string, required: bool, rows?: int}>}>
*/
public function steps(): array
{
@@ -86,10 +89,13 @@ final class SecurityCheckProcess
'kind' => self::KIND_TECH_CHECKLIST,
],
[
'key' => 'report',
'key' => self::STEP_REPORT,
'label' => 'Create report & notify customer',
'description' => 'Create the report for the customer and notify them.',
'kind' => self::KIND_STEP,
'fields' => [
['key' => self::FIELD_CUSTOMER_REPORT, 'label' => 'Official customer report', 'type' => self::FIELD_TEXTAREA, 'required' => true, 'rows' => 12],
],
],
];
}
@@ -114,7 +120,7 @@ final class SecurityCheckProcess
/**
* Structured fields captured on a given step (info textareas, datetimes, …).
*
* @return list<array{key: string, label: string, type: string, required: bool}>
* @return list<array{key: string, label: string, type: string, required: bool, rows?: int}>
*/
public function stepFields(string $stepKey): array
{

View File

@@ -0,0 +1,156 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Service\Image\ImageUploadTrait;
/**
* Stores and resolves the logo shown on the customer security-check report PDF.
*
* A single global asset (the module has no per-tenant settings), kept on the
* filesystem under storage/security/report-logo (GR-SEC-006: uploads live under
* storage/ only, are MIME-validated, and SVGs are sanitised). Mirrors the core
* BrandingLogoService pattern via ImageUploadTrait.
*
* @api Consumed by the report-design page (upload/preview/delete) and
* SecurityReportPdfService (logo embedding).
*/
final class SecurityReportLogoService
{
use ImageUploadTrait;
private const MAX_SIZE = 5242880; // 5 MB
private const SIZES = [64, 128, 256];
private const DEFAULT_SIZE = 256;
public function storageBase(): string
{
return self::imageStorageBase();
}
public function reportLogoDir(): string
{
return $this->storageBase() . '/security/report-logo';
}
public function findLogoPath(?int $size = null): ?string
{
$dir = $this->reportLogoDir();
if (!is_dir($dir)) {
return null;
}
if ($size) {
$variant = $this->findVariantPath($dir, $this->normalizeSize($size));
if ($variant) {
return $variant;
}
}
$defaultVariant = $this->findVariantPath($dir, self::DEFAULT_SIZE);
if ($defaultVariant) {
return $defaultVariant;
}
$original = self::imageFindOriginalPath($dir);
return $original ?: null;
}
public function hasLogo(): bool
{
$path = $this->findLogoPath();
return $path ? is_file($path) : false;
}
public function delete(): bool
{
$dir = $this->reportLogoDir();
if (!is_dir($dir)) {
return true;
}
$matches = array_merge(
glob($dir . '/logo-*.*') ?: [],
glob($dir . '/logo.*') ?: [],
glob($dir . '/original.*') ?: []
);
foreach ($matches as $file) {
if (is_file($file)) {
@unlink($file);
}
}
return true;
}
/**
* @param array<string, mixed> $file A single $_FILES entry (tmp_name, error, size)
* @return array{ok: bool, error?: string, path?: string, mime?: string}
*/
public function saveUpload(array $file): array
{
if (empty($file) || !isset($file['tmp_name'])) {
return ['ok' => false, 'error' => t('No file uploaded')];
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => t('Upload failed')];
}
if (($file['size'] ?? 0) > self::MAX_SIZE) {
return ['ok' => false, 'error' => t('File is too large')];
}
$tmpPath = (string) $file['tmp_name'];
$mime = $this->detectMime($tmpPath);
$isSvg = self::imageIsSvgUpload($mime, $tmpPath);
$ext = self::imageExtensionForMime($mime, $isSvg);
if (!$ext) {
return ['ok' => false, 'error' => t('Invalid image file')];
}
if ($isSvg && !self::imageIsSafeSvg($tmpPath)) {
return ['ok' => false, 'error' => t('Invalid image file')];
}
$dir = $this->reportLogoDir();
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
$this->delete();
$originalPath = $dir . '/original.' . $ext;
if (!move_uploaded_file($tmpPath, $originalPath)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
@chmod($originalPath, 0644);
// SVGs are embedded as-is; raster images get downscaled variants when GD is available.
$variantExt = function_exists('imagewebp') ? 'webp' : 'jpg';
if (!$isSvg && self::imageCanResize()) {
foreach (self::SIZES as $size) {
$target = $dir . '/logo-' . $size . '.' . $variantExt;
self::imageResizeAndFit($originalPath, $target, $size, $size, $variantExt);
}
}
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
}
public function detectMime(string $path): string
{
return self::imageDetectMime($path);
}
private function normalizeSize(int $size): int
{
return in_array($size, self::SIZES, true) ? $size : self::DEFAULT_SIZE;
}
// Multiple extensions may coexist (e.g. after a format change) — pick the freshest.
private function findVariantPath(string $dir, int $size): ?string
{
$matches = glob($dir . '/logo-' . $size . '.*');
if (!$matches) {
return null;
}
usort($matches, static fn ($a, $b) => filemtime($b) <=> filemtime($a));
return $matches[0];
}
}

View File

@@ -24,7 +24,10 @@ final class SecurityReportPdfService
public function __construct(
private readonly SecurityCheckProcess $process,
private readonly BrandingLogoService $brandingLogoService,
private readonly ?TenantLogoService $tenantLogoService = null
private readonly ?TenantLogoService $tenantLogoService = null,
private readonly ?SecurityReportSettingsGateway $reportSettings = null,
private readonly ?SecurityReportTemplateService $templateService = null,
private readonly ?SecurityReportLogoService $reportLogoService = null
) {
}
@@ -46,7 +49,7 @@ final class SecurityReportPdfService
$report['logo_data_uri'] = $this->resolveLogoDataUri((string) ($options['tenant_uuid'] ?? ''));
$report['generated_at'] = date('Y-m-d H:i');
$html = $this->renderTemplate($report);
$html = $this->renderReportHtml($report);
if ($html === '') {
return '';
}
@@ -103,12 +106,19 @@ final class SecurityReportPdfService
$progress = is_array($check['progress'] ?? null) ? $check['progress'] : [];
$status = (string) ($check['status'] ?? SecurityCheckProcess::STATUS_OPEN);
// The owner-authored, customer-facing report text captured on the final step.
$reportFields = is_array($processState[SecurityCheckProcess::STEP_REPORT]['fields'] ?? null)
? $processState[SecurityCheckProcess::STEP_REPORT]['fields']
: [];
return [
'title' => t('Security check report'),
'customer_report' => trim((string) ($reportFields[SecurityCheckProcess::FIELD_CUSTOMER_REPORT] ?? '')),
'customer_name' => (string) ($check['debitor_name'] ?? ''),
'customer_no' => (string) ($check['debitor_no'] ?? ''),
'domain' => (string) ($check['domain_url'] ?? ($check['domain_no'] ?? '')),
'product' => (string) ($check['product_name'] ?? ($check['product_code'] ?? '')),
'owner' => (string) ($check['owner_name'] ?? ''),
'status' => $status,
'status_label' => t(SecurityCheckProcess::statusLabel($status)),
'summary' => [
@@ -196,6 +206,26 @@ final class SecurityReportPdfService
return $sections;
}
/**
* Produce the report HTML. When a custom template is configured (and the
* collaborating services are wired), it is filled with the report context;
* otherwise the built-in PHTML default layout is used. Keeping the default
* on the proven PHTML path makes the feature purely additive.
*
* @param array<string, mixed> $report
*/
private function renderReportHtml(array $report): string
{
if ($this->reportSettings !== null && $this->templateService !== null) {
$template = $this->reportSettings->getTemplateHtml();
if ($template !== '') {
return $this->templateService->render($template, $report);
}
}
return $this->renderTemplate($report);
}
/**
* @param array<string, mixed> $report
*/
@@ -218,16 +248,25 @@ final class SecurityReportPdfService
}
/**
* Resolve a logo data URI: tenant light-logo → global branding logo →
* static brand asset → transparent pixel. Mirrors the core PDF pattern; the
* PDF has no theme context, so the light variant is always used.
* Resolve a logo data URI: configured report logo → tenant light-logo →
* global branding logo → static brand asset → transparent pixel. Mirrors the
* core PDF pattern; the PDF has no theme context, so the light variant is
* always used.
*/
private function resolveLogoDataUri(string $tenantUuid): string
{
$path = '';
$mime = '';
if ($this->tenantLogoService !== null && $tenantUuid !== '' && $this->tenantLogoService->hasLogo($tenantUuid, TenantLogoService::THEME_LIGHT)) {
if ($this->reportLogoService !== null && $this->reportLogoService->hasLogo()) {
$logoPath = $this->reportLogoService->findLogoPath(256);
if ($logoPath && is_file($logoPath)) {
$path = $logoPath;
$mime = $this->reportLogoService->detectMime($logoPath);
}
}
if ($path === '' && $this->tenantLogoService !== null && $tenantUuid !== '' && $this->tenantLogoService->hasLogo($tenantUuid, TenantLogoService::THEME_LIGHT)) {
$logoPath = $this->tenantLogoService->findLogoPath($tenantUuid, TenantLogoService::THEME_LIGHT, 128);
if ($logoPath && is_file($logoPath)) {
$path = $logoPath;

View File

@@ -0,0 +1,64 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
/**
* Global presentation settings for the customer security-check report PDF.
*
* Stores the admin-authored HTML/CSS report template in the core `settings`
* table (GR-CORE-011). The template is not a secret, so it is stored in clear
* text (unlike the BC credentials in SecurityBcSettingsGateway). Setting keys
* are prefixed with 'security.' to avoid cross-module collisions.
*
* The uploaded report logo is a binary asset and lives on the filesystem —
* see SecurityReportLogoService — not here.
*
* @api Consumed by the Security report-design page and SecurityReportPdfService.
*/
class SecurityReportSettingsGateway
{
public const KEY_TEMPLATE_HTML = 'security.report_template_html';
/** Soft cap so the settings row stays sane; a real template is a few KB. */
public const MAX_TEMPLATE_LENGTH = 200000;
public function __construct(
private readonly SettingsMetadataGateway $settingsMetadataGateway
) {
}
/**
* The stored custom template, or '' when none is configured (the PDF service
* then falls back to its built-in default layout).
*/
public function getTemplateHtml(): string
{
return trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_TEMPLATE_HTML) ?? ''));
}
public function hasCustomTemplate(): bool
{
return $this->getTemplateHtml() !== '';
}
/**
* Persist the template. Passing null or an empty/blank string clears it,
* restoring the built-in default. Returns false if the value exceeds the
* soft length cap.
*/
public function setTemplateHtml(?string $html): bool
{
$html = trim((string) ($html ?? ''));
if ($html !== '' && strlen($html) > self::MAX_TEMPLATE_LENGTH) {
return false;
}
return $this->settingsMetadataGateway->set(
self::KEY_TEMPLATE_HTML,
$html !== '' ? $html : null,
self::KEY_TEMPLATE_HTML
);
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace MintyPHP\Module\Security\Service;
/**
* Renders the admin-authored HTML/CSS report template into a final HTML string
* by substituting {{placeholder}} variables with values from the report context.
*
* Pure transform (no I/O): the consuming SecurityReportPdfService builds the
* context, this fills the template, Dompdf rasterises the result. Scalar values
* are HTML-escaped so customer/BC data can never break out of the surrounding
* markup; the {{checklist}}, {{process}} and {{logo_src}} variables are built
* here as trusted HTML / a data URI. Substitution is a single simultaneous pass
* (strtr), so an injected value can never be re-expanded as another placeholder.
*
* The template text itself is authored by a settings-manager (gated by
* security.settings.manage) and rendered with Dompdf's remote fetching and PHP
* execution disabled — it shapes a downloaded PDF, not an app web page.
*
* @api Consumed by SecurityReportPdfService and the report-design page (variable
* reference + default scaffold).
*/
final class SecurityReportTemplateService
{
/**
* Fill a template with the report context.
*
* @param array<string, mixed> $context Output of SecurityReportPdfService::buildReportContext()
* plus app_name / logo_data_uri / generated_at.
*/
public function render(string $template, array $context): string
{
return strtr($template, $this->buildVariableMap($context));
}
/**
* Placeholder => already-translated description, for the UI variable reference
* and to document the contract in one place. Keys are the bare token names
* (without braces); render() wraps them in {{ }}.
*
* @return array<string, string>
*/
public function availableVariables(): array
{
return [
'customer_report' => t('Official customer report text (written by the owner on the report step)'),
'logo_src' => t('Logo image source (data URI) — use in <img src="{{logo_src}}">'),
'title' => t('Report title'),
'customer_name' => t('Customer name'),
'customer_no' => t('Customer number'),
'domain' => t('Domain'),
'product' => t('Software product'),
'owner' => t('Responsible person'),
'status' => t('Status'),
'percent' => t('Completion percentage'),
'checks_done' => t('Completed technical checks'),
'checks_total' => t('Total technical checks'),
'generated_at' => t('Generation date'),
'app_name' => t('Application name'),
'checklist' => t('Technical checklist table (HTML)'),
'process' => t('Process steps table (HTML)'),
];
}
/**
* Build the {{token}} => value map. Scalars are HTML-escaped; structural
* variables are trusted HTML built from the (already escaped) context.
*
* @param array<string, mixed> $context
* @return array<string, string>
*/
private function buildVariableMap(array $context): array
{
$summary = is_array($context['summary'] ?? null) ? $context['summary'] : [];
$scalars = [
'logo_src' => (string) ($context['logo_data_uri'] ?? ''),
'title' => (string) ($context['title'] ?? ''),
'customer_name' => (string) ($context['customer_name'] ?? ''),
'customer_no' => (string) ($context['customer_no'] ?? ''),
'domain' => (string) ($context['domain'] ?? ''),
'product' => (string) ($context['product'] ?? ''),
'owner' => (string) ($context['owner'] ?? ''),
'status' => (string) ($context['status_label'] ?? ''),
'percent' => (string) (int) ($summary['percent'] ?? 0),
'checks_done' => (string) (int) ($summary['tech_done'] ?? 0),
'checks_total' => (string) (int) ($summary['tech_total'] ?? 0),
'generated_at' => (string) ($context['generated_at'] ?? ''),
'app_name' => (string) ($context['app_name'] ?? ''),
];
$map = [];
foreach ($scalars as $key => $value) {
$map['{{' . $key . '}}'] = $this->esc($value);
}
// Owner-authored prose: escape, then keep the author's line breaks.
$map['{{customer_report}}'] = nl2br($this->esc((string) ($context['customer_report'] ?? '')));
$map['{{checklist}}'] = $this->buildChecklistHtml(
is_array($context['tech_sections'] ?? null) ? $context['tech_sections'] : []
);
$map['{{process}}'] = $this->buildProcessHtml(
is_array($context['steps'] ?? null) ? $context['steps'] : []
);
return $map;
}
/**
* @param array<int, mixed> $sections
*/
private function buildChecklistHtml(array $sections): string
{
if ($sections === []) {
return '<p class="muted">' . $this->esc(t('This product has no technical checklist items defined yet.')) . '</p>';
}
$rows = '';
foreach ($sections as $section) {
if (!is_array($section)) {
continue;
}
$label = trim((string) ($section['label'] ?? ''));
if ($label !== '') {
$rows .= '<tr class="section-row"><td colspan="2">' . $this->esc($label) . '</td></tr>';
}
foreach ((is_array($section['items'] ?? null) ? $section['items'] : []) as $item) {
if (!is_array($item)) {
continue;
}
$state = !empty($item['done'])
? '<span class="ok">&#10003; ' . $this->esc(t('Completed')) . '</span>'
: '<span class="pending">' . $this->esc(t('Not completed')) . '</span>';
$note = trim((string) ($item['note'] ?? ''));
$noteHtml = $note !== '' ? '<div class="note">' . $this->esc($note) . '</div>' : '';
$rows .= '<tr><td class="state">' . $state . '</td><td>'
. $this->esc((string) ($item['label'] ?? '')) . $noteHtml . '</td></tr>';
}
}
return '<table class="checklist">' . $rows . '</table>';
}
/**
* @param array<int, mixed> $steps
*/
private function buildProcessHtml(array $steps): string
{
$rows = '';
foreach ($steps as $step) {
if (!is_array($step)) {
continue;
}
$state = !empty($step['done'])
? '<span class="ok">&#10003; ' . $this->esc(t('Completed')) . '</span>'
: '<span class="pending">' . $this->esc(t('Open')) . '</span>';
$completedAt = trim((string) ($step['completed_at'] ?? ''));
$when = $completedAt !== '' ? ' <span class="muted">&middot; ' . $this->esc($completedAt) . '</span>' : '';
$rows .= '<tr><td class="state">' . $state . '</td><td>'
. $this->esc((string) ($step['number'] ?? '')) . '. '
. $this->esc((string) ($step['label'] ?? '')) . $when . '</td></tr>';
}
return '<table class="checklist">' . $rows . '</table>';
}
/**
* A ready-to-edit starter template offered in the editor. Faithful to the
* built-in default layout so admins have a working baseline to customise.
*/
public function defaultTemplateHtml(): string
{
return <<<'HTML'
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
* { box-sizing: border-box; }
body { font-family: "DejaVu Sans", Arial, sans-serif; color: #1a1a1a; font-size: 12px; line-height: 1.45; margin: 0; }
h1 { font-size: 20px; margin: 0 0 2px; }
h2 { font-size: 14px; margin: 22px 0 8px; padding-bottom: 4px; border-bottom: 1px solid #e2e2e2; }
.muted { color: #6a6a6a; }
.header { border-bottom: 2px solid #1a1a1a; padding-bottom: 12px; margin-bottom: 16px; }
.header-logo { max-height: 48px; max-width: 200px; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; }
.meta td { padding: 3px 0; vertical-align: top; }
.meta td.label { color: #6a6a6a; width: 38%; }
.summary { margin: 6px 0 4px; font-size: 13px; }
.bar { height: 10px; background: #ececec; border-radius: 5px; overflow: hidden; margin-top: 6px; }
.bar-fill { height: 10px; background: #2e7d32; }
.checklist td { padding: 6px 8px; border-bottom: 1px solid #ededed; vertical-align: top; }
.checklist td.state { width: 110px; white-space: nowrap; }
.section-row td { background: #f5f5f5; font-weight: bold; padding: 6px 8px; }
.ok { color: #2e7d32; font-weight: bold; }
.pending { color: #b26a00; font-weight: bold; }
.note { color: #6a6a6a; font-size: 11px; margin-top: 2px; }
.footer { margin-top: 26px; padding-top: 8px; border-top: 1px solid #e2e2e2; color: #8a8a8a; font-size: 10px; }
</style>
</head>
<body>
<div class="header">
<img src="{{logo_src}}" alt="" class="header-logo">
<h1>{{title}}</h1>
<div class="muted">{{generated_at}}</div>
</div>
<table class="meta">
<tr><td class="label">Customer</td><td>{{customer_name}} ({{customer_no}})</td></tr>
<tr><td class="label">Domain</td><td>{{domain}}</td></tr>
<tr><td class="label">Software product</td><td>{{product}}</td></tr>
<tr><td class="label">Responsible</td><td>{{owner}}</td></tr>
<tr><td class="label">Status</td><td>{{status}}</td></tr>
</table>
<h2>Result</h2>
<div class="summary">{{checks_done}} / {{checks_total}} ({{percent}}%)</div>
<div class="bar"><div class="bar-fill" style="width: {{percent}}%;"></div></div>
<h2>Report</h2>
<div class="customer-report">{{customer_report}}</div>
<h2>Technical checklist</h2>
{{checklist}}
<h2>Process</h2>
{{process}}
<div class="footer">{{app_name}} &middot; {{title}} &middot; {{generated_at}}</div>
</body>
</html>
HTML;
}
private function esc(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}

View File

@@ -30,6 +30,8 @@ return [
['path' => 'security/templates/edit/{id}', 'target' => 'security/templates/edit'],
['path' => 'security/settings', 'target' => 'security/settings'],
['path' => 'security/settings/test-connection-data', 'target' => 'security/settings/test-connection-data'],
['path' => 'security/report-design', 'target' => 'security/report-design'],
['path' => 'security/report-design/logo-file', 'target' => 'security/report-design/logo-file'],
],
'public_paths' => [],

View File

@@ -186,7 +186,7 @@ $completionMeta = static function (array $entry) use ($userNames): string {
<?php if ($isDatetime): ?>
<input type="datetime-local" id="<?php e($fid); ?>" name="<?php e($fname); ?>" value="<?php e($fval); ?>"<?php if ($fieldRequired): ?> data-security-required="1"<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>>
<?php else: ?>
<textarea id="<?php e($fid); ?>" name="<?php e($fname); ?>" rows="2"<?php if ($fieldRequired): ?> data-security-required="1"<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>><?php e($fval); ?></textarea>
<textarea id="<?php e($fid); ?>" name="<?php e($fname); ?>" rows="<?php e((string) (int) ($field['rows'] ?? 2)); ?>"<?php if ($fieldRequired): ?> data-security-required="1"<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>><?php e($fval); ?></textarea>
<?php endif; ?>
</div>
<?php endforeach; ?>
@@ -240,14 +240,16 @@ $completionMeta = static function (array $entry) use ($userNames): string {
<?php if ($status === SecurityCheckProcess::STATUS_ARCHIVED): ?>
<button type="submit" name="action" value="unarchive" class="secondary outline small"><i class="bi bi-arrow-counterclockwise"></i> <?php e(t('Reopen check')); ?></button>
<?php else: ?>
<button type="submit" name="action" value="archive" class="secondary outline small"><i class="bi bi-archive"></i> <?php e(t('Archive check')); ?></button>
<button type="submit" name="action" value="archive" class="secondary outline small"
data-confirm-message="<?php e(t('Archive this security check? It will become read-only.')); ?>"
data-confirm-variant="warning"><i class="bi bi-archive"></i> <?php e(t('Archive check')); ?></button>
<?php endif; ?>
</form>
<form method="post"
data-detail-confirm-message="<?php e(t('Delete this security check? This cannot be undone.')); ?>"
data-detail-confirm-variant="danger">
<form method="post">
<?php \MintyPHP\Session::getCsrfInput(); ?>
<button type="submit" name="action" value="delete" class="danger outline small"><i class="bi bi-trash3"></i> <?php e(t('Delete check')); ?></button>
<button type="submit" name="action" value="delete" class="danger outline small"
data-confirm-message="<?php e(t('Delete this security check? This cannot be undone.')); ?>"
data-confirm-variant="danger"><i class="bi bi-trash3"></i> <?php e(t('Delete check')); ?></button>
</form>
</div>
</div>

View File

@@ -0,0 +1,84 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityReportLogoService;
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
$request = requestInput();
$reportSettings = app(SecurityReportSettingsGateway::class);
$logoService = app(SecurityReportLogoService::class);
$templateService = app(SecurityReportTemplateService::class);
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), 'security/report-design', 'csrf_expired');
Router::redirect('security/report-design');
return;
}
if ($request->isMethod('POST')) {
$action = (string) $request->body('action', 'save');
if ($action === 'delete_logo') {
$logoService->delete();
Flash::success(t('Report logo removed'), 'security/report-design', 'report_logo_removed');
Router::redirect('security/report-design');
return;
}
$errorBag = formErrors();
if (!$reportSettings->setTemplateHtml((string) $request->body('template_html', ''))) {
$errorBag->add('template_html', t('The template is too large.'));
}
// Only touch the logo when a file was actually submitted.
$upload = $request->filesAll()['logo'] ?? [];
if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$result = $logoService->saveUpload($upload);
if (!($result['ok'] ?? false)) {
$errorBag->addGlobal((string) ($result['error'] ?? t('Upload failed')));
}
}
if ($errorBag->hasAny()) {
flashFormErrors($errorBag, 'security/report-design', 'report_design_error');
} else {
Flash::success(t('Settings saved'), 'security/report-design', 'report_design_saved');
}
Router::redirect('security/report-design');
return;
}
$hasCustomTemplate = $reportSettings->hasCustomTemplate();
// Prefill the editor with the built-in default scaffold when nothing is stored,
// so the admin has a working baseline to customise.
$templateHtml = $hasCustomTemplate ? $reportSettings->getTemplateHtml() : $templateService->defaultTemplateHtml();
$variables = $templateService->availableVariables();
$hasLogo = $logoService->hasLogo();
$logoVersion = '';
if ($hasLogo) {
$logoPath = $logoService->findLogoPath(256);
$logoVersion = ($logoPath && is_file($logoPath)) ? (string) filemtime($logoPath) : '';
}
Buffer::set('title', t('Customer report design'));
Buffer::set('style_groups', json_encode(['security']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Security'), 'path' => 'security/checks'],
['label' => t('Customer report design')],
];

View File

@@ -0,0 +1,75 @@
<?php
$templateHtml = (string) ($templateHtml ?? '');
$variables = is_array($variables ?? null) ? $variables : [];
$hasLogo = (bool) ($hasLogo ?? false);
$logoVersion = (string) ($logoVersion ?? '');
$logoCurrentSrc = '';
if ($hasLogo) {
$logoCurrentSrc = lurl('security/report-design/logo-file') . '?size=256'
. ($logoVersion !== '' ? '&v=' . rawurlencode($logoVersion) : '');
}
?>
<div class="app-details-container">
<section data-tab-panel data-tab-panel-wide>
<?php
$titlebar = [
'title' => t('Customer report design'),
'actions' => [
['label' => t('Save'), 'type' => 'submit', 'form' => 'security-report-design-form', 'class' => 'primary', 'tone' => 'success'],
],
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<p class="app-muted"><?php e(t('Configure how the customer security-check report PDF looks: upload a logo and edit the HTML/CSS template that is rendered to the PDF.')); ?></p>
<div class="app-details-card">
<h3><?php e(t('Report logo')); ?></h3>
<?php
$fileUpload = [
'name' => 'logo',
'accept' => 'image/svg+xml,image/png,image/jpeg,image/webp',
'hint' => t('Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.'),
'currentSrc' => $logoCurrentSrc,
'currentLabel' => t('Current logo'),
'deleteConfirm' => t('Delete this logo?'),
'formId' => 'security-report-design-form',
'deleteFormId' => 'security-report-logo-delete-form',
];
require templatePath('partials/app-file-upload.phtml');
?>
</div>
<form method="post" id="security-report-design-form" enctype="multipart/form-data">
<div class="app-details-card">
<h3><?php e(t('Template (HTML & CSS)')); ?></h3>
<p class="app-muted"><?php e(t('Leave empty to use the built-in default template. Scripts and remote resources are not executed.')); ?></p>
<div class="security-template-editor">
<textarea name="template_html" rows="22" spellcheck="false" autocomplete="off" wrap="off"><?php e($templateHtml); ?></textarea>
</div>
<?php if ($variables !== []): ?>
<div class="security-variable-reference">
<h4><?php e(t('Available variables')); ?></h4>
<p class="app-muted"><?php e(t('These placeholders are replaced when the report is generated:')); ?></p>
<dl class="security-variable-list">
<?php foreach ($variables as $token => $description): ?>
<dt><code><?php e('{{' . $token . '}}'); ?></code></dt>
<dd><?php e((string) $description); ?></dd>
<?php endforeach; ?>
</dl>
</div>
<?php endif; ?>
</div>
<?php \MintyPHP\Session::getCsrfInput(); ?>
</form>
<form method="post" id="security-report-logo-delete-form" hidden>
<?php \MintyPHP\Session::getCsrfInput(); ?>
<input type="hidden" name="action" value="delete_logo">
</form>
</section>
</div>

View File

@@ -0,0 +1,31 @@
<?php
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityReportLogoService;
use MintyPHP\Support\Guard;
// Streams the report logo image directly — no view template for this route.
define('MINTY_ALLOW_OUTPUT', true);
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
$logoService = app(SecurityReportLogoService::class);
$query = requestInput()->queryAll();
$size = isset($query['size']) ? (int) $query['size'] : null;
$path = $logoService->findLogoPath($size);
if (!$path || !is_file($path)) {
http_response_code(404);
return;
}
$mime = $logoService->detectMime($path);
header('Content-Type: ' . $mime);
header('X-Content-Type-Options: nosniff');
header('Content-Security-Policy: sandbox');
header('Cache-Control: private, max-age=300');
header('Content-Length: ' . filesize($path));
readfile($path);

View File

@@ -9,6 +9,7 @@ $canManageSettings = !empty($securityNav['can_manage_settings']);
$checksActive = navActive(['security/checks', 'security/checks/create', 'security/checks/edit'], true);
$templatesActive = navActive(['security/templates', 'security/templates/create', 'security/templates/edit'], true);
$settingsActive = navActive('security/settings', true);
$reportDesignActive = navActive('security/report-design', true);
$securityNavGroups = [
[
@@ -41,6 +42,12 @@ $securityNavGroups = [
'active' => $settingsActive,
'visible' => $canManageSettings,
],
[
'label' => t('Report design'),
'path' => 'security/report-design',
'active' => $reportDesignActive,
'visible' => $canManageSettings,
],
],
],
];

View File

@@ -36,6 +36,23 @@ class SecurityCheckProcessTest extends TestCase
$this->assertContains('external_systems', $keys);
}
public function testReportStepRequiresOfficialCustomerReportField(): void
{
$process = new SecurityCheckProcess();
$fields = $process->stepFields(SecurityCheckProcess::STEP_REPORT);
$this->assertSame(
[SecurityCheckProcess::FIELD_CUSTOMER_REPORT],
array_column($fields, 'key')
);
// The owner must fill it before the final step can be completed.
$this->assertContains(
SecurityCheckProcess::FIELD_CUSTOMER_REPORT,
$process->requiredFieldKeys(SecurityCheckProcess::STEP_REPORT)
);
}
public function testRequiredFieldKeysExcludeOptionalFields(): void
{
$keys = (new SecurityCheckProcess())->requiredFieldKeys(SecurityCheckProcess::STEP_INFORMATION);

View File

@@ -128,6 +128,9 @@ class SecurityCheckServiceTest extends TestCase
'blocker_start' => '2026-07-01T08:00',
'blocker_end' => '2026-07-02T18:00',
];
$step[SecurityCheckProcess::STEP_REPORT]['fields'] = [
SecurityCheckProcess::FIELD_CUSTOMER_REPORT => 'All checks passed; no action required.',
];
$input = [
'step' => $step,
'tech' => ['tls' => ['done' => '1', 'note' => 'verified']],
@@ -143,6 +146,10 @@ class SecurityCheckServiceTest extends TestCase
$this->assertSame(77, $process_state['beauftragung']['by']);
$this->assertNotEmpty($process_state['beauftragung']['at']);
$this->assertSame('Jane', $process_state[SecurityCheckProcess::STEP_INFORMATION]['fields']['technical_contact']);
$this->assertSame(
'All checks passed; no action required.',
$process_state[SecurityCheckProcess::STEP_REPORT]['fields'][SecurityCheckProcess::FIELD_CUSTOMER_REPORT]
);
$tech_state = json_decode((string) $captured['tech_state_json'], true);
$this->assertTrue($tech_state['tls']['done']);

View File

@@ -0,0 +1,81 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityReportLogoService;
use PHPUnit\Framework\TestCase;
/**
* Service-behaviour test. As with TenantLogoServiceTest, the successful upload
* path is asserted indirectly (MIME/size guards, no directory creation on
* rejection) because move_uploaded_file() needs a real HTTP-uploaded file and
* cannot run under phpunit. The move step is covered by manual smoke testing.
*/
class SecurityReportLogoServiceTest extends TestCase
{
private SecurityReportLogoService $service;
protected function setUp(): void
{
$this->service = new SecurityReportLogoService();
}
public function testReportLogoDirIsScopedToSecurityReportLogo(): void
{
$this->assertStringEndsWith('/security/report-logo', $this->service->reportLogoDir());
}
public function testSaveUploadRejectsMissingFile(): void
{
$this->assertFalse($this->service->saveUpload([])['ok']);
}
public function testSaveUploadRejectsUploadError(): void
{
$result = $this->service->saveUpload([
'tmp_name' => '/tmp/whatever',
'error' => UPLOAD_ERR_NO_FILE,
'size' => 100,
]);
$this->assertFalse($result['ok']);
}
public function testSaveUploadRejectsOversizedFile(): void
{
$result = $this->service->saveUpload([
'tmp_name' => '/tmp/whatever',
'error' => UPLOAD_ERR_OK,
'size' => 6 * 1024 * 1024,
]);
$this->assertFalse($result['ok']);
}
public function testSaveUploadRejectsNonImageContent(): void
{
$tmp = tempnam(sys_get_temp_dir(), 'sec-logo-test');
$this->assertNotFalse($tmp);
file_put_contents($tmp, "just some plain text, not an image\n");
try {
$result = $this->service->saveUpload([
'tmp_name' => $tmp,
'error' => UPLOAD_ERR_OK,
'size' => filesize($tmp),
]);
$this->assertFalse($result['ok']);
// Rejection must happen before any directory/file is created.
$this->assertFalse(is_dir($this->service->reportLogoDir() . '/__never__'));
} finally {
@unlink($tmp);
}
}
public function testDeleteIsIdempotentWhenNothingStored(): void
{
// delete() is safe to call even when the directory does not exist.
$this->assertTrue($this->service->delete());
}
}

View File

@@ -4,6 +4,8 @@ namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
use MintyPHP\Service\Branding\BrandingLogoService;
use PHPUnit\Framework\TestCase;
@@ -28,6 +30,9 @@ class SecurityReportPdfServiceTest extends TestCase
foreach ($process->manualStepKeys() as $key) {
$processState[$key] = ['done' => true, 'at' => '2026-06-18 10:00:00'];
}
$processState[SecurityCheckProcess::STEP_REPORT]['fields'] = [
SecurityCheckProcess::FIELD_CUSTOMER_REPORT => 'No critical findings.',
];
$snapshot = ['version' => 1, 'items' => [
['type' => 'section', 'label' => 'Transport'],
['type' => 'check', 'key' => 'tls', 'label' => 'TLS enforced'],
@@ -48,6 +53,7 @@ class SecurityReportPdfServiceTest extends TestCase
'domain_no' => 'DNS-1',
'product_name' => 'Intranet',
'product_code' => 'intranet',
'owner_name' => 'Jane Doe',
'status' => SecurityCheckProcess::STATUS_IN_PROGRESS,
'process_state' => $processState,
'tech_schema_snapshot' => $snapshot,
@@ -64,6 +70,8 @@ class SecurityReportPdfServiceTest extends TestCase
$this->assertSame('D-100', $context['customer_no']);
$this->assertSame('acme.de', $context['domain']);
$this->assertSame('Intranet', $context['product']);
$this->assertSame('Jane Doe', $context['owner']);
$this->assertSame('No critical findings.', $context['customer_report']);
$this->assertNotSame('', $context['title']);
// Summary mirrors the progress math (3 tech items, 2 done).
@@ -95,6 +103,7 @@ class SecurityReportPdfServiceTest extends TestCase
$this->assertSame('', $context['customer_name']);
$this->assertSame('', $context['domain']);
$this->assertSame('', $context['customer_report']);
$this->assertSame([], $context['tech_sections']);
$this->assertSame(0, $context['summary']['total']);
$this->assertSame(0, $context['summary']['percent']);
@@ -126,4 +135,28 @@ class SecurityReportPdfServiceTest extends TestCase
$this->assertNotSame('', $pdf);
$this->assertStringStartsWith('%PDF-', $pdf);
}
public function testRenderCustomerReportPdfUsesConfiguredCustomTemplate(): void
{
$branding = $this->createMock(BrandingLogoService::class);
$branding->method('hasLogo')->willReturn(false);
$settings = $this->createMock(SecurityReportSettingsGateway::class);
$settings->method('getTemplateHtml')->willReturn(
'<!doctype html><html><body><h1>Custom {{customer_name}}</h1>{{checklist}}</body></html>'
);
$service = new SecurityReportPdfService(
new SecurityCheckProcess(),
$branding,
null,
$settings,
new SecurityReportTemplateService(),
null
);
$pdf = $service->renderCustomerReportPdf($this->enrichedCheck(), ['app_name' => 'CoreCore']);
$this->assertStringStartsWith('%PDF-', $pdf);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use PHPUnit\Framework\TestCase;
class SecurityReportSettingsGatewayTest extends TestCase
{
/**
* @param array<string, string> $values
*/
private function makeGateway(array $values = []): SecurityReportSettingsGateway
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getValue')->willReturnCallback(static fn (string $key): ?string => $values[$key] ?? null);
$metadata->method('set')->willReturn(true);
return new SecurityReportSettingsGateway($metadata);
}
public function testTemplateDefaultsToEmpty(): void
{
$gateway = $this->makeGateway();
$this->assertSame('', $gateway->getTemplateHtml());
$this->assertFalse($gateway->hasCustomTemplate());
}
public function testGetTemplateHtmlReturnsTrimmedStoredValue(): void
{
$gateway = $this->makeGateway([
SecurityReportSettingsGateway::KEY_TEMPLATE_HTML => " <h1>{{title}}</h1> ",
]);
$this->assertSame('<h1>{{title}}</h1>', $gateway->getTemplateHtml());
$this->assertTrue($gateway->hasCustomTemplate());
}
public function testSetTemplateHtmlRejectsOversizedTemplate(): void
{
$gateway = $this->makeGateway();
$tooLong = str_repeat('a', SecurityReportSettingsGateway::MAX_TEMPLATE_LENGTH + 1);
$this->assertFalse($gateway->setTemplateHtml($tooLong));
}
public function testSetTemplateHtmlPersistsValueAndClearsOnEmpty(): void
{
$writes = [];
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getValue')->willReturn(null);
$metadata->method('set')->willReturnCallback(static function (string $key, ?string $value, ?string $description = null) use (&$writes): bool {
$writes[$key] = $value;
return true;
});
$gateway = new SecurityReportSettingsGateway($metadata);
$this->assertTrue($gateway->setTemplateHtml('<p>x</p>'));
$this->assertSame('<p>x</p>', $writes[SecurityReportSettingsGateway::KEY_TEMPLATE_HTML]);
// Blank input clears the setting (restores the built-in default).
$this->assertTrue($gateway->setTemplateHtml(' '));
$this->assertNull($writes[SecurityReportSettingsGateway::KEY_TEMPLATE_HTML]);
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
use PHPUnit\Framework\TestCase;
class SecurityReportTemplateServiceTest extends TestCase
{
/**
* @return array<string, mixed>
*/
private function context(): array
{
return [
'title' => 'Security check report',
'customer_name' => '<b>Acme</b>',
'customer_no' => 'D-100',
'domain' => 'acme.de',
'product' => 'Intranet',
'owner' => 'Jane Doe',
'status_label' => 'In progress',
'generated_at' => '2026-06-22 10:00',
'app_name' => 'CoreCore',
'customer_report' => "Line one\nLine two",
'logo_data_uri' => 'data:image/png;base64,AAAA',
'summary' => ['percent' => 72, 'tech_done' => 2, 'tech_total' => 3],
'tech_sections' => [
['label' => 'Transport', 'items' => [
['label' => 'TLS enforced', 'done' => true, 'note' => 'TLS 1.3'],
['label' => 'HSTS header', 'done' => false, 'note' => ''],
]],
],
'steps' => [
['number' => 1, 'label' => 'Kickoff', 'done' => true, 'completed_at' => '2026-06-18'],
],
];
}
public function testRenderSubstitutesScalarsAndEscapesData(): void
{
$service = new SecurityReportTemplateService();
$out = $service->render(
'<h1>{{title}}</h1> who={{customer_name}} dom={{domain}} owner={{owner}} p={{percent}}% src={{logo_src}}',
$this->context()
);
$this->assertStringContainsString('<h1>Security check report</h1>', $out);
$this->assertStringContainsString('dom=acme.de', $out);
$this->assertStringContainsString('owner=Jane Doe', $out);
$this->assertStringContainsString('p=72%', $out);
$this->assertStringContainsString('src=data:image/png;base64,AAAA', $out);
// Customer data is HTML-escaped so it cannot break out of the template markup.
$this->assertStringContainsString('who=&lt;b&gt;Acme&lt;/b&gt;', $out);
$this->assertStringNotContainsString('<b>Acme</b>', $out);
}
public function testRenderCustomerReportEscapesAndPreservesLineBreaks(): void
{
$service = new SecurityReportTemplateService();
$context = $this->context();
$context['customer_report'] = "First line <script>\nSecond line";
$out = $service->render('<section>{{customer_report}}</section>', $context);
// HTML in the owner's text is escaped...
$this->assertStringContainsString('First line &lt;script&gt;', $out);
$this->assertStringNotContainsString('<script>', $out);
// ...and author line breaks survive as <br>.
$this->assertMatchesRegularExpression('/First line &lt;script&gt;<br\s*\/?>\s*Second line/', $out);
}
public function testRenderBuildsChecklistAndProcessHtml(): void
{
$service = new SecurityReportTemplateService();
$out = $service->render('{{checklist}}||{{process}}', $this->context());
$this->assertStringContainsString('Transport', $out);
$this->assertStringContainsString('TLS enforced', $out);
$this->assertStringContainsString('TLS 1.3', $out);
$this->assertStringContainsString('Kickoff', $out);
$this->assertStringContainsString('<table class="checklist">', $out);
}
public function testRenderIsSinglePassAndDoesNotReExpandInjectedTokens(): void
{
$service = new SecurityReportTemplateService();
$context = $this->context();
$context['customer_name'] = '{{title}}';
$context['title'] = 'INJECTED';
$out = $service->render('{{customer_name}}', $context);
// The value substituted for {{customer_name}} must not be re-scanned,
// so the literal token survives and {{title}} is never expanded here.
$this->assertStringContainsString('{{title}}', $out);
$this->assertStringNotContainsString('INJECTED', $out);
}
public function testRenderHandlesEmptyContextSafely(): void
{
$service = new SecurityReportTemplateService();
$out = $service->render('[{{title}}]({{checklist}})', []);
$this->assertStringContainsString('[]', $out);
// No sections -> the "no items" notice instead of a table.
$this->assertStringContainsString('class="muted"', $out);
}
public function testDefaultTemplateContainsCoreVariables(): void
{
$service = new SecurityReportTemplateService();
$html = $service->defaultTemplateHtml();
$this->assertNotSame('', $html);
$this->assertStringContainsString('{{logo_src}}', $html);
$this->assertStringContainsString('{{checklist}}', $html);
$this->assertStringContainsString('{{process}}', $html);
}
public function testAvailableVariablesExposeStructuralTokens(): void
{
$variables = (new SecurityReportTemplateService())->availableVariables();
$this->assertArrayHasKey('customer_report', $variables);
$this->assertArrayHasKey('logo_src', $variables);
$this->assertArrayHasKey('checklist', $variables);
$this->assertArrayHasKey('process', $variables);
}
}

View File

@@ -545,3 +545,58 @@
padding: var(--app-spacing);
}
}
/* --- Customer report design (logo + HTML/CSS template editor) --- */
.security-template-editor textarea {
width: 100%;
margin: 0;
font-family: var(--font-mono, ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace);
font-size: 0.8125rem;
line-height: 1.5;
tab-size: 2;
white-space: pre;
resize: vertical;
}
.security-variable-reference {
margin-top: calc(var(--app-spacing) * 1.25);
}
.security-variable-reference h4 {
margin: 0 0 0.25rem;
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-medium, 600);
}
.security-variable-list {
display: grid;
grid-template-columns: minmax(12rem, max-content) 1fr;
gap: 0.25rem 1rem;
margin: 0.5rem 0 0;
}
.security-variable-list dt {
margin: 0;
}
.security-variable-list dt code {
font-size: 0.8125rem;
white-space: nowrap;
}
.security-variable-list dd {
margin: 0;
color: var(--app-muted-color, #6a6a6a);
font-size: var(--text-sm, 0.875rem);
}
@media (max-width: 576px) {
.security-variable-list {
grid-template-columns: 1fr;
gap: 0.1rem 0;
}
.security-variable-list dd {
margin-bottom: 0.4rem;
}
}

View File

@@ -1374,12 +1374,6 @@ parameters:
count: 1
path: core/Service/User/UserTenantContextService.php
-
message: '#^Public method "MintyPHP\\Support\\Flash\:\:add\(\)" is never used$#'
identifier: public.method.unused
count: 1
path: core/Support/Flash.php
-
message: '#^Public method "MintyPHP\\Support\\Flash\:\:dismissByKey\(\)" is never used$#'
identifier: public.method.unused

View File

@@ -17,6 +17,7 @@ class ServiceLayerNoHtmlContractTest extends TestCase
/** @var list<string> */
private const EXCLUDED_FILES = [
'core/Service/User/UserAccessPdfService.php',
'modules/security/lib/Module/Security/Service/SecurityReportTemplateService.php',
];
/**