true, 'errors' => []]; } $tenantIds = self::normalizeTenantIds($tenantIds); if (!$tenantIds) { return ['ok' => true, 'errors' => []]; } $prepared = self::prepareForSync($tenantIds, $post); if ($prepared['errors']) { return ['ok' => false, 'errors' => $prepared['errors']]; } return ['ok' => true, 'errors' => []]; } public static function buildDefinitionsByTenant(array $tenantIds, bool $includeInactive = false): array { $tenantIds = self::normalizeTenantIds($tenantIds); if (!$tenantIds) { return []; } $definitions = TenantCustomFieldDefinitionRepository::listByTenantIds($tenantIds, !$includeInactive); if (!$definitions) { return []; } $definitionIds = array_values(array_filter(array_map( static fn (array $definition): int => (int) ($definition['id'] ?? 0), $definitions ), static fn (int $id): bool => $id > 0)); $options = TenantCustomFieldOptionRepository::listByDefinitionIds($definitionIds, !$includeInactive); $optionsByDefinition = []; foreach ($options as $option) { $definitionId = (int) ($option['definition_id'] ?? 0); if ($definitionId <= 0) { continue; } $optionsByDefinition[$definitionId] ??= []; $optionsByDefinition[$definitionId][] = $option; } $grouped = []; foreach ($definitions as $definition) { $tenantId = (int) ($definition['tenant_id'] ?? 0); $definitionId = (int) ($definition['id'] ?? 0); if ($tenantId <= 0 || $definitionId <= 0) { continue; } $definition['options'] = $optionsByDefinition[$definitionId] ?? []; $grouped[$tenantId] ??= []; $grouped[$tenantId][] = $definition; } return $grouped; } public static function buildUserValueMap(int $userId, array $definitionIds): array { if ($userId <= 0) { return []; } $definitionIds = array_values(array_unique(array_map('intval', $definitionIds))); $definitionIds = array_values(array_filter($definitionIds, static fn ($id) => $id > 0)); if (!$definitionIds) { return []; } $rows = UserCustomFieldValueRepository::listByUserAndDefinitionIds($userId, $definitionIds); if (!$rows) { return []; } $valueIds = array_values(array_filter(array_map( static fn (array $row): int => (int) ($row['id'] ?? 0), $rows ), static fn (int $id): bool => $id > 0)); $optionIdsByValue = UserCustomFieldValueOptionRepository::listOptionIdsByValueIds($valueIds); $map = []; foreach ($rows as $row) { $definitionId = (int) ($row['definition_id'] ?? 0); $valueId = (int) ($row['id'] ?? 0); if ($definitionId <= 0 || $valueId <= 0) { continue; } $map[$definitionId] = [ 'id' => $valueId, 'value_text' => array_key_exists('value_text', $row) ? (string) ($row['value_text'] ?? '') : '', 'value_bool' => array_key_exists('value_bool', $row) && $row['value_bool'] !== null ? (int) $row['value_bool'] : null, 'value_date' => array_key_exists('value_date', $row) ? (string) ($row['value_date'] ?? '') : '', 'option_id' => array_key_exists('option_id', $row) && $row['option_id'] !== null ? (int) $row['option_id'] : null, 'option_ids' => $optionIdsByValue[$valueId] ?? [], ]; } return $map; } /** * Build API-safe custom field payload grouped by tenant. * * @param array> $tenantSummaries Assignment tenant rows * from user assignment context, each with at least id/uuid/description/status. * @return array> */ public static function buildPublicValuesByTenant(int $userId, array $tenantSummaries, ?int $tenantScopeId = null): array { if ($userId <= 0) { return []; } $tenantScopeId = (int) ($tenantScopeId ?? 0); $orderedTenants = []; foreach ($tenantSummaries as $tenantSummary) { if (!is_array($tenantSummary)) { continue; } $tenantId = (int) ($tenantSummary['id'] ?? 0); if ($tenantId <= 0) { continue; } if ($tenantScopeId > 0 && $tenantId !== $tenantScopeId) { continue; } $orderedTenants[$tenantId] = [ 'id' => $tenantId, 'uuid' => (string) ($tenantSummary['uuid'] ?? ''), 'description' => (string) ($tenantSummary['description'] ?? ''), 'status' => (string) ($tenantSummary['status'] ?? ''), ]; } if (!$orderedTenants) { return []; } $tenantIds = array_keys($orderedTenants); $definitionsByTenant = self::buildDefinitionsByTenant($tenantIds, false); if (!$definitionsByTenant) { return []; } $definitionIds = []; foreach ($tenantIds as $tenantId) { foreach (($definitionsByTenant[$tenantId] ?? []) as $definition) { $definitionId = (int) ($definition['id'] ?? 0); if ($definitionId > 0) { $definitionIds[] = $definitionId; } } } $definitionIds = array_values(array_unique($definitionIds)); $valueMap = self::buildUserValueMap($userId, $definitionIds); $result = []; foreach ($tenantIds as $tenantId) { $definitions = $definitionsByTenant[$tenantId] ?? []; if (!$definitions) { continue; } $fields = []; foreach ($definitions as $definition) { $definitionId = (int) ($definition['id'] ?? 0); if ($definitionId <= 0) { continue; } $type = strtolower(trim((string) ($definition['type'] ?? 'text'))); $optionsRaw = is_array($definition['options'] ?? null) ? $definition['options'] : []; $optionsById = []; $optionsPublic = []; foreach ($optionsRaw as $option) { if (!is_array($option)) { continue; } $optionId = (int) ($option['id'] ?? 0); $optionKey = trim((string) ($option['option_key'] ?? '')); if ($optionId <= 0 || $optionKey === '') { continue; } $optionsById[$optionId] = $optionKey; $optionsPublic[] = [ 'option_key' => $optionKey, 'label' => (string) ($option['label'] ?? ''), ]; } $field = [ 'uuid' => (string) ($definition['uuid'] ?? ''), 'field_key' => (string) ($definition['field_key'] ?? ''), 'label' => (string) ($definition['label'] ?? ''), 'type' => $type, 'is_filterable' => !empty($definition['is_filterable']), 'value' => self::mapPublicValueByType($type, $valueMap[$definitionId] ?? null, $optionsById), ]; if (in_array($type, ['select', 'multiselect'], true)) { $field['options'] = $optionsPublic; } $fields[] = $field; } if (!$fields) { continue; } $tenant = $orderedTenants[$tenantId]; $result[] = [ 'tenant' => [ 'uuid' => (string) $tenant['uuid'], 'description' => (string) $tenant['description'], 'status' => (string) $tenant['status'], ], 'fields' => $fields, ]; } return $result; } public static function syncForUser(int $userId, array $tenantIds, array $post, bool $canEdit): array { if ($userId <= 0) { return ['ok' => false, 'errors' => [t('User not found')]]; } $tenantIds = self::normalizeTenantIds($tenantIds); // Always clean values that belong to tenants no longer assigned to the user. if (!UserCustomFieldValueRepository::deleteByUserOutsideTenantIds($userId, $tenantIds)) { return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]]; } if (!$canEdit) { return ['ok' => true, 'errors' => []]; } if (!$tenantIds) { return ['ok' => true, 'errors' => []]; } $prepared = self::prepareForSync($tenantIds, $post); if ($prepared['errors']) { return ['ok' => false, 'errors' => $prepared['errors']]; } if (!$prepared['prepared']) { return ['ok' => true, 'errors' => []]; } foreach ($prepared['prepared'] as $definitionId => $valueData) { if (!empty($valueData['empty'])) { if (!UserCustomFieldValueRepository::deleteByUserAndDefinitionIds($userId, [(int) $definitionId])) { return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]]; } continue; } if (!empty($valueData['option_ids'])) { $valueId = UserCustomFieldValueRepository::upsertScalarValue($userId, (int) $definitionId, [ 'value_text' => null, 'value_bool' => null, 'value_date' => null, 'option_id' => null, ]); if (!$valueId) { return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]]; } if (!UserCustomFieldValueOptionRepository::replaceForValueId((int) $valueId, $valueData['option_ids'])) { return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]]; } continue; } $valueId = UserCustomFieldValueRepository::upsertScalarValue($userId, (int) $definitionId, [ 'value_text' => $valueData['value_text'], 'value_bool' => $valueData['value_bool'], 'value_date' => $valueData['value_date'], 'option_id' => $valueData['option_id'], ]); if (!$valueId) { return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]]; } if (!UserCustomFieldValueOptionRepository::replaceForValueId((int) $valueId, [])) { return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]]; } } return ['ok' => true, 'errors' => []]; } private static function prepareForSync(array $tenantIds, array $post): array { $definitionsByTenant = self::buildDefinitionsByTenant($tenantIds, false); $definitions = []; foreach ($definitionsByTenant as $tenantDefinitionList) { foreach ($tenantDefinitionList as $definition) { $definitionId = (int) ($definition['id'] ?? 0); if ($definitionId > 0) { $definitions[$definitionId] = $definition; } } } if (!$definitions) { return ['prepared' => [], 'errors' => []]; } $rawValues = is_array($post['custom_field_values'] ?? null) ? $post['custom_field_values'] : []; $rawMultiValues = is_array($post['custom_field_values_multi'] ?? null) ? $post['custom_field_values_multi'] : []; $errors = []; $prepared = []; foreach ($definitions as $definitionId => $definition) { $type = strtolower((string) ($definition['type'] ?? '')); $label = (string) ($definition['label'] ?? ('#' . $definitionId)); $options = is_array($definition['options'] ?? null) ? $definition['options'] : []; $activeOptionIds = array_values(array_filter(array_map( static fn (array $option): int => !empty($option['active']) ? (int) ($option['id'] ?? 0) : 0, $options ), static fn (int $id): bool => $id > 0)); $activeOptionMap = $activeOptionIds ? array_fill_keys($activeOptionIds, true) : []; $rawScalar = $rawValues[(string) $definitionId] ?? null; $rawMulti = $rawMultiValues[(string) $definitionId] ?? []; $valueData = [ 'value_text' => null, 'value_bool' => null, 'value_date' => null, 'option_id' => null, 'option_ids' => [], 'empty' => true, ]; if (in_array($type, ['text', 'textarea'], true)) { $text = trim((string) $rawScalar); if ($text !== '') { $valueData['value_text'] = $text; $valueData['empty'] = false; } } elseif ($type === 'boolean') { $bool = self::normalizeBool($rawScalar); if ($bool === null && trim((string) $rawScalar) !== '') { $errors[] = sprintf(t('Invalid value for %s'), $label); continue; } if ($bool !== null) { $valueData['value_bool'] = $bool; $valueData['empty'] = false; } } elseif ($type === 'date') { $date = self::normalizeDate($rawScalar); if ($date === null && trim((string) $rawScalar) !== '') { $errors[] = sprintf(t('Invalid value for %s'), $label); continue; } if ($date !== null) { $valueData['value_date'] = $date; $valueData['empty'] = false; } } elseif ($type === 'select') { $optionId = (int) $rawScalar; if ($optionId > 0 && !isset($activeOptionMap[$optionId])) { $errors[] = sprintf(t('Invalid value for %s'), $label); continue; } if ($optionId > 0) { $valueData['option_id'] = $optionId; $valueData['empty'] = false; } } elseif ($type === 'multiselect') { $optionIds = RepoQuery::normalizeIdList($rawMulti); $invalid = array_values(array_filter($optionIds, static fn (int $id): bool => !isset($activeOptionMap[$id]))); if ($invalid) { $errors[] = sprintf(t('Invalid value for %s'), $label); continue; } if ($optionIds) { $valueData['option_ids'] = $optionIds; $valueData['empty'] = false; } } $prepared[$definitionId] = $valueData; } return ['prepared' => $prepared, 'errors' => $errors]; } public static function extractAddressBookFilterSpec(array $query, int $currentUserId): array { $tenantIds = (new UserScopeGateway())->getUserTenantIds($currentUserId); $definitions = TenantCustomFieldDefinitionRepository::listFilterableByTenantIds($tenantIds); if (!$definitions) { return ['definitions' => [], 'filters' => [], 'query' => []]; } $definitionMapByUuid = []; $definitionIds = []; foreach ($definitions as $definition) { $uuid = strtolower(trim((string) ($definition['uuid'] ?? ''))); $definitionId = (int) ($definition['id'] ?? 0); if ($uuid === '' || $definitionId <= 0) { continue; } $definitionMapByUuid[$uuid] = $definition; $definitionIds[] = $definitionId; } $definitionIds = array_values(array_unique($definitionIds)); $options = TenantCustomFieldOptionRepository::listByDefinitionIds($definitionIds, true); $optionMapByDefinition = []; foreach ($options as $option) { $definitionId = (int) ($option['definition_id'] ?? 0); $optionId = (int) ($option['id'] ?? 0); if ($definitionId <= 0 || $optionId <= 0) { continue; } $optionMapByDefinition[$definitionId] ??= []; $optionMapByDefinition[$definitionId][$optionId] = true; } $filters = [ 'select' => [], 'boolean' => [], 'multiselect' => [], 'date' => [], ]; $normalizedQuery = []; foreach ($query as $rawKey => $rawValue) { $key = strtolower(trim((string) $rawKey)); if ($key === '') { continue; } if (preg_match('/^cf_([a-f0-9-]{36})$/', $key, $match)) { $uuid = $match[1]; $definition = $definitionMapByUuid[$uuid] ?? null; if (!$definition) { continue; } $definitionId = (int) ($definition['id'] ?? 0); $type = strtolower((string) ($definition['type'] ?? '')); if ($type === 'select') { $optionId = (int) $rawValue; if ($optionId > 0 && !empty($optionMapByDefinition[$definitionId][$optionId])) { $filters['select'][$definitionId] = $optionId; $normalizedQuery[$key] = (string) $optionId; } } elseif ($type === 'boolean') { $bool = self::normalizeBool($rawValue); if ($bool !== null) { $filters['boolean'][$definitionId] = $bool; $normalizedQuery[$key] = (string) $bool; } } continue; } if (preg_match('/^cfm_([a-f0-9-]{36})$/', $key, $match)) { $uuid = $match[1]; $definition = $definitionMapByUuid[$uuid] ?? null; if (!$definition || strtolower((string) ($definition['type'] ?? '')) !== 'multiselect') { continue; } $definitionId = (int) ($definition['id'] ?? 0); $optionIds = RepoQuery::normalizeIdList($rawValue); if (!$optionIds) { continue; } $allowedMap = $optionMapByDefinition[$definitionId] ?? []; $optionIds = array_values(array_filter($optionIds, static fn (int $id): bool => isset($allowedMap[$id]))); if (!$optionIds) { continue; } $filters['multiselect'][$definitionId] = $optionIds; $normalizedQuery[$key] = implode(',', $optionIds); continue; } if (preg_match('/^cfd_([a-f0-9-]{36})_(from|to)$/', $key, $match)) { $uuid = $match[1]; $bound = $match[2]; $definition = $definitionMapByUuid[$uuid] ?? null; if (!$definition || strtolower((string) ($definition['type'] ?? '')) !== 'date') { continue; } $date = self::normalizeDate($rawValue); if ($date === null) { continue; } $definitionId = (int) ($definition['id'] ?? 0); $filters['date'][$definitionId] ??= ['from' => null, 'to' => null]; $filters['date'][$definitionId][$bound] = $date; $normalizedQuery[$key] = $date; } } return [ 'definitions' => array_values($definitionMapByUuid), 'filters' => $filters, 'query' => $normalizedQuery, ]; } private static function normalizeTenantIds(array $tenantIds): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); return array_values(array_filter($tenantIds, static fn ($id) => $id > 0)); } private static function normalizeBool($value): ?int { if (is_bool($value)) { return $value ? 1 : 0; } $value = strtolower(trim((string) $value)); if ($value === '') { return null; } if (in_array($value, ['1', 'true', 'yes', 'on'], true)) { return 1; } if (in_array($value, ['0', 'false', 'no', 'off'], true)) { return 0; } return null; } private static function normalizeDate($value): ?string { $value = trim((string) $value); if ($value === '') { return null; } $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value); if (!$dt || $dt->format('Y-m-d') !== $value) { return null; } return $value; } private static function mapPublicValueByType(string $type, ?array $valueData, array $optionsById) { $hasValue = is_array($valueData); if ($type === 'multiselect') { if (!$hasValue) { return []; } $optionIds = is_array($valueData['option_ids'] ?? null) ? $valueData['option_ids'] : []; $keys = []; foreach ($optionIds as $optionIdRaw) { $optionId = (int) $optionIdRaw; if ($optionId > 0 && isset($optionsById[$optionId])) { $keys[] = (string) $optionsById[$optionId]; } } return array_values(array_unique($keys)); } if (!$hasValue) { return null; } return match ($type) { 'boolean' => array_key_exists('value_bool', $valueData) && $valueData['value_bool'] !== null ? ((int) $valueData['value_bool'] === 1) : null, 'date' => (($date = trim((string) ($valueData['value_date'] ?? ''))) !== '' ? $date : null), 'select' => (($optionId = (int) ($valueData['option_id'] ?? 0)) > 0 && isset($optionsById[$optionId])) ? (string) $optionsById[$optionId] : null, default => (($text = (string) ($valueData['value_text'] ?? '')) !== '' ? $text : null), }; } }