1
0
Files
breadcrumb-the-shire/lib/Service/CustomField/UserCustomFieldValueService.php

605 lines
24 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Service\CustomField;
use MintyPHP\Repository\CustomField\TenantCustomFieldDefinitionRepository;
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepository;
use MintyPHP\Repository\CustomField\UserCustomFieldValueOptionRepository;
use MintyPHP\Repository\CustomField\UserCustomFieldValueRepository;
2026-02-23 12:58:19 +01:00
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Service\Tenant\TenantScopeService;
class UserCustomFieldValueService
{
2026-03-04 15:56:58 +01:00
public function __construct(
private readonly TenantScopeService $userScopeGateway
2026-03-04 15:56:58 +01:00
) {
}
public function validateForTenants(array $tenantIds, array $post, bool $canEdit): array
{
if (!$canEdit) {
return ['ok' => true, 'errors' => []];
}
$tenantIds = self::normalizeTenantIds($tenantIds);
if (!$tenantIds) {
return ['ok' => true, 'errors' => []];
}
2026-03-04 15:56:58 +01:00
$prepared = $this->prepareForSync($tenantIds, $post);
if ($prepared['errors']) {
return ['ok' => false, 'errors' => $prepared['errors']];
}
return ['ok' => true, 'errors' => []];
}
2026-03-04 15:56:58 +01:00
public 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;
}
2026-03-04 15:56:58 +01:00
public 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<int, array<string, mixed>> $tenantSummaries Assignment tenant rows
2026-02-23 12:58:19 +01:00
* from user assignment context, each with at least id/uuid/description/status.
* @return array<int, array<string, mixed>>
*/
2026-03-04 15:56:58 +01:00
public 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);
2026-03-04 15:56:58 +01:00
$definitionsByTenant = $this->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));
2026-03-04 15:56:58 +01:00
$valueMap = $this->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' => [
2026-02-23 12:58:19 +01:00
'uuid' => (string) $tenant['uuid'],
'description' => (string) $tenant['description'],
'status' => (string) $tenant['status'],
],
'fields' => $fields,
];
}
return $result;
}
2026-03-04 15:56:58 +01:00
public 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);
2026-03-06 00:44:52 +01:00
// Always clean values for tenants no longer assigned — happens even when !$canEdit,
// because orphaned custom field data should not persist after tenant reassignment.
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' => []];
}
2026-03-04 15:56:58 +01:00
$prepared = $this->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' => []];
}
2026-03-04 15:56:58 +01:00
private function prepareForSync(array $tenantIds, array $post): array
{
2026-03-04 15:56:58 +01:00
$definitionsByTenant = $this->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];
}
2026-03-04 15:56:58 +01:00
public function extractAddressBookFilterSpec(array $query, int $currentUserId): array
{
2026-03-04 15:56:58 +01:00
$tenantIds = $this->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;
}
2026-03-06 00:44:52 +01:00
// URL key prefixes: cf_ = single-value (select/boolean), cfm_ = multiselect, cfd_ = date range.
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 function userScopeGateway(): TenantScopeService
2026-03-04 15:56:58 +01:00
{
return $this->userScopeGateway;
}
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),
};
}
}