feat(helpdesk): add handover protocol schema editor to software products

Add a JSON-based schema editor as a new tab on the software product edit
page. Admins can define protocol fields (heading, paragraph, text,
textarea, number, date, checkbox, select) via a structured UI with
add/remove/reorder controls. The schema is stored as a versioned JSON
column on the product row.

Includes DB migration, server-side validation (type whitelist, key
uniqueness, key format, max 50 fields, select option checks), i18n
(DE+EN), CSS, and 10 PHPUnit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 16:44:12 +02:00
parent 364bda67a9
commit 44153f513f
11 changed files with 862 additions and 3 deletions

View File

@@ -92,6 +92,21 @@ class SoftwareProductRepository
];
}
public function updateHandoverSchema(string $code, ?string $schemaJson): bool
{
if (trim($code) === '') {
return false;
}
$result = DB::update(
'UPDATE helpdesk_software_products SET handover_protocol_schema = ? WHERE code = ?',
$schemaJson,
$code
);
return $result !== false;
}
public function updateName(string $code, string $name): bool
{
if (trim($code) === '') {

View File

@@ -28,6 +28,141 @@ class SoftwareProductService
return $this->repository->listPaged($filters);
}
private const ALLOWED_FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select'];
private const DISPLAY_ONLY_TYPES = ['heading', 'paragraph'];
private const MAX_SCHEMA_FIELDS = 50;
/**
* Validate and save a handover protocol schema for a software product.
*
* @param list<array<string, mixed>> $fields
* @return array{ok: bool, errors: array<string, string>}
*/
public function saveHandoverSchema(string $code, array $fields): array
{
$errors = [];
$product = $this->repository->findByCode($code);
if ($product === null) {
return ['ok' => false, 'errors' => ['schema' => t('Software product not found')]];
}
if ($fields === []) {
$this->repository->updateHandoverSchema($code, null);
return ['ok' => true, 'errors' => []];
}
if (count($fields) > self::MAX_SCHEMA_FIELDS) {
$errors['schema'] = t('Maximum %d fields allowed', self::MAX_SCHEMA_FIELDS);
return ['ok' => false, 'errors' => $errors];
}
$seenKeys = [];
foreach ($fields as $index => $field) {
$fieldNum = $index + 1;
$type = (string) ($field['type'] ?? '');
$label = trim((string) ($field['label'] ?? ''));
$key = trim((string) ($field['key'] ?? ''));
$isDisplayOnly = in_array($type, self::DISPLAY_ONLY_TYPES, true);
if (!in_array($type, self::ALLOWED_FIELD_TYPES, true)) {
$errors["field_{$fieldNum}_type"] = t('Field %d: unknown type "%s"', $fieldNum, $type);
continue;
}
if ($label === '') {
$errors["field_{$fieldNum}_label"] = t('Field %d: label is required', $fieldNum);
}
if (!$isDisplayOnly) {
if ($key === '') {
$errors["field_{$fieldNum}_key"] = t('Field %d: key is required', $fieldNum);
} elseif (!preg_match('/^[a-z][a-z0-9_]*$/', $key)) {
$errors["field_{$fieldNum}_key"] = t('Field %d: key must start with a letter and contain only lowercase letters, digits, and underscores', $fieldNum);
} elseif (isset($seenKeys[$key])) {
$errors["field_{$fieldNum}_key"] = t('Field %d: duplicate key "%s"', $fieldNum, $key);
} else {
$seenKeys[$key] = true;
}
}
if ($type === 'select') {
$options = $field['options'] ?? [];
if (!is_array($options) || $options === []) {
$errors["field_{$fieldNum}_options"] = t('Field %d: select must have at least one option', $fieldNum);
} else {
foreach ($options as $optIdx => $opt) {
$optNum = $optIdx + 1;
if (trim((string) ($opt['value'] ?? '')) === '') {
$errors["field_{$fieldNum}_option_{$optNum}"] = t('Field %d, option %d: value is required', $fieldNum, $optNum);
}
}
}
}
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$normalized = $this->normalizeSchemaFields($fields);
$currentSchema = $product['handover_protocol_schema'] ?? null;
$currentVersion = 0;
if ($currentSchema !== null) {
$decoded = json_decode($currentSchema, true);
$currentVersion = (int) ($decoded['version'] ?? 0);
}
$schema = [
'version' => $currentVersion + 1,
'fields' => $normalized,
];
$json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$this->repository->updateHandoverSchema($code, $json);
return ['ok' => true, 'errors' => []];
}
/**
* @param list<array<string, mixed>> $fields
* @return list<array<string, mixed>>
*/
private function normalizeSchemaFields(array $fields): array
{
$normalized = [];
foreach ($fields as $field) {
$type = (string) ($field['type'] ?? '');
$isDisplayOnly = in_array($type, self::DISPLAY_ONLY_TYPES, true);
$entry = ['type' => $type, 'label' => trim((string) ($field['label'] ?? ''))];
if (!$isDisplayOnly) {
$entry['key'] = trim((string) ($field['key'] ?? ''));
$entry['required'] = !empty($field['required']);
}
if ($type === 'select' && is_array($field['options'] ?? null)) {
$entry['options'] = [];
foreach ($field['options'] as $opt) {
$entry['options'][] = [
'value' => trim((string) ($opt['value'] ?? '')),
'label' => trim((string) ($opt['label'] ?? '')),
];
}
}
$normalized[] = $entry;
}
return $normalized;
}
/**
* Update the user-managed name field for a software product.
*