definitionRepository->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 = $this->optionRepository->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'] = $this->resolveFieldKey( $tenantId, (string) ($form['label'] ?? ''), null, (string) ($form['field_key'] ?? '') ); $errors = $this->validateForm($form, null); if ($errors) { return ['ok' => false, 'errors' => $errors, 'form' => $form]; } $definitionId = $this->definitionRepository->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'] ?? $this->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)) { $this->optionRepository->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 = $this->definitionRepository->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'] = $this->resolveFieldKey( (int) ($existing['tenant_id'] ?? 0), (string) ($form['label'] ?? ''), (int) ($existing['id'] ?? 0), (string) ($existing['field_key'] ?? '') ); $errors = $this->validateForm($form, (int) ($existing['id'] ?? 0)); if ($errors) { return ['ok' => false, 'errors' => $errors, 'form' => $form]; } $updated = $this->definitionRepository->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)) { $this->optionRepository->replaceForDefinition((int) $existing['id'], $form['options']); } else { $this->optionRepository->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 = $this->definitionRepository->findByUuid($uuid); if (!$existing || !isset($existing['id'])) { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } $deleted = $this->definitionRepository->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 = $this->definitionRepository->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 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 = $this->definitionRepository->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 function nextSortOrder(int $tenantId): int { $max = 0; foreach ($this->definitionRepository->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 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 = $this->definitionRepository->findByTenantIdAndKey($tenantId, $candidate); if (!$existing || (int) ($existing['id'] ?? 0) === $excludeId) { return $candidate; } $candidate = $base . '_' . $counter; $counter++; if ($counter > 10000) { return ''; } } } }