refactor(customfield): interface-based DI for repositories

Align CustomField-Schicht mit core/Repository/Access-Muster. Vier
Repositories (Tenant-Definition, Tenant-Option, User-Value, User-Value-Option)
bekommen je ein Interface, werden von static auf instance umgestellt und
ueber CustomFieldRepositoryFactory injiziert.

TenantCustomFieldService und UserCustomFieldValueService injizieren die
Repository-Interfaces statt statisch aufzurufen. CustomFieldServicesFactory
reicht Instanzen durch. AddressBookService (Modul) bekommt
TenantCustomFieldOptionRepositoryInterface per Container — Interface-Kopplung
verbessert Modul-Isolation gegenueber dem vorherigen statischen Aufruf.

Kein Verhaltenswechsel, keine SQL-Aenderung, keine Service-Signatur-Aenderung.
Alle tenant_id-Parameter erhalten (GR-SEC-009, Security-Review SR-002).

Oeffnet den Weg fuer Cluster B1 (TenantCustomFieldService-Tests) und B2
(UserCustomFieldValueService-Tests), die nun mit createMock() moeglich sind.

Nebenbei zwei PHPStan-Folge-Findings korrigiert:
- Veralteter Ignore-Eintrag fuer findById aus phpstan-baseline.neon entfernt
- Redundanter is_array-Check in TenantCustomFieldOptionRepository entfernt
  (durch typisierte Interface-Signatur abgedeckt)

Gates: QG-001 (1945 Tests, 28581 Assertions) / QG-002 / QG-003 / QG-006 pass.

Workflow: .agents/runs/CUSTOMFIELD-DI-REFACTOR-001/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 19:32:14 +02:00
parent 0da785f4b7
commit 6b05bbfe9c
18 changed files with 227 additions and 83 deletions

View File

@@ -0,0 +1,40 @@
<?php
namespace MintyPHP\Service\CustomField;
use MintyPHP\Repository\CustomField\TenantCustomFieldDefinitionRepository;
use MintyPHP\Repository\CustomField\TenantCustomFieldDefinitionRepositoryInterface;
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepository;
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepositoryInterface;
use MintyPHP\Repository\CustomField\UserCustomFieldValueOptionRepository;
use MintyPHP\Repository\CustomField\UserCustomFieldValueOptionRepositoryInterface;
use MintyPHP\Repository\CustomField\UserCustomFieldValueRepository;
use MintyPHP\Repository\CustomField\UserCustomFieldValueRepositoryInterface;
class CustomFieldRepositoryFactory
{
private ?TenantCustomFieldDefinitionRepository $tenantCustomFieldDefinitionRepository = null;
private ?TenantCustomFieldOptionRepository $tenantCustomFieldOptionRepository = null;
private ?UserCustomFieldValueRepository $userCustomFieldValueRepository = null;
private ?UserCustomFieldValueOptionRepository $userCustomFieldValueOptionRepository = null;
public function createTenantCustomFieldDefinitionRepository(): TenantCustomFieldDefinitionRepositoryInterface
{
return $this->tenantCustomFieldDefinitionRepository ??= new TenantCustomFieldDefinitionRepository();
}
public function createTenantCustomFieldOptionRepository(): TenantCustomFieldOptionRepositoryInterface
{
return $this->tenantCustomFieldOptionRepository ??= new TenantCustomFieldOptionRepository();
}
public function createUserCustomFieldValueRepository(): UserCustomFieldValueRepositoryInterface
{
return $this->userCustomFieldValueRepository ??= new UserCustomFieldValueRepository();
}
public function createUserCustomFieldValueOptionRepository(): UserCustomFieldValueOptionRepositoryInterface
{
return $this->userCustomFieldValueOptionRepository ??= new UserCustomFieldValueOptionRepository();
}
}

View File

@@ -12,17 +12,28 @@ class CustomFieldServicesFactory
public function __construct(
private readonly TenantRepository $tenantRepository,
private readonly TenantScopeService $userScopeGateway
private readonly TenantScopeService $userScopeGateway,
private readonly CustomFieldRepositoryFactory $customFieldRepositoryFactory
) {
}
public function createTenantCustomFieldService(): TenantCustomFieldService
{
return $this->tenantCustomFieldService ??= new TenantCustomFieldService($this->tenantRepository);
return $this->tenantCustomFieldService ??= new TenantCustomFieldService(
$this->tenantRepository,
$this->customFieldRepositoryFactory->createTenantCustomFieldDefinitionRepository(),
$this->customFieldRepositoryFactory->createTenantCustomFieldOptionRepository(),
);
}
public function createUserCustomFieldValueService(): UserCustomFieldValueService
{
return $this->userCustomFieldValueService ??= new UserCustomFieldValueService($this->userScopeGateway);
return $this->userCustomFieldValueService ??= new UserCustomFieldValueService(
$this->userScopeGateway,
$this->customFieldRepositoryFactory->createTenantCustomFieldDefinitionRepository(),
$this->customFieldRepositoryFactory->createTenantCustomFieldOptionRepository(),
$this->customFieldRepositoryFactory->createUserCustomFieldValueRepository(),
$this->customFieldRepositoryFactory->createUserCustomFieldValueOptionRepository(),
);
}
}

View File

@@ -2,8 +2,8 @@
namespace MintyPHP\Service\CustomField;
use MintyPHP\Repository\CustomField\TenantCustomFieldDefinitionRepository;
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepository;
use MintyPHP\Repository\CustomField\TenantCustomFieldDefinitionRepositoryInterface;
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepositoryInterface;
use MintyPHP\Repository\Tenant\TenantRepository;
class TenantCustomFieldService
@@ -11,7 +11,9 @@ class TenantCustomFieldService
private const ALLOWED_TYPES = ['text', 'textarea', 'select', 'multiselect', 'boolean', 'date'];
public function __construct(
private readonly TenantRepository $tenantRepository
private readonly TenantRepository $tenantRepository,
private readonly TenantCustomFieldDefinitionRepositoryInterface $definitionRepository,
private readonly TenantCustomFieldOptionRepositoryInterface $optionRepository
) {
}
@@ -22,14 +24,14 @@ class TenantCustomFieldService
return [];
}
$definitions = TenantCustomFieldDefinitionRepository::listByTenantId($tenantId, false);
$definitions = $this->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 = TenantCustomFieldOptionRepository::listByDefinitionIds($definitionIds, false);
$options = $this->optionRepository->listByDefinitionIds($definitionIds, false);
$optionsByDefinition = [];
foreach ($options as $option) {
$definitionId = (int) ($option['definition_id'] ?? 0);
@@ -57,18 +59,18 @@ class TenantCustomFieldService
$form = self::sanitizeForm($input);
$form['tenant_id'] = $tenantId;
$form['field_key'] = self::resolveFieldKey(
$form['field_key'] = $this->resolveFieldKey(
$tenantId,
(string) ($form['label'] ?? ''),
null,
(string) ($form['field_key'] ?? '')
);
$errors = self::validateForm($form, null);
$errors = $this->validateForm($form, null);
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
$definitionId = TenantCustomFieldDefinitionRepository::create([
$definitionId = $this->definitionRepository->create([
'tenant_id' => $tenantId,
'field_key' => $form['field_key'],
'label' => $form['label'],
@@ -76,7 +78,7 @@ class TenantCustomFieldService
'is_required' => $form['is_required'],
'is_filterable' => $form['is_filterable'],
'active' => $form['active'],
'sort_order' => $form['sort_order'] ?? self::nextSortOrder($tenantId),
'sort_order' => $form['sort_order'] ?? $this->nextSortOrder($tenantId),
'created_by' => $currentUserId > 0 ? $currentUserId : null,
]);
if (!$definitionId) {
@@ -84,7 +86,7 @@ class TenantCustomFieldService
}
if (in_array($form['type'], ['select', 'multiselect'], true)) {
TenantCustomFieldOptionRepository::replaceForDefinition((int) $definitionId, $form['options']);
$this->optionRepository->replaceForDefinition((int) $definitionId, $form['options']);
}
return ['ok' => true, 'id' => (int) $definitionId, 'form' => $form];
@@ -97,25 +99,25 @@ class TenantCustomFieldService
return ['ok' => false, 'errors' => [t('Custom field not found')]];
}
$existing = TenantCustomFieldDefinitionRepository::findByUuid($uuid);
$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'] = self::resolveFieldKey(
$form['field_key'] = $this->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));
$errors = $this->validateForm($form, (int) ($existing['id'] ?? 0));
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
$updated = TenantCustomFieldDefinitionRepository::update((int) $existing['id'], [
$updated = $this->definitionRepository->update((int) $existing['id'], [
'field_key' => $form['field_key'],
'label' => $form['label'],
'type' => $form['type'],
@@ -131,9 +133,9 @@ class TenantCustomFieldService
// 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']);
$this->optionRepository->replaceForDefinition((int) $existing['id'], $form['options']);
} else {
TenantCustomFieldOptionRepository::deleteByDefinitionId((int) $existing['id']);
$this->optionRepository->deleteByDefinitionId((int) $existing['id']);
}
return ['ok' => true, 'form' => $form];
@@ -145,11 +147,11 @@ class TenantCustomFieldService
if ($uuid === '') {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$existing = TenantCustomFieldDefinitionRepository::findByUuid($uuid);
$existing = $this->definitionRepository->findByUuid($uuid);
if (!$existing || !isset($existing['id'])) {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$deleted = TenantCustomFieldDefinitionRepository::delete((int) $existing['id']);
$deleted = $this->definitionRepository->delete((int) $existing['id']);
if (!$deleted) {
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
}
@@ -158,7 +160,7 @@ class TenantCustomFieldService
public function definitionTenantIdByUuid(string $uuid): int
{
$definition = TenantCustomFieldDefinitionRepository::findByUuid(trim($uuid));
$definition = $this->definitionRepository->findByUuid(trim($uuid));
return (int) ($definition['tenant_id'] ?? 0);
}
@@ -184,7 +186,7 @@ class TenantCustomFieldService
];
}
private static function validateForm(array &$form, ?int $excludeId): array
private function validateForm(array &$form, ?int $excludeId): array
{
$errors = [];
$tenantId = (int) ($form['tenant_id'] ?? 0);
@@ -204,7 +206,7 @@ class TenantCustomFieldService
$errors[] = t('Field type is invalid');
}
if ($fieldKey !== '') {
$existingByKey = TenantCustomFieldDefinitionRepository::findByTenantIdAndKey($tenantId, $fieldKey);
$existingByKey = $this->definitionRepository->findByTenantIdAndKey($tenantId, $fieldKey);
if ($existingByKey && (int) ($existingByKey['id'] ?? 0) !== (int) ($excludeId ?? 0)) {
$errors[] = t('Field key already exists');
}
@@ -285,10 +287,10 @@ class TenantCustomFieldService
return $value;
}
private static function nextSortOrder(int $tenantId): int
private function nextSortOrder(int $tenantId): int
{
$max = 0;
foreach (TenantCustomFieldDefinitionRepository::listByTenantId($tenantId, false) as $definition) {
foreach ($this->definitionRepository->listByTenantId($tenantId, false) as $definition) {
$sortOrder = (int) ($definition['sort_order'] ?? 0);
if ($sortOrder > $max) {
$max = $sortOrder;
@@ -300,7 +302,7 @@ class TenantCustomFieldService
return $max + 10;
}
private static function resolveFieldKey(int $tenantId, string $label, ?int $excludeId, string $preferred): string
private function resolveFieldKey(int $tenantId, string $label, ?int $excludeId, string $preferred): string
{
$excludeId = (int) ($excludeId ?? 0);
$source = trim($preferred) !== '' ? trim($preferred) : trim($label);
@@ -317,7 +319,7 @@ class TenantCustomFieldService
$candidate = $base;
$counter = 2;
while (true) {
$existing = TenantCustomFieldDefinitionRepository::findByTenantIdAndKey($tenantId, $candidate);
$existing = $this->definitionRepository->findByTenantIdAndKey($tenantId, $candidate);
if (!$existing || (int) ($existing['id'] ?? 0) === $excludeId) {
return $candidate;
}

View File

@@ -2,17 +2,21 @@
namespace MintyPHP\Service\CustomField;
use MintyPHP\Repository\CustomField\TenantCustomFieldDefinitionRepository;
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepository;
use MintyPHP\Repository\CustomField\UserCustomFieldValueOptionRepository;
use MintyPHP\Repository\CustomField\UserCustomFieldValueRepository;
use MintyPHP\Repository\CustomField\TenantCustomFieldDefinitionRepositoryInterface;
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepositoryInterface;
use MintyPHP\Repository\CustomField\UserCustomFieldValueOptionRepositoryInterface;
use MintyPHP\Repository\CustomField\UserCustomFieldValueRepositoryInterface;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Service\Tenant\TenantScopeService;
class UserCustomFieldValueService
{
public function __construct(
private readonly TenantScopeService $userScopeGateway
private readonly TenantScopeService $userScopeGateway,
private readonly TenantCustomFieldDefinitionRepositoryInterface $definitionRepository,
private readonly TenantCustomFieldOptionRepositoryInterface $optionRepository,
private readonly UserCustomFieldValueRepositoryInterface $valueRepository,
private readonly UserCustomFieldValueOptionRepositoryInterface $valueOptionRepository
) {
}
@@ -39,7 +43,7 @@ class UserCustomFieldValueService
return [];
}
$definitions = TenantCustomFieldDefinitionRepository::listByTenantIds($tenantIds, !$includeInactive);
$definitions = $this->definitionRepository->listByTenantIds($tenantIds, !$includeInactive);
if (!$definitions) {
return [];
}
@@ -48,7 +52,7 @@ class UserCustomFieldValueService
static fn (array $definition): int => (int) ($definition['id'] ?? 0),
$definitions
), static fn (int $id): bool => $id > 0));
$options = TenantCustomFieldOptionRepository::listByDefinitionIds($definitionIds, !$includeInactive);
$options = $this->optionRepository->listByDefinitionIds($definitionIds, !$includeInactive);
$optionsByDefinition = [];
foreach ($options as $option) {
$definitionId = (int) ($option['definition_id'] ?? 0);
@@ -85,7 +89,7 @@ class UserCustomFieldValueService
return [];
}
$rows = UserCustomFieldValueRepository::listByUserAndDefinitionIds($userId, $definitionIds);
$rows = $this->valueRepository->listByUserAndDefinitionIds($userId, $definitionIds);
if (!$rows) {
return [];
}
@@ -94,7 +98,7 @@ class UserCustomFieldValueService
static fn (array $row): int => (int) ($row['id'] ?? 0),
$rows
), static fn (int $id): bool => $id > 0));
$optionIdsByValue = UserCustomFieldValueOptionRepository::listOptionIdsByValueIds($valueIds);
$optionIdsByValue = $this->valueOptionRepository->listOptionIdsByValueIds($valueIds);
$map = [];
foreach ($rows as $row) {
@@ -250,7 +254,7 @@ class UserCustomFieldValueService
$tenantIds = self::normalizeTenantIds($tenantIds);
// 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)) {
if (!$this->valueRepository->deleteByUserOutsideTenantIds($userId, $tenantIds)) {
return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]];
}
if (!$canEdit) {
@@ -270,14 +274,14 @@ class UserCustomFieldValueService
}
foreach ($prepared['prepared'] as $definitionId => $valueData) {
if (!empty($valueData['empty'])) {
if (!UserCustomFieldValueRepository::deleteByUserAndDefinitionIds($userId, [(int) $definitionId])) {
if (!$this->valueRepository->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, [
$valueId = $this->valueRepository->upsertScalarValue($userId, (int) $definitionId, [
'value_text' => null,
'value_bool' => null,
'value_date' => null,
@@ -286,13 +290,13 @@ class UserCustomFieldValueService
if (!$valueId) {
return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]];
}
if (!UserCustomFieldValueOptionRepository::replaceForValueId((int) $valueId, $valueData['option_ids'])) {
if (!$this->valueOptionRepository->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, [
$valueId = $this->valueRepository->upsertScalarValue($userId, (int) $definitionId, [
'value_text' => $valueData['value_text'],
'value_bool' => $valueData['value_bool'],
'value_date' => $valueData['value_date'],
@@ -301,7 +305,7 @@ class UserCustomFieldValueService
if (!$valueId) {
return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]];
}
if (!UserCustomFieldValueOptionRepository::replaceForValueId((int) $valueId, [])) {
if (!$this->valueOptionRepository->replaceForValueId((int) $valueId, [])) {
return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]];
}
}
@@ -419,7 +423,7 @@ class UserCustomFieldValueService
public function extractCustomFieldFilterSpec(array $query, int $currentUserId): array
{
$tenantIds = $this->userScopeGateway()->getUserTenantIds($currentUserId);
$definitions = TenantCustomFieldDefinitionRepository::listFilterableByTenantIds($tenantIds);
$definitions = $this->definitionRepository->listFilterableByTenantIds($tenantIds);
if (!$definitions) {
return ['definitions' => [], 'filters' => [], 'query' => []];
}
@@ -436,7 +440,7 @@ class UserCustomFieldValueService
$definitionIds[] = $definitionId;
}
$definitionIds = array_values(array_unique($definitionIds));
$options = TenantCustomFieldOptionRepository::listByDefinitionIds($definitionIds, true);
$options = $this->optionRepository->listByDefinitionIds($definitionIds, true);
$optionMapByDefinition = [];
foreach ($options as $option) {
$definitionId = (int) ($option['definition_id'] ?? 0);