Files
breadcrumb-the-shire/core/Service/CustomField/TenantCustomFieldService.php
fs 0e86925464 refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root
immediately communicates which code is core platform and which lives
in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the
Composer PSR-4 path mapping moves from lib/ to core/.

Module-internal lib/ directories (modules/*/lib/) are untouched.

Updated across the full stack:
- composer.json PSR-4 mapping
- bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php)
- tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer)
- 26 architecture contract tests
- enforcement-policy, guard-catalog, quality-gates
- all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/)
- bin/qa-extended.sh search paths
- .claude/settings.local.json permission paths

Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner →
Executor → Code Review (4 findings fixed) → Security Review →
Acceptance Test → Finalizer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:20:42 +02:00

333 lines
12 KiB
PHP

<?php
namespace MintyPHP\Service\CustomField;
use MintyPHP\Repository\CustomField\TenantCustomFieldDefinitionRepository;
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
class TenantCustomFieldService
{
private const ALLOWED_TYPES = ['text', 'textarea', 'select', 'multiselect', 'boolean', 'date'];
public function __construct(
private readonly TenantRepository $tenantRepository
) {
}
public function listForTenant(int $tenantId): array
{
$tenantId = (int) $tenantId;
if ($tenantId <= 0) {
return [];
}
$definitions = TenantCustomFieldDefinitionRepository::listByTenantId($tenantId, false);
if (!$definitions) {
return [];
}
$definitionIds = array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $definitions);
$definitionIds = array_values(array_filter($definitionIds, static fn (int $id): bool => $id > 0));
$options = TenantCustomFieldOptionRepository::listByDefinitionIds($definitionIds, false);
$optionsByDefinition = [];
foreach ($options as $option) {
$definitionId = (int) ($option['definition_id'] ?? 0);
if ($definitionId <= 0) {
continue;
}
$optionsByDefinition[$definitionId] ??= [];
$optionsByDefinition[$definitionId][] = $option;
}
foreach ($definitions as &$definition) {
$definitionId = (int) ($definition['id'] ?? 0);
$definition['options'] = $optionsByDefinition[$definitionId] ?? [];
}
unset($definition);
return $definitions;
}
public function createForTenant(int $tenantId, array $input, int $currentUserId): array
{
if ($tenantId <= 0 || !$this->tenantRepository->find($tenantId)) {
return ['ok' => false, 'errors' => [t('Tenant not found')]];
}
$form = self::sanitizeForm($input);
$form['tenant_id'] = $tenantId;
$form['field_key'] = self::resolveFieldKey(
$tenantId,
(string) ($form['label'] ?? ''),
null,
(string) ($form['field_key'] ?? '')
);
$errors = self::validateForm($form, null);
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
$definitionId = TenantCustomFieldDefinitionRepository::create([
'tenant_id' => $tenantId,
'field_key' => $form['field_key'],
'label' => $form['label'],
'type' => $form['type'],
'is_required' => $form['is_required'],
'is_filterable' => $form['is_filterable'],
'active' => $form['active'],
'sort_order' => $form['sort_order'] ?? self::nextSortOrder($tenantId),
'created_by' => $currentUserId > 0 ? $currentUserId : null,
]);
if (!$definitionId) {
return ['ok' => false, 'errors' => [t('Custom field can not be created')], 'form' => $form];
}
if (in_array($form['type'], ['select', 'multiselect'], true)) {
TenantCustomFieldOptionRepository::replaceForDefinition((int) $definitionId, $form['options']);
}
return ['ok' => true, 'id' => (int) $definitionId, 'form' => $form];
}
public function updateByUuid(string $uuid, array $input, int $currentUserId): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['ok' => false, 'errors' => [t('Custom field not found')]];
}
$existing = TenantCustomFieldDefinitionRepository::findByUuid($uuid);
if (!$existing) {
return ['ok' => false, 'errors' => [t('Custom field not found')]];
}
$form = self::sanitizeForm($input);
$form['tenant_id'] = (int) ($existing['tenant_id'] ?? 0);
$form['field_key'] = self::resolveFieldKey(
(int) ($existing['tenant_id'] ?? 0),
(string) ($form['label'] ?? ''),
(int) ($existing['id'] ?? 0),
(string) ($existing['field_key'] ?? '')
);
$errors = self::validateForm($form, (int) ($existing['id'] ?? 0));
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
$updated = TenantCustomFieldDefinitionRepository::update((int) $existing['id'], [
'field_key' => $form['field_key'],
'label' => $form['label'],
'type' => $form['type'],
'is_required' => $form['is_required'],
'is_filterable' => $form['is_filterable'],
'active' => $form['active'],
'sort_order' => $form['sort_order'] ?? (int) ($existing['sort_order'] ?? 100),
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
]);
if (!$updated) {
return ['ok' => false, 'errors' => [t('Custom field can not be updated')], 'form' => $form];
}
// When type is changed away from select/multiselect, remove orphaned option rows.
if (in_array($form['type'], ['select', 'multiselect'], true)) {
TenantCustomFieldOptionRepository::replaceForDefinition((int) $existing['id'], $form['options']);
} else {
TenantCustomFieldOptionRepository::deleteByDefinitionId((int) $existing['id']);
}
return ['ok' => true, 'form' => $form];
}
public function deleteByUuid(string $uuid): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$existing = TenantCustomFieldDefinitionRepository::findByUuid($uuid);
if (!$existing || !isset($existing['id'])) {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$deleted = TenantCustomFieldDefinitionRepository::delete((int) $existing['id']);
if (!$deleted) {
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
}
return ['ok' => true, 'definition' => $existing];
}
public function definitionTenantIdByUuid(string $uuid): int
{
$definition = TenantCustomFieldDefinitionRepository::findByUuid(trim($uuid));
return (int) ($definition['tenant_id'] ?? 0);
}
private static function sanitizeForm(array $input): array
{
$label = trim((string) ($input['label'] ?? ''));
$fieldKeyInput = trim((string) ($input['field_key'] ?? ''));
$type = strtolower(trim((string) ($input['type'] ?? 'text')));
$sortOrderRaw = $input['sort_order'] ?? null;
$sortOrder = is_numeric($sortOrderRaw) ? (int) $sortOrderRaw : null;
return [
'label' => $label,
'field_key' => self::normalizeKey($fieldKeyInput),
'type' => $type,
// User custom fields are always optional in V1.
'is_required' => 0,
'is_filterable' => !empty($input['is_filterable']) ? 1 : 0,
'active' => array_key_exists('active', $input) ? (int) (!empty($input['active'])) : 1,
'sort_order' => $sortOrder !== null && $sortOrder > 0 ? $sortOrder : null,
'options' => self::parseOptionsLines((string) ($input['options_text'] ?? '')),
'options_text' => (string) ($input['options_text'] ?? ''),
];
}
private static function validateForm(array &$form, ?int $excludeId): array
{
$errors = [];
$tenantId = (int) ($form['tenant_id'] ?? 0);
$type = (string) ($form['type'] ?? '');
$fieldKey = (string) ($form['field_key'] ?? '');
if ($tenantId <= 0) {
$errors[] = t('Tenant not found');
}
if ($form['label'] === '') {
$errors[] = t('Label cannot be empty');
}
if ($fieldKey === '') {
$errors[] = t('Field key cannot be empty');
}
if (!in_array($type, self::ALLOWED_TYPES, true)) {
$errors[] = t('Field type is invalid');
}
if ($fieldKey !== '') {
$existingByKey = TenantCustomFieldDefinitionRepository::findByTenantIdAndKey($tenantId, $fieldKey);
if ($existingByKey && (int) ($existingByKey['id'] ?? 0) !== (int) ($excludeId ?? 0)) {
$errors[] = t('Field key already exists');
}
}
if (in_array($type, ['select', 'multiselect'], true)) {
if (!$form['options']) {
$errors[] = t('Options are required for this field type');
}
} else {
$form['is_filterable'] = in_array($type, ['boolean', 'date'], true)
? (int) ($form['is_filterable'] ?? 0)
: 0;
$form['options'] = [];
}
// Text/textarea types can't be used as filters — too many distinct values to enumerate in UI.
if (!in_array($type, ['select', 'multiselect', 'boolean', 'date'], true)) {
$form['is_filterable'] = 0;
}
return $errors;
}
// Line format: "key|Label" or just "Label" (key is derived from the label when omitted).
// Duplicate keys are silently dropped — first occurrence wins.
private static function parseOptionsLines(string $value): array
{
$lines = preg_split('/\r\n|\r|\n/', $value);
if (!is_array($lines)) {
return [];
}
$options = [];
$sortOrder = 10;
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
$parts = explode('|', $line, 2);
$label = trim((string) ($parts[1] ?? $parts[0]));
$keyInput = trim((string) ($parts[1] ?? '') !== '' ? $parts[0] : $label);
$optionKey = self::normalizeKey($keyInput);
if ($label === '' || $optionKey === '') {
continue;
}
if (isset($options[$optionKey])) {
continue;
}
$options[$optionKey] = [
'option_key' => $optionKey,
'label' => $label,
'active' => 1,
'sort_order' => $sortOrder,
];
$sortOrder += 10;
}
return array_values($options);
}
// Transliterates accented characters (é → e, ü → u) before slugifying so non-ASCII labels
// produce readable keys. Falls back to direct slugification if iconv is unavailable.
private static function normalizeKey(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if (function_exists('iconv')) {
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
if (is_string($ascii) && $ascii !== '') {
$value = $ascii;
}
}
$value = strtolower($value);
$value = preg_replace('/[^a-z0-9]+/', '_', $value);
$value = trim((string) $value, '_');
return $value;
}
private static function nextSortOrder(int $tenantId): int
{
$max = 0;
foreach (TenantCustomFieldDefinitionRepository::listByTenantId($tenantId, false) as $definition) {
$sortOrder = (int) ($definition['sort_order'] ?? 0);
if ($sortOrder > $max) {
$max = $sortOrder;
}
}
if ($max <= 0) {
return 100;
}
return $max + 10;
}
private static function resolveFieldKey(int $tenantId, string $label, ?int $excludeId, string $preferred): string
{
$excludeId = (int) ($excludeId ?? 0);
$source = trim($preferred) !== '' ? trim($preferred) : trim($label);
$base = self::normalizeKey($source);
if ($base === '') {
$fallbackSeed = trim($label) !== '' ? trim($label) : $source;
$base = 'field_' . substr(sha1($fallbackSeed), 0, 8);
}
if ($tenantId <= 0) {
return '';
}
// Auto-increment suffix until a unique key is found; 10000 guard prevents infinite loop.
$candidate = $base;
$counter = 2;
while (true) {
$existing = TenantCustomFieldDefinitionRepository::findByTenantIdAndKey($tenantId, $candidate);
if (!$existing || (int) ($existing['id'] ?? 0) === $excludeId) {
return $candidate;
}
$candidate = $base . '_' . $counter;
$counter++;
if ($counter > 10000) {
return '';
}
}
}
}